From fdf0e161d4171d0498cca72cbd18be3b291571cd Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Fri, 5 Dec 2025 17:39:22 +0100 Subject: [PATCH] refactor: remove builtin player (in favor of sendspin) (#2752) --- .../providers/builtin_player/__init__.py | 56 ---- .../providers/builtin_player/icon.svg | 144 -------- .../builtin_player/icon_monochrome.svg | 166 ---------- .../providers/builtin_player/manifest.json | 12 - .../providers/builtin_player/player.py | 312 ------------------ .../providers/builtin_player/provider.py | 128 ------- 6 files changed, 818 deletions(-) delete mode 100644 music_assistant/providers/builtin_player/__init__.py delete mode 100644 music_assistant/providers/builtin_player/icon.svg delete mode 100644 music_assistant/providers/builtin_player/icon_monochrome.svg delete mode 100644 music_assistant/providers/builtin_player/manifest.json delete mode 100644 music_assistant/providers/builtin_player/player.py delete mode 100644 music_assistant/providers/builtin_player/provider.py diff --git a/music_assistant/providers/builtin_player/__init__.py b/music_assistant/providers/builtin_player/__init__.py deleted file mode 100644 index 4c5d6be3..00000000 --- a/music_assistant/providers/builtin_player/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Built-in HTTP-based Player Provider for Music Assistant. - -This provider creates a standards HTTP audio streaming endpoint that can be utilized -by the MA web interface, accessed directly as a URL, consumed by Home Assistant media -browser, or integrated with other plugins without requiring third-party protocols. - -Usage requires registering a player through the 'builtin_player/register' API command. -The registered player must regularly update its state via 'builtin_player/update_state' -to maintain the connection. Players can be manually disconnected with 'builtin_player/unregister' -when no longer needed. - -Communication with the player occurs via events. The provider sends commands (play media url, pause, -stop, volume changes, etc.) through the BUILTIN_PLAYER event type. Client implementations must -listen for these events and respond accordingly to control playback and handle media changes. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from music_assistant_models.enums import ProviderFeature - -from music_assistant.mass import MusicAssistant -from music_assistant.models import ProviderInstanceType - -from .provider import BuiltinPlayerProvider - -if TYPE_CHECKING: - from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig - from music_assistant_models.provider import ProviderManifest - -SUPPORTED_FEATURES = {ProviderFeature.REMOVE_PLAYER} - - -async def setup( - mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig -) -> ProviderInstanceType: - """Initialize provider(instance) with given configuration.""" - return BuiltinPlayerProvider(mass, manifest, config, SUPPORTED_FEATURES) - - -async def get_config_entries( - mass: MusicAssistant, - instance_id: str | None = None, - action: str | None = None, - values: dict[str, ConfigValueType] | None = None, -) -> tuple[ConfigEntry, ...]: - """ - Return Config entries to setup this provider. - - instance_id: id of an existing provider instance (None if new instance setup). - action: [optional] action key called from config entries UI. - values: the (intermediate) raw values for config entries sent with the action. - """ - # ruff: noqa: ARG001 - return () diff --git a/music_assistant/providers/builtin_player/icon.svg b/music_assistant/providers/builtin_player/icon.svg deleted file mode 100644 index a843b011..00000000 --- a/music_assistant/providers/builtin_player/icon.svg +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/music_assistant/providers/builtin_player/icon_monochrome.svg b/music_assistant/providers/builtin_player/icon_monochrome.svg deleted file mode 100644 index 23c48c64..00000000 --- a/music_assistant/providers/builtin_player/icon_monochrome.svg +++ /dev/null @@ -1,166 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/music_assistant/providers/builtin_player/manifest.json b/music_assistant/providers/builtin_player/manifest.json deleted file mode 100644 index 7ee7a8ad..00000000 --- a/music_assistant/providers/builtin_player/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "type": "player", - "domain": "builtin_player", - "stage": "alpha", - "name": "Builtin Web Player", - "description": "Control playback and listen directly through the Music Assistant web interface.", - "codeowners": ["@music-assistant"], - "documentation": "https://music-assistant.io/player-support/builtin/", - "multi_instance": false, - "builtin": true, - "allow_disable": false -} diff --git a/music_assistant/providers/builtin_player/player.py b/music_assistant/providers/builtin_player/player.py deleted file mode 100644 index 54f881f2..00000000 --- a/music_assistant/providers/builtin_player/player.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Player model implementation for the Built-in Player.""" - -from __future__ import annotations - -from collections.abc import Callable -from time import time - -from aiohttp import web -from music_assistant_models.builtin_player import BuiltinPlayerEvent, BuiltinPlayerState -from music_assistant_models.config_entries import ConfigEntry, ConfigValueType -from music_assistant_models.constants import PLAYER_CONTROL_NATIVE -from music_assistant_models.enums import ( - BuiltinPlayerEventType, - ConfigEntryType, - ContentType, - EventType, - PlaybackState, - PlayerFeature, - PlayerType, -) -from music_assistant_models.media_items import AudioFormat - -from music_assistant.constants import ( - CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_HTTP_PROFILE_HIDDEN, - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, - CONF_MUTE_CONTROL, - CONF_POWER_CONTROL, - CONF_VOLUME_CONTROL, - DEFAULT_STREAM_HEADERS, - INTERNAL_PCM_FORMAT, - create_sample_rates_config_entry, -) -from music_assistant.helpers.audio import get_player_filter_params -from music_assistant.helpers.ffmpeg import get_ffmpeg_stream -from music_assistant.models.player import DeviceInfo, Player, PlayerMedia -from music_assistant.models.player_provider import PlayerProvider - -# If the player does not send an update within this time, it will be considered offline -DURATION_UNTIL_TIMEOUT = 120 # 60 second extra headroom -POLL_INTERVAL = 30 - - -class BuiltinPlayer(Player): - """Representation of a Builtin Player.""" - - last_update: float - unregister_cbs: list[Callable[[], None]] = [] - - def __init__( - self, - player_id: str, - provider: PlayerProvider, - name: str, - features: tuple[PlayerFeature, ...], - ) -> None: - """Initialize the Builtin Player.""" - super().__init__(provider, player_id) - self._attr_type = PlayerType.PLAYER - self._attr_power_control = PLAYER_CONTROL_NATIVE - self._attr_device_info = DeviceInfo() - self._attr_supported_features = set(features) - self._attr_needs_poll = True - self._attr_poll_interval = POLL_INTERVAL - self._attr_hidden_by_default = True - self._attr_expose_to_ha_by_default = False - self.register(name, False) - - def unregister_routes(self) -> None: - """Unregister all routes associated with this player.""" - for cb in self.unregister_cbs: - cb() - self.unregister_cbs.clear() - self._attr_available = False - self._attr_playback_state = PlaybackState.IDLE - self._attr_powered = False - self._attr_needs_poll = False - self.update_state() - - def register(self, player_name: str, update_state: bool = True) -> None: - """Register the player for playback.""" - if not self.unregister_cbs: - self.unregister_cbs = [ - self.mass.webserver.register_dynamic_route( - f"/builtin_player/flow/{self.player_id}.mp3", self._serve_audio_stream - ), - ] - - self._attr_playback_state = PlaybackState.IDLE - self._attr_name = player_name - self._attr_available = True - self._attr_powered = False - self._attr_needs_poll = True - self.last_update = time() - if update_state: - self.update_state() - - async def get_config_entries( - self, - action: str | None = None, - values: dict[str, ConfigValueType] | None = None, - ) -> list[ConfigEntry]: - """Return all (provider/player specific) Config Entries for the player.""" - base_entries = await super().get_config_entries(action=action, values=values) - return [ - *base_entries, - CONF_ENTRY_FLOW_MODE_ENFORCED, - # Hide power/volume/mute control options since they are guaranteed to work - ConfigEntry( - key=CONF_POWER_CONTROL, - type=ConfigEntryType.STRING, - label=CONF_POWER_CONTROL, - default_value=PLAYER_CONTROL_NATIVE, - hidden=True, - ), - ConfigEntry( - key=CONF_VOLUME_CONTROL, - type=ConfigEntryType.STRING, - label=CONF_VOLUME_CONTROL, - default_value=PLAYER_CONTROL_NATIVE, - hidden=True, - ), - ConfigEntry( - key=CONF_MUTE_CONTROL, - type=ConfigEntryType.STRING, - label=CONF_MUTE_CONTROL, - default_value=PLAYER_CONTROL_NATIVE, - hidden=True, - ), - CONF_ENTRY_HTTP_PROFILE_HIDDEN, - CONF_ENTRY_OUTPUT_CODEC_HIDDEN, - create_sample_rates_config_entry([48000]), - ] - - async def stop(self) -> None: - """Send STOP command to player.""" - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - self.player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.STOP), - ) - self._attr_current_media = None - self.update_state() - - async def play(self) -> None: - """Send PLAY command to player.""" - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - self.player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.PLAY), - ) - - async def pause(self) -> None: - """Send PAUSE command to player.""" - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - self.player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.PAUSE), - ) - - async def volume_set(self, volume_level: int) -> None: - """Send VOLUME_SET command to player.""" - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - self.player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.SET_VOLUME, volume=volume_level), - ) - - async def volume_mute(self, muted: bool) -> None: - """Send VOLUME MUTE command to player.""" - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - self.player_id, - BuiltinPlayerEvent( - type=BuiltinPlayerEventType.MUTE if muted else BuiltinPlayerEventType.UNMUTE - ), - ) - - async def play_media(self, media: PlayerMedia) -> None: - """Handle PLAY MEDIA on player.""" - url = f"builtin_player/flow/{self.player_id}.mp3" - self._attr_current_media = media - self._attr_playback_state = PlaybackState.PLAYING - self.update_state() - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - self.player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.PLAY_MEDIA, media_url=url), - ) - - async def power(self, powered: bool) -> None: - """Send POWER ON command to player.""" - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - self.player_id, - BuiltinPlayerEvent( - type=BuiltinPlayerEventType.POWER_ON - if powered - else BuiltinPlayerEventType.POWER_OFF - ), - ) - if not powered: - self._attr_powered = False - self.update_state() - - async def poll(self) -> None: - """ - Poll player for state updates. - - This is called by the Player Manager; - if the 'needs_poll' property is True. - """ - last_updated = time() - self.last_update - if last_updated > DURATION_UNTIL_TIMEOUT: - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - self.player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.TIMEOUT), - ) - self.unregister_routes() - - async def on_unload(self) -> None: - """Handle logic when the player is unloaded from the Player controller.""" - self.unregister_routes() - - async def _serve_audio_stream(self, request: web.Request) -> web.StreamResponse: - """Serve the flow stream audio to a player.""" - player_id = request.path.rsplit(".")[0].rsplit("/")[-1] - format_str = request.path.rsplit(".")[-1] - self.logger.debug("Serving audio stream to %s", player_id) - - if not (player := self.mass.players.get(player_id)): - raise web.HTTPNotFound(reason=f"Unknown player: {player_id}") - - headers = { - **DEFAULT_STREAM_HEADERS, - "Content-Type": f"audio/{format_str}", - "Accept-Ranges": "none", - } - - resp = web.StreamResponse(status=200, reason="OK", headers=headers) - resp.enable_chunked_encoding() - - await resp.prepare(request) - - # return early if this is not a GET request - if request.method != "GET": - return resp - - # Check for a client probe request (from an iPhone/iPad) - if (range_header := request.headers.get("Range")) and range_header == "bytes=0-1": - self.logger.debug("Client is probing the stream.") - # We don't early exit here since playback would otherwise never start - # on iOS devices with Home Assistant OS installations. - - media = player._current_media - if media is None: - raise web.HTTPNotFound(reason="No active media found!") - - # TODO: set encoding quality using a bitrate parameter, - # maybe even dynamic with auto/semiauto switching with bad network? - if format_str == "mp3": - stream_format = AudioFormat(content_type=ContentType.MP3) - else: - stream_format = AudioFormat(content_type=ContentType.FLAC) - - pcm_format = AudioFormat( - sample_rate=stream_format.sample_rate, - content_type=INTERNAL_PCM_FORMAT.content_type, - bit_depth=INTERNAL_PCM_FORMAT.bit_depth, - channels=INTERNAL_PCM_FORMAT.channels, - ) - - async for chunk in get_ffmpeg_stream( - # Use get_stream helper which handles all media types including UGP streams - audio_input=self.mass.streams.get_stream(media, pcm_format), - input_format=pcm_format, - output_format=stream_format, - # Apple ignores "Accept-Ranges=none" on iOS and iPadOS for some reason, - # so we need to slowly feed the music to avoid the Browser stopping and later - # restarting the audio stream (from a wrong position!) by keeping the buffer short. - extra_input_args=["-readrate", "1.02", "-readrate_initial_burst", "6"], - filter_params=get_player_filter_params(self.mass, player_id, pcm_format, stream_format), - ): - try: - await resp.write(chunk) - except (ConnectionError, ConnectionResetError): - break - - return resp - - def update_builtin_player_state(self, state: BuiltinPlayerState) -> None: - """Update the current state of the player.""" - self._attr_elapsed_time_last_updated = time() - self.last_update = time() - self._attr_elapsed_time = float(state.position) - self._attr_volume_muted = state.muted - self._attr_volume_level = state.volume - if not state.powered: - self._attr_powered = False - self._attr_playback_state = PlaybackState.IDLE - elif state.playing: - self._attr_powered = True - self._attr_playback_state = PlaybackState.PLAYING - elif state.paused: - self._attr_powered = True - self._attr_playback_state = PlaybackState.PAUSED - else: - self._attr_powered = True - self._attr_playback_state = PlaybackState.IDLE - - self.update_state() diff --git a/music_assistant/providers/builtin_player/provider.py b/music_assistant/providers/builtin_player/provider.py deleted file mode 100644 index 5d405896..00000000 --- a/music_assistant/providers/builtin_player/provider.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Provider implementation for the Built-in Player.""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import TYPE_CHECKING, cast - -import shortuuid -from music_assistant_models.builtin_player import BuiltinPlayerEvent, BuiltinPlayerState -from music_assistant_models.enums import BuiltinPlayerEventType, EventType, PlayerFeature - -from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user -from music_assistant.models.player import Player -from music_assistant.models.player_provider import PlayerProvider - -from .player import BuiltinPlayer - - -class BuiltinPlayerProvider(PlayerProvider): - """Builtin Player Provider for playing to the Music Assistant Web Interface.""" - - _unregister_cbs: list[Callable[[], None]] - - async def handle_async_init(self) -> None: - """Handle asynchronous initialization of the provider.""" - self._unregister_cbs = [ - self.mass.register_api_command("builtin_player/register", self.register_player), - self.mass.register_api_command("builtin_player/unregister", self.unregister_player), - self.mass.register_api_command("builtin_player/update_state", self.update_player_state), - ] - - async def unload(self, is_removed: bool = False) -> None: - """ - Handle unload/close of the provider. - - Called when provider is deregistered (e.g. MA exiting or config reloading). - is_removed will be set to True when the provider is removed from the configuration. - """ - for unload_cb in self._unregister_cbs: - unload_cb() - - async def remove_player(self, player_id: str) -> None: - """Remove a player.""" - self.mass.signal_event( - EventType.BUILTIN_PLAYER, - player_id, - BuiltinPlayerEvent(type=BuiltinPlayerEventType.TIMEOUT), - ) - await self.unregister_player(player_id) - - async def register_player(self, player_name: str, player_id: str | None) -> Player: - """Register a player. - - Every player must first be registered through this `builtin_player/register` API command - before any playback can occur. - Since players queues can time out, this command either will create a new player queue, - or restore it from the last session. - - - player_name: Human readable name of the player, will only be used in case this call - creates a new queue. - - player_id: the id of the builtin player, set to None on new sessions. The returned player - will have a new random player_id - """ - if player_id is None: - player_id = f"ma_{shortuuid.random(10).lower()}" - - player_features = { - PlayerFeature.VOLUME_SET, - PlayerFeature.VOLUME_MUTE, - PlayerFeature.PAUSE, - PlayerFeature.POWER, - } - - # special case: user has player filter enabled but wants - # to use the builtin player on their device, so add it to their filter - current_user = get_current_user() - if ( - current_user - and current_user.player_filter - and player_id not in current_user.player_filter - ): - current_user.player_filter.append(player_id) - await self.mass.webserver.auth.update_user_filters( - current_user, player_filter=current_user.player_filter, provider_filter=None - ) - - player = self.mass.players.get(player_id) - - if player is None: - player = BuiltinPlayer( - player_id=player_id, - provider=self, - name=player_name, - features=tuple(player_features), - ) - await self.mass.players.register_or_update(player) - else: - if TYPE_CHECKING: - player = cast("BuiltinPlayer", player) - player.register(player_name) - - return player - - async def unregister_player(self, player_id: str) -> None: - """Manually unregister a player with `builtin_player/unregister`.""" - if player := self.mass.players.get(player_id): - if TYPE_CHECKING: - player = cast("BuiltinPlayer", player) - player.unregister_routes() - - async def update_player_state(self, player_id: str, state: BuiltinPlayerState) -> bool: - """Update current state of a player. - - A player must periodically update the state of through this `builtin_player/update_state` - API command. - - Returns False in case the player already timed out or simply doesn't exist. - In that case, register the player first with `builtin_player/register`. - """ - if not (player := self.mass.players.get(player_id)): - return False - - if TYPE_CHECKING: - player = cast("BuiltinPlayer", player) - - player.update_builtin_player_state(state) - - return True -- 2.34.1