From 72c279d65ee42e17c78364b7e2ffe786e4b49867 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 1 Oct 2025 13:11:26 +0200 Subject: [PATCH] Various Playergroup fixes (#2444) --- music_assistant/controllers/music.py | 12 + .../controllers/players/__init__.py | 21 + .../player_controller.py} | 57 +- .../controllers/players/sync_groups.py | 590 ++++++++++++++++++ music_assistant/controllers/streams.py | 15 +- music_assistant/helpers/audio.py | 2 +- music_assistant/mass.py | 15 +- music_assistant/models/player.py | 542 ++-------------- music_assistant/models/player_provider.py | 51 +- .../_demo_player_provider/__init__.py | 2 - music_assistant/providers/airplay/__init__.py | 3 - .../providers/bluesound/__init__.py | 2 - music_assistant/providers/deezer/__init__.py | 3 +- .../providers/musiccast/__init__.py | 3 - .../__init__.py | 0 .../constants.py | 0 .../helpers.py | 0 .../{podcast-index => podcast_index}/icon.svg | 0 .../icon_monochrome.svg | 0 .../manifest.json | 4 +- .../provider.py | 0 .../providers/resonate/provider.py | 2 - .../providers/snapcast/__init__.py | 3 - music_assistant/providers/sonos/__init__.py | 3 - music_assistant/providers/sonos/player.py | 68 +- .../providers/sonos_s1/__init__.py | 3 - .../providers/squeezelite/__init__.py | 3 - .../providers/universal_group/player.py | 3 +- pyproject.toml | 12 +- 29 files changed, 768 insertions(+), 651 deletions(-) create mode 100644 music_assistant/controllers/players/__init__.py rename music_assistant/controllers/{players.py => players/player_controller.py} (97%) create mode 100644 music_assistant/controllers/players/sync_groups.py rename music_assistant/providers/{podcast-index => podcast_index}/__init__.py (100%) rename music_assistant/providers/{podcast-index => podcast_index}/constants.py (100%) rename music_assistant/providers/{podcast-index => podcast_index}/helpers.py (100%) rename music_assistant/providers/{podcast-index => podcast_index}/icon.svg (100%) rename music_assistant/providers/{podcast-index => podcast_index}/icon_monochrome.svg (100%) rename music_assistant/providers/{podcast-index => podcast_index}/manifest.json (86%) rename music_assistant/providers/{podcast-index => podcast_index}/provider.py (100%) diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index 33e609d0..a5e79c0a 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -170,6 +170,18 @@ class MusicController(CoreController): if self.database: await self.database.close() + async def on_provider_loaded(self, provider: MusicProvider) -> None: + """Handle logic when a provider is loaded.""" + await self.schedule_provider_sync(provider.instance_id) + + async def on_provider_unload(self, provider: MusicProvider) -> None: + """Handle logic when a provider is (about to get) unloaded.""" + # make sure to stop any running sync tasks first + for sync_task in self.in_progress_syncs: + if sync_task.provider_instance == provider.instance_id: + if sync_task.task: + sync_task.task.cancel() + @property def providers(self) -> list[MusicProvider]: """Return all loaded/running MusicProviders (instances).""" diff --git a/music_assistant/controllers/players/__init__.py b/music_assistant/controllers/players/__init__.py new file mode 100644 index 00000000..455198a9 --- /dev/null +++ b/music_assistant/controllers/players/__init__.py @@ -0,0 +1,21 @@ +""" +MusicAssistant PlayerController. + +Handles all logic to control supported players, +which are provided by Player Providers. + +Note that the PlayerController has a concept of a 'player' and a 'playerstate'. +The Player is the actual object that is provided by the provider, +which incorporates the actual state of the player (e.g. volume, state, etc) +and functions for controlling the player (e.g. play, pause, etc). + +The playerstate is the (final) state of the player, including any user customizations +and transformations that are applied to the player. +The playerstate is the object that is exposed to the outside world (via the API). +""" + +from __future__ import annotations + +from .player_controller import PlayerController + +__all__ = ["PlayerController"] diff --git a/music_assistant/controllers/players.py b/music_assistant/controllers/players/player_controller.py similarity index 97% rename from music_assistant/controllers/players.py rename to music_assistant/controllers/players/player_controller.py index f81b91d9..0c260c2c 100644 --- a/music_assistant/controllers/players.py +++ b/music_assistant/controllers/players/player_controller.py @@ -20,7 +20,7 @@ import asyncio import functools import time from contextlib import suppress -from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, cast +from typing import TYPE_CHECKING, Any, Concatenate, TypedDict, cast from music_assistant_models.constants import ( PLAYER_CONTROL_FAKE, @@ -65,7 +65,6 @@ from music_assistant.constants import ( CONF_PLAYERS, CONF_PRE_ANNOUNCE_CHIME_URL, ) -from music_assistant.controllers.streams import AnnounceData from music_assistant.helpers.api import api_command from music_assistant.helpers.tags import async_parse_tags from music_assistant.helpers.throttle_retry import Throttler @@ -75,6 +74,8 @@ from music_assistant.models.player import Player, PlayerMedia, PlayerState from music_assistant.models.player_provider import PlayerProvider from music_assistant.models.plugin import PluginProvider, PluginSource +from .sync_groups import SyncGroupController, SyncGroupPlayer + if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Coroutine, Iterator @@ -84,18 +85,21 @@ if TYPE_CHECKING: CACHE_CATEGORY_PLAYER_POWER = 1 -_PlayerControllerT = TypeVar("_PlayerControllerT", bound="PlayerController") -_R = TypeVar("_R") -_P = ParamSpec("_P") +class AnnounceData(TypedDict): + """Announcement data.""" + + announcement_url: str + pre_announce: bool + pre_announce_url: str def handle_player_command[PlayerControllerT: "PlayerController", **P, R]( - func: Callable[Concatenate[_PlayerControllerT, _P], Awaitable[_R]], -) -> Callable[Concatenate[_PlayerControllerT, _P], Coroutine[Any, Any, _R | None]]: + func: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]], +) -> Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]: """Check and log commands to players.""" @functools.wraps(func) - async def wrapper(self: _PlayerControllerT, *args: _P.args, **kwargs: _P.kwargs) -> _R | None: + async def wrapper(self: PlayerControllerT, *args: P.args, **kwargs: P.kwargs) -> R | None: """Log and handle_player_command commands to players.""" player_id = kwargs["player_id"] if "player_id" in kwargs else args[0] if (player := self._players.get(player_id)) is None or not player.available: @@ -138,6 +142,7 @@ class PlayerController(CoreController): self._poll_task: asyncio.Task | None = None self._player_throttlers: dict[str, Throttler] = {} self._announce_locks: dict[str, asyncio.Lock] = {} + self._sync_groups: SyncGroupController = SyncGroupController(self) async def setup(self, config: CoreConfig) -> None: """Async initialize of module.""" @@ -148,6 +153,16 @@ class PlayerController(CoreController): if self._poll_task and not self._poll_task.done(): self._poll_task.cancel() + async def on_provider_loaded(self, provider: PlayerProvider) -> None: + """Handle logic when a provider is loaded.""" + if ProviderFeature.SYNC_PLAYERS in provider.supported_features: + await self._sync_groups.on_provider_loaded(provider) + + async def on_provider_unload(self, provider: PlayerProvider) -> None: + """Handle logic when a provider is (about to get) unloaded.""" + if ProviderFeature.SYNC_PLAYERS in provider.supported_features: + await self._sync_groups.on_provider_unload(provider) + @property def providers(self) -> list[PlayerProvider]: """Return all loaded/running MusicProviders.""" @@ -158,6 +173,7 @@ class PlayerController(CoreController): return_unavailable: bool = True, return_disabled: bool = False, provider_filter: str | None = None, + return_sync_groups: bool = True, ) -> list[Player]: """ Return all registered players. @@ -174,6 +190,7 @@ class PlayerController(CoreController): if (player.available or return_unavailable) and (player.enabled or return_disabled) and (provider_filter is None or player.provider.lookup_key == provider_filter) + and (return_sync_groups or not isinstance(player, SyncGroupPlayer)) ] @api_command("players/all") @@ -895,8 +912,8 @@ class PlayerController(CoreController): if player.playback_state != PlaybackState.IDLE: await self.cmd_stop(player_id) await asyncio.sleep(0.5) # small delay to allow stop to process - player.active_source = None - player.current_media = None + player.state.active_source = None + player.state.current_media = None # check if source is a pluginsource # in that case the source id is the instance_id of the plugin provider if plugin_prov := self.mass.get_provider(source): @@ -1150,17 +1167,25 @@ class PlayerController(CoreController): """ Create a new (permanent) Group Player. - :param provider: The provider to create the group player for + :param provider: The provider(id) to create the group player for :param name: Name of the new group player :param members: List of player ids to add to the group :param dynamic: Whether the group is dynamic (members can change) """ if not (provider_instance := self.mass.get_provider(provider)): raise ProviderUnavailableError(f"Provider {provider} not found") - provider_instance.check_feature(ProviderFeature.CREATE_GROUP_PLAYER) provider_instance = cast("PlayerProvider", provider_instance) - # create the group player - return await provider_instance.create_group_player(name, members, dynamic) + if ProviderFeature.CREATE_GROUP_PLAYER in provider_instance.supported_features: + return await provider_instance.create_group_player(name, members, dynamic) + if ProviderFeature.SYNC_PLAYERS in provider_instance.supported_features: + # provider supports syncing but not dedicated group players + # create a sync group instead + return await self._sync_groups.create_group_player( + provider_instance, name, members, dynamic=dynamic + ) + raise UnsupportedFeaturedException( + f"Provider {provider} does not support creating group players" + ) @api_command("players/remove_group_player") async def remove_group_player(self, player_id: str) -> None: @@ -1693,7 +1718,6 @@ class PlayerController(CoreController): await self.cmd_power(config.player_id, False) elif player.playback_state != PlaybackState.IDLE: await self.cmd_stop(config.player_id) - player.available = False # if the PlayerQueue was playing, restart playback # TODO: add property to ConfigEntry if it requires a restart of playback on change elif not player_disabled and resume_queue and resume_queue.state == PlaybackState.PLAYING: @@ -1977,9 +2001,6 @@ class PlayerController(CoreController): str(err), exc_info=err if self.logger.isEnabledFor(10) else None, ) - finally: - # always update player state - self.mass.loop.call_soon(player.update_state) await asyncio.sleep(1) async def _handle_select_plugin_source( diff --git a/music_assistant/controllers/players/sync_groups.py b/music_assistant/controllers/players/sync_groups.py new file mode 100644 index 00000000..0d95ca9e --- /dev/null +++ b/music_assistant/controllers/players/sync_groups.py @@ -0,0 +1,590 @@ +""" +Controller for (provider specific) SyncGroup players. + +A SyncGroup player is a virtual player that automatically groups multiple players +together in a sync group, where one player is the sync leader +and the other players are synced to that leader. +""" + +from __future__ import annotations + +import asyncio +from copy import deepcopy +from typing import TYPE_CHECKING, cast + +import shortuuid +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption +from music_assistant_models.constants import PLAYER_CONTROL_NONE +from music_assistant_models.enums import ( + ConfigEntryType, + PlaybackState, + PlayerFeature, + PlayerType, + ProviderFeature, +) +from music_assistant_models.errors import UnsupportedFeaturedException +from music_assistant_models.player import DeviceInfo, PlayerMedia, PlayerSource +from propcache import under_cached_property as cached_property + +from music_assistant.constants import ( + CONF_CROSSFADE_DURATION, + CONF_DYNAMIC_GROUP_MEMBERS, + CONF_ENABLE_ICY_METADATA, + CONF_FLOW_MODE, + CONF_GROUP_MEMBERS, + CONF_HTTP_PROFILE, + CONF_OUTPUT_CODEC, + CONF_SAMPLE_RATES, + CONF_SMART_FADES_MODE, + SYNCGROUP_PREFIX, +) +from music_assistant.models.player import GroupPlayer, Player + +if TYPE_CHECKING: + from music_assistant.models.player_provider import PlayerProvider + + from .player_controller import PlayerController + + +SUPPORT_DYNAMIC_LEADER = { + # providers that support dynamic leader selection in a syncgroup + # meaning that if you would remove the current leader from the group, + # the provider will automatically select a new leader from the remaining members + # and the music keeps playing uninterrupted. + "airplay", + "squeezelite", + "resonate", + # TODO: Get this working with Sonos as well (need to handle range requests) +} + +OPTIONAL_FEATURES = { + PlayerFeature.ENQUEUE, + PlayerFeature.GAPLESS_PLAYBACK, + PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE, + PlayerFeature.NEXT_PREVIOUS, + PlayerFeature.PAUSE, + PlayerFeature.PLAY_ANNOUNCEMENT, + PlayerFeature.SEEK, + PlayerFeature.SELECT_SOURCE, + PlayerFeature.VOLUME_MUTE, +} + + +class SyncGroupPlayer(GroupPlayer): + """Helper class for a (provider specific) SyncGroup player.""" + + _attr_type: PlayerType = PlayerType.GROUP + sync_leader: Player | None = None + """The active sync leader player for this syncgroup.""" + + @cached_property + def is_dynamic(self) -> bool: + """Return if the player is a dynamic group player.""" + return bool(self.config.get_value(CONF_DYNAMIC_GROUP_MEMBERS, False)) + + def __init__( + self, + provider: PlayerProvider, + player_id: str, + ) -> None: + """Initialize GroupPlayer instance.""" + super().__init__(provider, player_id) + self._attr_name = self.config.name or f"SyncGroup {player_id}" + self._attr_available = True + self._attr_powered = False # group players are always powered off by default + self._attr_active_source = None + self._attr_device_info = DeviceInfo(model="Sync Group", manufacturer=provider.name) + self._attr_supported_features = { + PlayerFeature.POWER, + PlayerFeature.VOLUME_SET, + } + + async def on_config_updated(self) -> None: + """Handle logic when the player is loaded or updated.""" + # Config is only available after the player was registered + static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, [])) + self._attr_static_group_members = static_members.copy() + if not self.powered: + self._attr_group_members = static_members.copy() + if self.is_dynamic: + self._attr_supported_features.add(PlayerFeature.SET_MEMBERS) + else: + self._attr_supported_features.discard(PlayerFeature.SET_MEMBERS) + + @property + def supported_features(self) -> set[PlayerFeature]: + """Return the supported features of the player.""" + if self.sync_leader: + base_features = self._attr_supported_features.copy() + # add features supported by the sync leader + for feature in OPTIONAL_FEATURES: + if feature in self.sync_leader.supported_features: + base_features.add(feature) + return base_features + return self._attr_supported_features + + @property + def playback_state(self) -> PlaybackState: + """Return the current playback state of the player.""" + if self.power_state: + return self.sync_leader.playback_state if self.sync_leader else PlaybackState.IDLE + else: + return PlaybackState.IDLE + + @cached_property + def flow_mode(self) -> bool: + """ + Return if the player needs flow mode. + + Will by default be set to True if the player does not support PlayerFeature.ENQUEUE + or has a flow mode config entry set to True. + """ + if leader := self.sync_leader: + return leader.flow_mode + return False + + @property + def elapsed_time(self) -> float | None: + """Return the elapsed time in (fractional) seconds of the current track (if any).""" + return self.sync_leader.elapsed_time if self.sync_leader else None + + @property + def elapsed_time_last_updated(self) -> float | None: + """Return when the elapsed time was last updated.""" + return self.sync_leader.elapsed_time_last_updated if self.sync_leader else None + + @property + def current_media(self) -> PlayerMedia | None: + """Return the current media item (if any) loaded in the player.""" + return self.sync_leader.current_media if self.sync_leader else self._attr_current_media + + @property + def active_source(self) -> str | None: + """Return the active source id (if any) of the player.""" + return self._attr_active_source + + @property + def source_list(self) -> list[PlayerSource]: + """Return list of available (native) sources for this player.""" + if self.sync_leader: + return self.sync_leader.source_list + return [] + + @property + def can_group_with(self) -> set[str]: + """ + Return the id's of players this player can group with. + + This should return set of player_id's this player can group/sync with + or just the provider's instance_id if all players can group with each other. + """ + if self.is_dynamic and (leader := self.sync_leader): + return leader.can_group_with + elif self.is_dynamic: + return {self.provider.lookup_key} + else: + return set() + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the given player (if any).""" + entries: list[ConfigEntry] = [ + # default entries for player groups + *await super().get_config_entries(), + # add syncgroup specific entries + ConfigEntry( + key=CONF_GROUP_MEMBERS, + type=ConfigEntryType.STRING, + multi_value=True, + label="Group members", + default_value=[], + description="Select all players you want to be part of this group", + required=False, # needed for dynamic members (which allows empty members list) + options=[ + ConfigValueOption(x.display_name, x.player_id) + for x in self.provider.players + if x.type != PlayerType.GROUP + ], + ), + ConfigEntry( + key="dynamic_members", + type=ConfigEntryType.BOOLEAN, + label="Enable dynamic members", + description="Allow (un)joining members dynamically, so the group more or less " + "behaves the same like manually syncing players together, " + "with the main difference being that the group player will hold the queue.", + default_value=False, + required=False, + ), + ] + # combine base group entries with (base) player entries for this player type + child_player = next((x for x in self.provider.players if x.type == PlayerType.PLAYER), None) + if child_player: + allowed_conf_entries = ( + CONF_HTTP_PROFILE, + CONF_ENABLE_ICY_METADATA, + CONF_CROSSFADE_DURATION, + CONF_OUTPUT_CODEC, + CONF_FLOW_MODE, + CONF_SAMPLE_RATES, + CONF_SMART_FADES_MODE, + ) + child_config_entries = await child_player.get_config_entries() + entries.extend( + [entry for entry in child_config_entries if entry.key in allowed_conf_entries] + ) + return entries + + async def stop(self) -> None: + """Send STOP command to given player.""" + if sync_leader := self.sync_leader: + await sync_leader.stop() + + async def play(self) -> None: + """Send PLAY command to given player.""" + if sync_leader := self.sync_leader: + await sync_leader.play() + + async def pause(self) -> None: + """Send PAUSE command to given player.""" + if sync_leader := self.sync_leader: + await sync_leader.pause() + + async def power(self, powered: bool) -> None: + """Handle POWER command to group player.""" + prev_power = self._attr_powered + if powered == prev_power: + # no change + return + + # always stop at power off + if not powered and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): + await self.stop() + + # optimistically set the group state + + self._attr_powered = powered + self.update_state() + + if not prev_power and powered: + # ensure static members are present when powering on + for static_group_member in self._attr_static_group_members: + member_player = self.mass.players.get(static_group_member) + if not member_player or not member_player.available or not member_player.enabled: + if static_group_member in self._attr_group_members: + self._attr_group_members.remove(static_group_member) + continue + if static_group_member not in self._attr_group_members: + self._attr_group_members.append(static_group_member) + # Select sync leader and handle turn on + new_leader = self._select_sync_leader() + # handle TURN_ON of the group player by turning on all members + for member in self.mass.players.iter_group_members( + self, only_powered=False, active_only=False + ): + await self._handle_member_collisions(member) + if not member.powered and member.power_control != PLAYER_CONTROL_NONE: + await member.power(True) + # Set up the sync group with the new leader + await self._handle_leader_transition(new_leader) + elif prev_power and not powered: + # handle TURN_OFF of the group player by dissolving group and turning off all members + await self._dissolve_syncgroup() + # turn off all group members + for member in self.mass.players.iter_group_members( + self, only_powered=True, active_only=True + ): + if member.powered and member.power_control != PLAYER_CONTROL_NONE: + await member.power(False) + + if not powered: + # Reset to unfiltered static members list when powered off + # (the frontend will hide unavailable members) + self._attr_group_members = self._attr_static_group_members.copy() + self._attr_active_source = None + # and clear the sync leader + self.sync_leader = None + self.update_state() + + async def volume_set(self, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + # group volume is already handled in the player manager + + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on given player.""" + # power on (which will also resync if needed) + await self.power(True) + # simply forward the command to the sync leader + if sync_leader := self.sync_leader: + await sync_leader.play_media(media) + self._attr_current_media = deepcopy(media) + self._attr_active_source = media.source_id + self.update_state() + else: + raise RuntimeError("an empty group cannot play media, consider adding members first") + + async def enqueue_next_media(self, media: PlayerMedia) -> None: + """Handle enqueuing of a next media item on the player.""" + if sync_leader := self.sync_leader: + await sync_leader.enqueue_next_media(media) + + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """Handle SET_MEMBERS command on the player.""" + if not self.is_dynamic: + raise UnsupportedFeaturedException( + f"Group {self.display_name} does not allow dynamically adding/removing members!" + ) + # handle additions + final_players_to_add: list[str] = [] + for player_id in player_ids_to_add or []: + if player_id in self._attr_group_members: + continue + if player_id == self.player_id: + raise UnsupportedFeaturedException( + f"Cannot add {self.display_name} to itself as a member!" + ) + self._attr_group_members.append(player_id) + final_players_to_add.append(player_id) + # handle removals + final_players_to_remove: list[str] = [] + for player_id in player_ids_to_remove or []: + if player_id not in self._attr_group_members: + continue + if player_id == self.player_id: + raise UnsupportedFeaturedException( + f"Cannot remove {self.display_name} from itself as a member!" + ) + self._attr_group_members.remove(player_id) + final_players_to_remove.append(player_id) + self.update_state() + if not self.powered: + # Don't need to do anything else if the group is powered off + # The syncing will be done once powered on + return + next_leader = self._select_sync_leader() + prev_leader = self.sync_leader + + if prev_leader and next_leader is None: + # Edge case: we no longer have any members in the group (and thus no leader) + await self._handle_leader_transition(None) + elif prev_leader != next_leader: + # Edge case: we had changed the leader (or just got one) + await self._handle_leader_transition(next_leader) + elif self.sync_leader and (player_ids_to_add or player_ids_to_remove): + # if the group still has the same leader, we need to (re)sync the members + # Handle collisions for newly added players + for player_id in final_players_to_add: + if player := self.mass.players.get(player_id): + await self._handle_member_collisions(player) + + await self.sync_leader.set_members( + player_ids_to_add=final_players_to_add, + player_ids_to_remove=final_players_to_remove, + ) + + async def _form_syncgroup(self) -> None: + """Form syncgroup by syncing all (possible) members.""" + if self.sync_leader is None: + # This is an empty group, leader will be selected once a member is added + self._attr_group_members = [] + self.update_state() + return + # ensure the sync leader is first in the list + self._attr_group_members = [ + self.sync_leader.player_id, + *[x for x in self._attr_group_members if x != self.sync_leader.player_id], + ] + self.update_state() + members_to_sync: list[str] = [] + for member in self.mass.players.iter_group_members(self, active_only=False): + # Handle collisions before attempting to sync + await self._handle_member_collisions(member) + + if member.synced_to and member.synced_to != self.sync_leader.player_id: + # ungroup first + await member.ungroup() + if member.player_id == self.sync_leader.player_id: + # skip sync leader + continue + if ( + member.synced_to == self.sync_leader.player_id + and member.player_id in self.sync_leader.group_members + ): + # already synced + continue + members_to_sync.append(member.player_id) + if members_to_sync: + await self.sync_leader.set_members(members_to_sync) + + async def _dissolve_syncgroup(self) -> None: + """Dissolve the current syncgroup by ungrouping all members and restoring leader queue.""" + if sync_leader := self.sync_leader: + # dissolve the temporary syncgroup from the sync leader + sync_children = [x for x in sync_leader.group_members if x != sync_leader.player_id] + if sync_children: + await sync_leader.set_members(player_ids_to_remove=sync_children) + # Reset the leaders queue since it is no longer part of this group + sync_leader.update_state() + + async def _handle_leader_transition(self, new_leader: Player | None) -> None: + """Handle transition from current leader to new leader.""" + prev_leader = self.sync_leader + was_playing = False + + if ( + prev_leader + and new_leader + and prev_leader != new_leader + and self.provider.domain in SUPPORT_DYNAMIC_LEADER + ): + # provider supports dynamic leader selection, so just remove/add members + await prev_leader.ungroup() + self.sync_leader = new_leader + # allow some time to propagate the changes before resyncing + await asyncio.sleep(2) + await self._form_syncgroup() + return + + if prev_leader: + # Save current media and playback state for potential restart + was_playing = self.playback_state == PlaybackState.PLAYING + # Stop current playback and dissolve existing group + await self.stop() + await self._dissolve_syncgroup() + # allow some time to propagate the changes before resyncing + await asyncio.sleep(2) + + # Set new leader + self.sync_leader = new_leader + + if new_leader: + # form a syncgroup with the new leader + await self._form_syncgroup() + + # Restart playback if requested and we have media to play + if was_playing and self.current_media is not None: + await new_leader.play_media(self.current_media) + + def _select_sync_leader(self) -> Player | None: + """Select the active sync leader player for a syncgroup.""" + if self.sync_leader and self.sync_leader.player_id in self.group_members: + # Don't change the sync leader if we already have one + return self.sync_leader + for prefer_sync_leader in (True, False): + for child_player in self.mass.players.iter_group_members(self): + if prefer_sync_leader and child_player.synced_to: + # prefer the first player that already has sync children + continue + if child_player.active_group not in ( + None, + self.player_id, + child_player.player_id, + ): + # this should not happen (because its already handled in the power on logic), + # but guard it just in case bad things happen + continue + return child_player + return None + + async def _handle_member_collisions(self, member: Player) -> None: + """Handle collisions when adding a member to the sync group.""" + active_groups = member.active_groups + for group in active_groups: + if group == self.player_id: + continue + # collision: child player is part another group that is already active ! + # solve this by trying to leave the group first + if other_group := self.mass.players.get(group): + if ( + other_group.supports_feature(PlayerFeature.SET_MEMBERS) + and member.player_id not in other_group.static_group_members + ): + await other_group.set_members(player_ids_to_remove=[member.player_id]) + else: + # if the other group does not support SET_MEMBERS or it is a static + # member, we need to power it off to leave the group + await other_group.power(False) + if ( + member.synced_to is not None + and member.synced_to != self.sync_leader + and (synced_to_player := self.mass.players.get(member.synced_to)) + and member.player_id in synced_to_player.group_members + ): + # collision: child player is synced to another player and still in that group + # ungroup it first + await synced_to_player.set_members(player_ids_to_remove=[member.player_id]) + + +class SyncGroupController: + """Controller managing SyncGroup players.""" + + def __init__(self, player_controller: PlayerController) -> None: + """Initialize SyncGroupController.""" + self.player_controller = player_controller + self.mass = player_controller.mass + + async def create_group_player( + self, provider: PlayerProvider, name: str, members: list[str], dynamic: bool = True + ) -> Player: + """ + Create new SyncGroup Player. + + :param provider: The provider to create the group player for + :param name: Name of the group player + :param members: List of player ids to add to the group + :param dynamic: Whether the group is dynamic (members can change) + """ + # default implementation for providers that support syncing players + if ProviderFeature.SYNC_PLAYERS not in provider.supported_features: + # the frontend should already prevent this, but just in case + raise UnsupportedFeaturedException( + f"Provider {provider.name} does not support player syncing!" + ) + # Create a new syncgroup player with the given members + members = [x for x in members if x in [y.player_id for y in provider.players]] + player_id = f"{SYNCGROUP_PREFIX}{shortuuid.random(8).lower()}" + self.mass.config.create_default_player_config( + player_id=player_id, + provider=provider.lookup_key, + name=name, + enabled=True, + values={ + CONF_GROUP_MEMBERS: members, + CONF_DYNAMIC_GROUP_MEMBERS: dynamic, + }, + ) + return await self._register_syncgroup_player(player_id, provider) + + async def remove_group_player(self, player_id: str) -> None: + """ + Remove a group player. + + :param player_id: ID of the group player to remove. + """ + # we simply permanently unregister the syncgroup player and wipe its config + await self.mass.players.unregister(player_id, True) + + async def _register_syncgroup_player(self, player_id: str, provider: PlayerProvider) -> Player: + """Register a syncgroup player.""" + syncgroup = SyncGroupPlayer(provider, player_id) + await self.mass.players.register_or_update(syncgroup) + return syncgroup + + async def on_provider_loaded(self, provider: PlayerProvider) -> None: + """Handle logic when a provider is loaded.""" + # register existing syncgroup players for this provider + for player_conf in await self.mass.config.get_player_configs(provider.lookup_key): + if player_conf.player_id.startswith(SYNCGROUP_PREFIX): + await self._register_syncgroup_player(player_conf.player_id, provider) + + async def on_provider_unload(self, provider: PlayerProvider) -> None: + """Handle logic when a provider is (about to get) unloaded.""" + # unregister existing syncgroup players for this provider + for player in self.mass.players.all( + provider_filter=provider.lookup_key, return_sync_groups=True + ): + if player.player_id.startswith(SYNCGROUP_PREFIX): + await self.mass.players.unregister(player.player_id, False) diff --git a/music_assistant/controllers/streams.py b/music_assistant/controllers/streams.py index f55fb34f..55ec3da0 100644 --- a/music_assistant/controllers/streams.py +++ b/music_assistant/controllers/streams.py @@ -14,7 +14,7 @@ import os import urllib.parse from collections.abc import AsyncGenerator from dataclasses import dataclass -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING from aiofiles.os import wrap from aiohttp import web @@ -54,6 +54,7 @@ from music_assistant.constants import ( SILENCE_FILE, VERBOSE_LOG_LEVEL, ) +from music_assistant.controllers.players.player_controller import AnnounceData from music_assistant.helpers.audio import ( CACHE_FILES_IN_USE, get_chunksize, @@ -117,14 +118,6 @@ class CrossfadeData: session_id: str -class AnnounceData(TypedDict): - """Announcement data.""" - - announcement_url: str - pre_announce: bool - pre_announce_url: str - - class StreamsController(CoreController): """Webserver Controller to stream audio to players.""" @@ -1052,7 +1045,7 @@ class StreamsController(CoreController): if plugin_source.stream_type == StreamType.CUSTOM else plugin_source.path ) - player.active_source = plugin_source_id + player.state.active_source = plugin_source_id plugin_source.in_use_by = player_id try: async for chunk in get_ffmpeg_stream( @@ -1069,7 +1062,7 @@ class StreamsController(CoreController): "Finished streaming PluginSource %s to %s", plugin_source_id, player_id ) await asyncio.sleep(0.5) - player.active_source = player.player_id + player.state.active_source = player.player_id plugin_source.in_use_by = None async def get_queue_item_stream( diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index bd03bb39..5b41c5bb 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -44,10 +44,10 @@ from music_assistant.constants import ( MASS_LOGGER_NAME, VERBOSE_LOG_LEVEL, ) +from music_assistant.controllers.players.sync_groups import SyncGroupPlayer from music_assistant.helpers.json import JSON_DECODE_EXCEPTIONS, json_loads from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER from music_assistant.helpers.util import clean_stream_title, remove_file -from music_assistant.models.player import SyncGroupPlayer from .datetime import utc from .dsp import filter_to_ffmpeg_params diff --git a/music_assistant/mass.py b/music_assistant/mass.py index 5678ed03..74dcfb57 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -42,7 +42,7 @@ from music_assistant.controllers.config import ConfigController from music_assistant.controllers.metadata import MetaDataController from music_assistant.controllers.music import MusicController from music_assistant.controllers.player_queues import PlayerQueuesController -from music_assistant.controllers.players import PlayerController +from music_assistant.controllers.players.player_controller import PlayerController from music_assistant.controllers.streams import StreamsController from music_assistant.controllers.webserver import WebserverController from music_assistant.helpers.aiohttp_client import create_clientsession @@ -586,11 +586,10 @@ class MusicAssistant: if provider.manifest.mdns_discovery: for mdns_type in provider.manifest.mdns_discovery: self._aiobrowser.types.discard(mdns_type) - # make sure to stop any running sync tasks first - for sync_task in self.music.in_progress_syncs: - if sync_task.provider_instance == instance_id: - if sync_task.task: - sync_task.task.cancel() + if isinstance(provider, PlayerProvider): + await self.players.on_provider_unload(provider) + if isinstance(provider, MusicProvider): + await self.music.on_provider_unload(provider) # check if there are no other providers dependent of this provider for dep_prov in self.providers: if dep_prov.manifest.depends_on == provider.domain: @@ -717,7 +716,9 @@ class MusicAssistant: self.signal_event(EventType.PROVIDERS_UPDATED, data=self.get_providers()) await self._update_available_providers_cache() if isinstance(provider, MusicProvider): - await self.music.schedule_provider_sync(provider.instance_id) + await self.music.on_provider_loaded(provider) + if isinstance(provider, PlayerProvider): + await self.players.on_provider_loaded(provider) async def __load_provider_manifests(self) -> None: """Preload all available provider manifest files.""" diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index af671f4e..1c5b404a 100644 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -44,9 +44,6 @@ from music_assistant.constants import ( ATTR_FAKE_MUTE, ATTR_FAKE_POWER, ATTR_FAKE_VOLUME, - CONF_CROSSFADE_DURATION, - CONF_DYNAMIC_GROUP_MEMBERS, - CONF_ENABLE_ICY_METADATA, CONF_ENTRY_ANNOUNCE_VOLUME, CONF_ENTRY_ANNOUNCE_VOLUME_MAX, CONF_ENTRY_ANNOUNCE_VOLUME_MIN, @@ -72,15 +69,10 @@ from music_assistant.constants import ( CONF_ENTRY_VOLUME_NORMALIZATION_TARGET, CONF_EXPOSE_PLAYER_TO_HA, CONF_FLOW_MODE, - CONF_GROUP_MEMBERS, CONF_HIDE_PLAYER_IN_UI, - CONF_HTTP_PROFILE, CONF_MUTE_CONTROL, - CONF_OUTPUT_CODEC, CONF_POWER_CONTROL, CONF_PRE_ANNOUNCE_CHIME_URL, - CONF_SAMPLE_RATES, - CONF_SMART_FADES_MODE, CONF_VOLUME_CONTROL, ) from music_assistant.helpers.util import ( @@ -200,18 +192,6 @@ class Player(ABC): """Return if the player is available.""" return self._attr_available - @available.setter - def available(self, value: bool) -> None: - """ - Set the availability of the player. - - :param value: bool if the player is available or not. - """ - if self._attr_available != value: - self._attr_available = value - # also update the state - self._state.available = value - @property def name(self) -> str | None: """Return the name of the player.""" @@ -279,16 +259,6 @@ class Player(ABC): """Return the elapsed time in (fractional) seconds of the current track (if any).""" return self._attr_elapsed_time - @elapsed_time.setter - def elapsed_time(self, value: float | None) -> None: - """Set the elapsed time on the player.""" - if self._attr_elapsed_time != value: - self._attr_elapsed_time = value - # also update the state - self._state.elapsed_time = value - # update the last updated time - self._attr_elapsed_time_last_updated = time.time() - @property def elapsed_time_last_updated(self) -> float | None: """ @@ -350,11 +320,6 @@ class Player(ABC): """ return self._attr_active_source - @active_source.setter - def active_source(self, value: str | None) -> None: - """Set the active source of the player.""" - self._attr_active_source = value - @property def source_list(self) -> list[PlayerSource]: """Return list of available (native) sources for this player.""" @@ -365,11 +330,6 @@ class Player(ABC): """Return the current media being played by the player.""" return self._attr_current_media - @current_media.setter - def current_media(self, value: PlayerMedia | None) -> None: - """Set the current media being played by the player.""" - self._attr_current_media = value - @property def needs_poll(self) -> bool: """Return if the player needs to be polled for state updates.""" @@ -830,8 +790,7 @@ class Player(ABC): """ # if the player is grouped/synced, use the active source of the group/parent player if parent_player_id := (self.synced_to or self.active_group): - if parent_player := self.mass.players.get(parent_player_id): - return parent_player.active_source_state + return parent_player_id # in case player's source is None, return the player_id (to indicate MA is active source) return self.active_source or self.player_id @@ -1027,7 +986,7 @@ class Player(ABC): """ return bool(self._config.get_value(CONF_EXPOSE_PLAYER_TO_HA)) - @cached_property + @property @final def mass_queue_active(self) -> bool: """ @@ -1235,37 +1194,38 @@ class Player(ABC): Returns a dict with the state attributes that have changed. """ prev_state = deepcopy(self._state) - self._state.name = self.display_name - self._state.available = self.available - self._state.device_info = self.device_info - self._state.supported_features = self.supported_features - self._state.playback_state = self.playback_state - self._state.elapsed_time = self.elapsed_time - self._state.elapsed_time_last_updated = self.elapsed_time_last_updated - self._state.powered = self.power_state - self._state.volume_level = self.volume_state - self._state.volume_muted = self.volume_muted_state - self._state.group_members = UniqueList(self.group_members) - self._state.static_group_members = UniqueList(self.static_group_members) - self._state.can_group_with = self.can_group_with - self._state.synced_to = self.synced_to - self._state.active_source = self.active_source_state - self._state.source_list = self.source_list_state - self._state.active_group = self.active_group - self._state.current_media = self.current_media - self._state.enabled = self.enabled - self._state.hide_player_in_ui = self.hide_player_in_ui - self._state.expose_to_ha = self.expose_to_ha - self._state.icon = self.icon - self._state.group_volume = self.group_volume - self._state.extra_attributes = self.extra_attributes - self._state.power_control = self.power_control - self._state.volume_control = self.volume_control - self._state.mute_control = self.mute_control - - # correct available state if needed - if not self._state.enabled: - self._state.available = False + self._state = PlayerState( + player_id=self.player_id, + provider=self.provider_id, + type=self.type, + available=self.enabled and self.available, + device_info=self.device_info, + supported_features=self.supported_features, + playback_state=self.playback_state, + elapsed_time=self.elapsed_time, + elapsed_time_last_updated=self.elapsed_time_last_updated, + powered=self.powered, + volume_level=self.volume_level, + volume_muted=self.volume_muted, + group_members=UniqueList(self.group_members), + static_group_members=UniqueList(self.static_group_members), + can_group_with=self.can_group_with, + synced_to=self.synced_to, + active_source=self.active_source_state, + source_list=self.source_list_state, + active_group=self.active_group, + current_media=self.current_media, + name=self.display_name, + enabled=self.enabled, + hide_player_in_ui=self.hide_player_in_ui, + expose_to_ha=self.expose_to_ha, + icon=self.icon, + group_volume=self.group_volume, + extra_attributes=self.extra_attributes, + power_control=self.power_control, + volume_control=self.volume_control, + mute_control=self.mute_control, + ) # correct group_members if needed if self._state.group_members == [self.player_id]: @@ -1314,6 +1274,18 @@ class Player(ABC): return not self.__eq__(other) +__all__ = [ + # explicitly re-export the models we imported from the models package, + # for convenience reasons + "EXTRA_ATTRIBUTES_TYPES", + "DeviceInfo", + "Player", + "PlayerMedia", + "PlayerSource", + "PlayerState", +] + + class GroupPlayer(Player): """Helper class for a (generic) group player.""" @@ -1382,425 +1354,3 @@ class GroupPlayer(Player): # This will set the (relative) volume level on all child players. # free to override if you want to handle this differently. await self.mass.players.set_group_volume(self, volume_level) - - -class SyncGroupPlayer(GroupPlayer): - """Helper class for a (provider specific) SyncGroup player.""" - - _attr_type: PlayerType = PlayerType.GROUP - sync_leader: Player | None = None - """The active sync leader player for this syncgroup.""" - - @cached_property - def is_dynamic(self) -> bool: - """Return if the player is a dynamic group player.""" - return bool(self.config.get_value(CONF_DYNAMIC_GROUP_MEMBERS, False)) - - def __init__( - self, - provider: PlayerProvider, - player_id: str, - ) -> None: - """Initialize GroupPlayer instance.""" - super().__init__(provider, player_id) - self._attr_name = self.config.name or f"SyncGroup {player_id}" - self._attr_available = True - self._attr_powered = False # group players are always powered off by default - self._attr_active_source = player_id - self._attr_device_info = DeviceInfo(model="Sync Group", manufacturer=provider.name) - self._attr_supported_features = { - PlayerFeature.POWER, - PlayerFeature.VOLUME_SET, - } - - async def on_config_updated(self) -> None: - """Handle logic when the player is loaded or updated.""" - # Config is only available after the player was registered - static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, [])) - self._attr_static_group_members = static_members.copy() - if not self.powered: - self._attr_group_members = static_members.copy() - if self.is_dynamic: - self._attr_supported_features.add(PlayerFeature.SET_MEMBERS) - else: - self._attr_supported_features.discard(PlayerFeature.SET_MEMBERS) - - @property - def supported_features(self) -> set[PlayerFeature]: - """Return the supported features of the player.""" - return self._attr_supported_features - - @property - def playback_state(self) -> PlaybackState: - """Return the current playback state of the player.""" - if self.power_state: - return self.sync_leader.playback_state if self.sync_leader else PlaybackState.IDLE - else: - return PlaybackState.IDLE - - @cached_property - def flow_mode(self) -> bool: - """ - Return if the player needs flow mode. - - Will by default be set to True if the player does not support PlayerFeature.ENQUEUE - or has a flow mode config entry set to True. - """ - if leader := self.sync_leader: - return leader.flow_mode - return False - - @property - def elapsed_time(self) -> float | None: - """Return the elapsed time in (fractional) seconds of the current track (if any).""" - return self.sync_leader.elapsed_time if self.sync_leader else None - - @elapsed_time.setter - def elapsed_time(self, value: float | None) -> None: - """Set the elapsed time on the player.""" - raise NotImplementedError("elapsed_time is read-only on a SyncGroup player") - - @property - def elapsed_time_last_updated(self) -> float | None: - """Return when the elapsed time was last updated.""" - return self.sync_leader.elapsed_time_last_updated if self.sync_leader else None - - @property - def can_group_with(self) -> set[str]: - """ - Return the id's of players this player can group with. - - This should return set of player_id's this player can group/sync with - or just the provider's instance_id if all players can group with each other. - """ - if self.is_dynamic and (leader := self.sync_leader): - return leader.can_group_with - elif self.is_dynamic: - return {self.provider.lookup_key} - else: - return set() - - async def get_config_entries(self) -> list[ConfigEntry]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - entries: list[ConfigEntry] = [ - # default entries for player groups - *await super().get_config_entries(), - # add syncgroup specific entries - ConfigEntry( - key=CONF_GROUP_MEMBERS, - type=ConfigEntryType.STRING, - multi_value=True, - label="Group members", - default_value=[], - description="Select all players you want to be part of this group", - required=False, # needed for dynamic members (which allows empty members list) - options=[ - ConfigValueOption(x.display_name, x.player_id) - for x in self.provider.players - if x.type != PlayerType.GROUP - ], - ), - ConfigEntry( - key="dynamic_members", - type=ConfigEntryType.BOOLEAN, - label="Enable dynamic members", - description="Allow (un)joining members dynamically, so the group more or less " - "behaves the same like manually syncing players together, " - "with the main difference being that the group player will hold the queue.", - default_value=False, - required=False, - ), - ] - # combine base group entries with (base) player entries for this player type - child_player = next((x for x in self.provider.players if x.type != PlayerType.GROUP), None) - if child_player: - allowed_conf_entries = ( - CONF_HTTP_PROFILE, - CONF_ENABLE_ICY_METADATA, - CONF_CROSSFADE_DURATION, - CONF_OUTPUT_CODEC, - CONF_FLOW_MODE, - CONF_SAMPLE_RATES, - CONF_SMART_FADES_MODE, - ) - child_config_entries = await child_player.get_config_entries() - entries.extend( - [entry for entry in child_config_entries if entry.key in allowed_conf_entries] - ) - return entries - - async def stop(self) -> None: - """Send STOP command to given player.""" - if sync_leader := self.sync_leader: - await sync_leader.stop() - - async def play(self) -> None: - """Send PLAY command to given player.""" - if sync_leader := self.sync_leader: - await sync_leader.play() - - async def pause(self) -> None: - """Send PAUSE command to given player.""" - if sync_leader := self.sync_leader: - await sync_leader.pause() - - async def _handle_member_collisions(self, member: Player) -> None: - """Handle collisions when adding a member to the sync group.""" - active_groups = member.active_groups - for group in active_groups: - if group == self.player_id: - continue - # collision: child player is part another group that is already active ! - # solve this by trying to leave the group first - if other_group := self.mass.players.get(group): - if ( - other_group.supports_feature(PlayerFeature.SET_MEMBERS) - and member.player_id not in other_group.static_group_members - ): - await other_group.set_members(player_ids_to_remove=[member.player_id]) - else: - # if the other group does not support SET_MEMBERS or it is a static - # member, we need to power it off to leave the group - await other_group.power(False) - if ( - member.synced_to is not None - and member.synced_to != self.sync_leader - and (synced_to_player := self.mass.players.get(member.synced_to)) - and member.player_id in synced_to_player.group_members - ): - # collision: child player is synced to another player and still in that group - # ungroup it first - await synced_to_player.set_members(player_ids_to_remove=[member.player_id]) - - async def power(self, powered: bool) -> None: - """Handle POWER command to group player.""" - # always stop at power off - if not powered and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): - await self.stop() - - # optimistically set the group state - prev_power = self._attr_powered - self._attr_powered = powered - self.update_state() - - if powered: - # reset the group members to the available static members when powering on - self._attr_group_members = [] - for static_group_member in self._attr_static_group_members: - if ( - (member_player := self.mass.players.get(static_group_member)) - and member_player.available - and member_player.enabled - ): - self._attr_group_members.append(static_group_member) - # Select sync leader and handle turn on - new_leader = self._select_sync_leader() - # handle TURN_ON of the group player by turning on all members - for member in self.mass.players.iter_group_members( - self, only_powered=False, active_only=False - ): - await self._handle_member_collisions(member) - if not member.powered and member.power_control != PLAYER_CONTROL_NONE: - await member.power(True) - # Set up the sync group with the new leader - await self._handle_leader_transition(new_leader) - elif prev_power: - # handle TURN_OFF of the group player by dissolving group and turning off all members - await self._dissolve_syncgroup() - # turn off all group members - for member in self.mass.players.iter_group_members( - self, only_powered=True, active_only=True - ): - if member.powered and member.power_control != PLAYER_CONTROL_NONE: - await member.power(False) - - if not powered: - # Reset to unfiltered static members list when powered off - # (the frontend will hide unavailable members) - self._attr_group_members = self._attr_static_group_members.copy() - # and clear the sync leader - self.sync_leader = None - - async def _dissolve_syncgroup(self) -> None: - """Dissolve the current syncgroup by ungrouping all members and restoring leader queue.""" - if sync_leader := self.sync_leader: - # dissolve the temporary syncgroup from the sync leader - sync_children = [x for x in sync_leader.group_members if x != sync_leader.player_id] - if sync_children: - await sync_leader.set_members(player_ids_to_remove=sync_children) - # Reset the leaders queue since it is no longer part of this group - sync_leader.active_source = None - sync_leader.current_media = None - sync_leader.update_state() - - async def _handle_leader_transition(self, new_leader: Player | None) -> None: - """Handle transition from current leader to new leader.""" - prev_leader = self.sync_leader - was_playing = False - - if prev_leader: - # Save current media and playback state for potential restart - was_playing = self.playback_state == PlaybackState.PLAYING - # Stop current playback and dissolve existing group - await self.stop() - await self._dissolve_syncgroup() - - # Set new leader - self.sync_leader = new_leader - - if new_leader: - # form a syncgroup with the new leader - await self._form_syncgroup() - - # Restart playback if requested and we have media to play - if was_playing and self.current_media is not None: - await new_leader.play_media(self.current_media) - - async def volume_set(self, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - # group volume is already handled in the player manager - - async def play_media(self, media: PlayerMedia) -> None: - """Handle PLAY MEDIA on given player.""" - # power on (which will also resync if needed) - await self.power(True) - # simply forward the command to the sync leader - if sync_leader := self.sync_leader: - await sync_leader.play_media(media) - self._attr_current_media = media - self._attr_active_source = media.source_id - self.update_state() - else: - raise RuntimeError("an empty group cannot play media, consider adding members first") - - async def enqueue_next_media(self, media: PlayerMedia) -> None: - """Handle enqueuing of a next media item on the player.""" - if sync_leader := self.sync_leader: - await sync_leader.enqueue_next_media(media) - - async def set_members( - self, - player_ids_to_add: list[str] | None = None, - player_ids_to_remove: list[str] | None = None, - ) -> None: - """Handle SET_MEMBERS command on the player.""" - if not self.is_dynamic: - raise UnsupportedFeaturedException( - f"Group {self.display_name} does not allow dynamically adding/removing members!" - ) - # handle additions - final_players_to_add: list[str] = [] - for player_id in player_ids_to_add or []: - if player_id in self._attr_group_members: - continue - if player_id == self.player_id: - raise UnsupportedFeaturedException( - f"Cannot add {self.display_name} to itself as a member!" - ) - self._attr_group_members.append(player_id) - final_players_to_add.append(player_id) - # handle removals - final_players_to_remove: list[str] = [] - for player_id in player_ids_to_remove or []: - if player_id not in self._attr_group_members: - continue - if player_id == self.player_id: - raise UnsupportedFeaturedException( - f"Cannot remove {self.display_name} from itself as a member!" - ) - self._attr_group_members.remove(player_id) - final_players_to_remove.append(player_id) - self.update_state() - if not self.powered: - # Don't need to do anything else if the group is powered off - # The syncing will be done once powered on - return - next_leader = self._select_sync_leader() - prev_leader = self.sync_leader - - if prev_leader and next_leader is None: - # Edge case: we no longer have any members in the group (and thus no leader) - await self._handle_leader_transition(None) - elif prev_leader != next_leader: - # Edge case: we had changed the leader (or just got one) - await self._handle_leader_transition(next_leader) - elif self.sync_leader and (player_ids_to_add or player_ids_to_remove): - # if the group still has the same leader, we need to (re)sync the members - # Handle collisions for newly added players - for player_id in final_players_to_add: - if player := self.mass.players.get(player_id): - await self._handle_member_collisions(player) - - await self.sync_leader.set_members( - player_ids_to_add=final_players_to_add, - player_ids_to_remove=final_players_to_remove, - ) - - async def _form_syncgroup(self) -> None: - """Form syncgroup by syncing all (possible) members.""" - if self.sync_leader is None: - # This is an empty group, leader will be selected once a member is added - self._attr_group_members = [] - self.update_state() - return - # ensure the sync leader is first in the list - self._attr_group_members = [ - self.sync_leader.player_id, - *[x for x in self._attr_group_members if x != self.sync_leader.player_id], - ] - self.update_state() - members_to_sync: list[str] = [] - for member in self.mass.players.iter_group_members(self, active_only=False): - # Handle collisions before attempting to sync - await self._handle_member_collisions(member) - - if member.synced_to and member.synced_to != self.sync_leader.player_id: - # ungroup first - await member.ungroup() - if member.player_id == self.sync_leader.player_id: - # skip sync leader - continue - if ( - member.synced_to == self.sync_leader.player_id - and member.player_id in self.sync_leader.group_members - ): - # already synced - continue - members_to_sync.append(member.player_id) - if members_to_sync: - await self.sync_leader.set_members(members_to_sync) - - def _select_sync_leader(self) -> Player | None: - """Select the active sync leader player for a syncgroup.""" - if self.sync_leader and self.sync_leader.player_id in self.group_members: - # Don't change the sync leader if we already have one - return self.sync_leader - for prefer_sync_leader in (True, False): - for child_player in self.mass.players.iter_group_members(self): - if prefer_sync_leader and child_player.synced_to: - # prefer the first player that already has sync children - continue - if child_player.active_group not in ( - None, - self.player_id, - child_player.player_id, - ): - # this should not happen (because its already handled in the power on logic), - # but guard it just in case bad things happen - continue - return child_player - return None - - -__all__ = [ - # explicitly re-export the models we imported from the models package, - # for convenience reasons - "EXTRA_ATTRIBUTES_TYPES", - "DeviceInfo", - "GroupPlayer", - "Player", - "PlayerMedia", - "PlayerSource", - "PlayerState", - "SyncGroupPlayer", -] diff --git a/music_assistant/models/player_provider.py b/music_assistant/models/player_provider.py index 0f516676..5f193278 100644 --- a/music_assistant/models/player_provider.py +++ b/music_assistant/models/player_provider.py @@ -4,18 +4,9 @@ from __future__ import annotations from typing import TYPE_CHECKING -import shortuuid -from music_assistant_models.enums import ProviderFeature from zeroconf import ServiceStateChange from zeroconf.asyncio import AsyncServiceInfo -from music_assistant.constants import ( - CONF_DYNAMIC_GROUP_MEMBERS, - CONF_GROUP_MEMBERS, - SYNCGROUP_PREFIX, -) -from music_assistant.models.player import SyncGroupPlayer - from .provider import Provider if TYPE_CHECKING: @@ -59,24 +50,6 @@ class PlayerProvider(Provider): :param members: List of player ids to add to the group :param dynamic: Whether the group is dynamic (members can change) """ - # default implementation for providers that support syncing players - if ProviderFeature.SYNC_PLAYERS in self.supported_features: - # we simply create a new syncgroup player with the given members - # feel free to override or extend this method in your provider - members = [x for x in members if x in [y.player_id for y in self.players]] - player_id = f"{SYNCGROUP_PREFIX}{shortuuid.random(8).lower()}" - self.mass.config.create_default_player_config( - player_id=player_id, - provider=self.lookup_key, - name=name, - enabled=True, - values={ - CONF_GROUP_MEMBERS: members, - CONF_DYNAMIC_GROUP_MEMBERS: dynamic, - }, - ) - return await self._register_syncgroup_player(player_id) - # all other providers should implement this method raise NotImplementedError async def remove_group_player(self, player_id: str) -> None: @@ -87,14 +60,6 @@ class PlayerProvider(Provider): :param player_id: ID of the group player to remove. """ - # default implementation for providers that support syncing players - if ProviderFeature.SYNC_PLAYERS in self.supported_features and player_id.startswith( - SYNCGROUP_PREFIX - ): - # we simply permanently unregister the syncgroup player and wipe its config - await self.mass.players.unregister(player_id, True) - return - # all other providers should implement this method raise NotImplementedError async def discover_players(self) -> None: @@ -114,22 +79,8 @@ class PlayerProvider(Provider): await self.on_mdns_service_state_change( mdns_name, ServiceStateChange.Added, info ) - # discover syncgroup players - if ( - ProviderFeature.SYNC_PLAYERS in self.supported_features - and ProviderFeature.CREATE_GROUP_PLAYER in self.supported_features - ): - for player_conf in await self.mass.config.get_player_configs(self.lookup_key): - if player_conf.player_id.startswith(SYNCGROUP_PREFIX): - await self._register_syncgroup_player(player_conf.player_id) - - async def _register_syncgroup_player(self, player_id: str) -> Player: - """Register a syncgroup player.""" - syncgroup = SyncGroupPlayer(self, player_id) - await self.mass.players.register_or_update(syncgroup) - return syncgroup @property def players(self) -> list[Player]: """Return all players belonging to this provider.""" - return self.mass.players.all(provider_filter=self.lookup_key) + return self.mass.players.all(provider_filter=self.lookup_key, return_sync_groups=False) diff --git a/music_assistant/providers/_demo_player_provider/__init__.py b/music_assistant/providers/_demo_player_provider/__init__.py index 286bf0f5..1607b798 100644 --- a/music_assistant/providers/_demo_player_provider/__init__.py +++ b/music_assistant/providers/_demo_player_provider/__init__.py @@ -53,8 +53,6 @@ SUPPORTED_FEATURES = { # that your provider supports or an empty set if none. # see the ProviderFeature enum for all available features ProviderFeature.SYNC_PLAYERS, - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/airplay/__init__.py b/music_assistant/providers/airplay/__init__.py index 6d42ffeb..2bbd0283 100644 --- a/music_assistant/providers/airplay/__init__.py +++ b/music_assistant/providers/airplay/__init__.py @@ -20,9 +20,6 @@ if TYPE_CHECKING: SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, - # support sync groups by reporting create/remove player group support - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/bluesound/__init__.py b/music_assistant/providers/bluesound/__init__.py index 737abe8e..2dc7892d 100644 --- a/music_assistant/providers/bluesound/__init__.py +++ b/music_assistant/providers/bluesound/__init__.py @@ -17,8 +17,6 @@ if TYPE_CHECKING: SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/deezer/__init__.py b/music_assistant/providers/deezer/__init__.py index 0ca2b909..08bc478f 100644 --- a/music_assistant/providers/deezer/__init__.py +++ b/music_assistant/providers/deezer/__init__.py @@ -842,4 +842,5 @@ class DeezerProvider(MusicProvider): Blowfish.MODE_CBC, b"\x00\x01\x02\x03\x04\x05\x06\x07", ) - return cipher.decrypt(chunk) # type: ignore[no-any-return] + + return cipher.decrypt(chunk) # type: ignore[no-any-return,unused-ignore] diff --git a/music_assistant/providers/musiccast/__init__.py b/music_assistant/providers/musiccast/__init__.py index eab00b68..5d893e98 100644 --- a/music_assistant/providers/musiccast/__init__.py +++ b/music_assistant/providers/musiccast/__init__.py @@ -11,9 +11,6 @@ from .provider import MusicCastProvider SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, - # support sync groups by reporting create/remove player group support - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/podcast-index/__init__.py b/music_assistant/providers/podcast_index/__init__.py similarity index 100% rename from music_assistant/providers/podcast-index/__init__.py rename to music_assistant/providers/podcast_index/__init__.py diff --git a/music_assistant/providers/podcast-index/constants.py b/music_assistant/providers/podcast_index/constants.py similarity index 100% rename from music_assistant/providers/podcast-index/constants.py rename to music_assistant/providers/podcast_index/constants.py diff --git a/music_assistant/providers/podcast-index/helpers.py b/music_assistant/providers/podcast_index/helpers.py similarity index 100% rename from music_assistant/providers/podcast-index/helpers.py rename to music_assistant/providers/podcast_index/helpers.py diff --git a/music_assistant/providers/podcast-index/icon.svg b/music_assistant/providers/podcast_index/icon.svg similarity index 100% rename from music_assistant/providers/podcast-index/icon.svg rename to music_assistant/providers/podcast_index/icon.svg diff --git a/music_assistant/providers/podcast-index/icon_monochrome.svg b/music_assistant/providers/podcast_index/icon_monochrome.svg similarity index 100% rename from music_assistant/providers/podcast-index/icon_monochrome.svg rename to music_assistant/providers/podcast_index/icon_monochrome.svg diff --git a/music_assistant/providers/podcast-index/manifest.json b/music_assistant/providers/podcast_index/manifest.json similarity index 86% rename from music_assistant/providers/podcast-index/manifest.json rename to music_assistant/providers/podcast_index/manifest.json index 3b27a0b5..e4a75dc9 100644 --- a/music_assistant/providers/podcast-index/manifest.json +++ b/music_assistant/providers/podcast_index/manifest.json @@ -1,8 +1,8 @@ { - "domain": "podcast-index", + "domain": "podcast_index", "name": "Podcast Index", "description": "Discover and play podcasts using the open Podcast Index.", - "documentation": "https://music-assistant.io/music-providers/podcast-index/", + "documentation": "https://music-assistant.io/music-providers/podcast_index/", "type": "music", "requirements": [], "codeowners": "@ozgav", diff --git a/music_assistant/providers/podcast-index/provider.py b/music_assistant/providers/podcast_index/provider.py similarity index 100% rename from music_assistant/providers/podcast-index/provider.py rename to music_assistant/providers/podcast_index/provider.py diff --git a/music_assistant/providers/resonate/provider.py b/music_assistant/providers/resonate/provider.py index dea31689..d1367acb 100644 --- a/music_assistant/providers/resonate/provider.py +++ b/music_assistant/providers/resonate/provider.py @@ -58,8 +58,6 @@ class ResonateProvider(PlayerProvider): """Return the features supported by this Provider.""" return { ProviderFeature.SYNC_PLAYERS, - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } async def loaded_in_mass(self) -> None: diff --git a/music_assistant/providers/snapcast/__init__.py b/music_assistant/providers/snapcast/__init__.py index 9deef506..51095efb 100644 --- a/music_assistant/providers/snapcast/__init__.py +++ b/music_assistant/providers/snapcast/__init__.py @@ -36,9 +36,6 @@ from music_assistant.providers.snapcast.provider import SnapCastProvider SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, ProviderFeature.REMOVE_PLAYER, - # support sync groups by reporting create/remove player group support - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/sonos/__init__.py b/music_assistant/providers/sonos/__init__.py index 796ac880..cf5d720e 100644 --- a/music_assistant/providers/sonos/__init__.py +++ b/music_assistant/providers/sonos/__init__.py @@ -25,9 +25,6 @@ if TYPE_CHECKING: SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, - # support sync groups by reporting create/remove player group support - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index 9dcc7fe0..a847062d 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio import time +from copy import deepcopy from typing import TYPE_CHECKING from aiohttp import ClientConnectorError @@ -300,7 +301,6 @@ class SonosPlayer(Player): await airplay_player.stop() else: await self.client.player.group.stop() - self._attr_playback_state = PlaybackState.IDLE self.update_state() async def pause(self) -> None: @@ -309,11 +309,6 @@ class SonosPlayer(Player): Will only be called if the player reports PlayerFeature.PAUSE is supported. """ - - def _update_state() -> None: - self._attr_playback_state = PlaybackState.PAUSED - self.update_state() - if self.client.player.is_passive: self.logger.debug("Ignore STOP command: Player is synced to another player.") return @@ -321,7 +316,6 @@ class SonosPlayer(Player): # linked airplay player is active, redirect the command self.logger.debug("Redirecting PAUSE command to linked airplay player.") await airplay_player.pause() - _update_state() return active_source = self._attr_active_source if self.mass.player_queues.get(active_source): @@ -332,14 +326,11 @@ class SonosPlayer(Player): # TODO: revisit this later once we implemented support for range requests # as I have the feeling the pause issue is related to seek support (=range requests) await self.stop() - _update_state() return if not self.client.player.group.playback_actions.can_pause: await self.stop() - _update_state() return await self.client.player.group.pause() - _update_state() async def next_track(self) -> None: """ @@ -384,11 +375,7 @@ class SonosPlayer(Player): :param media: Details of the item that needs to be played on the player. """ - - def _update_state() -> None: - self._attr_current_media = media - self._attr_playback_state = PlaybackState.PLAYING - self.update_state() + self._attr_current_media = deepcopy(media) if self.client.player.is_passive: # this should be already handled by the player manager, but just in case... @@ -403,17 +390,15 @@ class SonosPlayer(Player): # airplay mode is enabled, redirect the command self.logger.debug("Redirecting PLAY_MEDIA command to linked airplay player.") await self._play_media_airplay(airplay_player, media) - _update_state() return if media.media_type in ( MediaType.PLUGIN_SOURCE, MediaType.FLOW_STREAM, - ) or media.source_id.startswith(UGP_PREFIX): + ) or (media.source_id and media.source_id.startswith(UGP_PREFIX)): # flow stream or plugin source playback # always use the legacy (UPNP) playback method for this await self._play_media_legacy(media) - _update_state() return if media.source_id and media.queue_item_id: @@ -428,7 +413,6 @@ class SonosPlayer(Player): queue_version=str(int(mass_queue.items_last_updated)), ) self.mass.call_later(5, self.sync_play_modes, media.source_id) - _update_state() return # All other playback types @@ -439,7 +423,6 @@ class SonosPlayer(Player): await self.client.player.group.play_stream_url( media.uri, {"name": media.title, "type": "track"} ) - _update_state() async def select_source(self, source: str) -> None: """ @@ -492,22 +475,26 @@ class SonosPlayer(Player): :param player_ids_to_add: List of player_id's to add to the group. :param player_ids_to_remove: List of player_id's to remove from the group. """ + player_ids_to_add = player_ids_to_add or [] + player_ids_to_remove = player_ids_to_remove or [] if airplay_player := self.get_linked_airplay_player(False): # if airplay mode is enabled, we could possibly receive child player id's that are # not Sonos players, but AirPlay players. We redirect those. - airplay_child_ids = [x for x in player_ids_to_add or [] if x.startswith("ap")] - player_ids_to_add = [x for x in player_ids_to_add or [] if x not in airplay_child_ids] - if airplay_child_ids: - if ( - airplay_player.active_source != self._attr_active_source - and airplay_player.playback_state == PlaybackState.PLAYING - ): - # edge case player is not playing a MA queue - fail this request - raise PlayerCommandFailed("Player is not playing a Music Assistant queue.") - await self.mass.players.cmd_group_many(airplay_player.player_id, airplay_child_ids) - if player_ids_to_add: + airplay_player_ids_to_add = [x for x in player_ids_to_add if x.startswith("ap")] + player_ids_to_add = [x for x in player_ids_to_add if x not in airplay_player_ids_to_add] + airplay_player_ids_to_remove = [x for x in player_ids_to_remove if x.startswith("ap")] + player_ids_to_remove = [ + x for x in player_ids_to_remove if x not in airplay_player_ids_to_remove + ] + if airplay_player_ids_to_add or airplay_player_ids_to_remove: + await self.mass.players.cmd_set_members( + airplay_player.player_id, + player_ids_to_add=airplay_player_ids_to_add, + player_ids_to_remove=airplay_player_ids_to_remove, + ) + if player_ids_to_add or player_ids_to_remove: await self.client.player.group.modify_group_members( - player_ids_to_add=player_ids_to_add, player_ids_to_remove=[] + player_ids_to_add=player_ids_to_add, player_ids_to_remove=player_ids_to_remove ) async def ungroup(self) -> None: @@ -551,8 +538,15 @@ class SonosPlayer(Player): def on_player_event(self, event: SonosEvent | None) -> None: """Handle incoming event from player.""" - self.update_attributes() - self.update_state() + try: + self.update_attributes() + except Exception as err: + self.logger.exception("Failed to update player attributes: %s", err) + return + try: + self.update_state() + except Exception as err: + self.logger.exception("Failed to update player state: %s", err) def update_attributes(self) -> None: # noqa: PLR0915 """Update the player attributes.""" @@ -600,7 +594,7 @@ class SonosPlayer(Player): self._attr_group_members.clear() # map playback state - self._playback_state = PLAYBACK_STATE_MAP[active_group.playback_state] + self._attr_playback_state = PLAYBACK_STATE_MAP[active_group.playback_state] self._attr_elapsed_time = active_group.position # figure out the active source based on the container @@ -637,9 +631,7 @@ class SonosPlayer(Player): if SOURCE_SPOTIFY not in [x.id for x in self._attr_source_list]: self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_SPOTIFY]) elif active_service == MusicService.MUSIC_ASSISTANT: - if self.client.player.is_coordinator: - self._attr_active_source = self._player_id - elif object_id := container.get("id", {}).get("objectId"): + if object_id := container.get("id", {}).get("objectId"): self._attr_active_source = object_id.split(":")[-1] else: self._attr_active_source = None diff --git a/music_assistant/providers/sonos_s1/__init__.py b/music_assistant/providers/sonos_s1/__init__.py index 42ef9404..a84d4b47 100644 --- a/music_assistant/providers/sonos_s1/__init__.py +++ b/music_assistant/providers/sonos_s1/__init__.py @@ -27,9 +27,6 @@ if TYPE_CHECKING: SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, - # support sync groups by reporting create/remove player group support - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/squeezelite/__init__.py b/music_assistant/providers/squeezelite/__init__.py index 0da03798..cb17862f 100644 --- a/music_assistant/providers/squeezelite/__init__.py +++ b/music_assistant/providers/squeezelite/__init__.py @@ -26,9 +26,6 @@ if TYPE_CHECKING: SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, - # support sync groups by reporting create/remove player group support - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/universal_group/player.py b/music_assistant/providers/universal_group/player.py index 42c748d1..396cdf74 100644 --- a/music_assistant/providers/universal_group/player.py +++ b/music_assistant/providers/universal_group/player.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from copy import deepcopy from time import time from typing import TYPE_CHECKING, cast @@ -262,7 +263,7 @@ class UniversalGroupPlayer(GroupPlayer): base_url = f"{self.mass.streams.base_url}/ugp/{self.player_id}.flac" # set the state optimistically - self._attr_current_media = media + self._attr_current_media = deepcopy(media) self._attr_elapsed_time = 0 self._attr_elapsed_time_last_updated = time() - 1 self._attr_playback_state = PlaybackState.PLAYING diff --git a/pyproject.toml b/pyproject.toml index 31e518bd..5ee94d77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,6 @@ test = [ "ruff==0.12.12", ] - [project.scripts] mass = "music_assistant.__main__:main" @@ -130,7 +129,16 @@ enable_error_code = [ "truthy-iterable", ] exclude = [ - '^music_assistant/controllers/.*$', + '^music_assistant/controllers/__init__.py$', + '^music_assistant/controllers/cache.py$', + '^music_assistant/controllers/config.py$', + '^music_assistant/controllers/media/.*$', + '^music_assistant/controllers/metadata.py$', + '^music_assistant/controllers/music.py$', + '^music_assistant/controllers/player_queues.py$', + '^music_assistant/controllers/players/player_controller.py', + '^music_assistant/controllers/streams.py$', + '^music_assistant/controllers/webserver.py', '^music_assistant/helpers/app_vars.py', '^music_assistant/models/player_provider.py', '^music_assistant/providers/apple_music/.*$', -- 2.34.1