From ff6acc9fcbb98206a5fdc080fab484b0c83daf4a Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 27 Jan 2024 11:56:56 +0100 Subject: [PATCH] Add player provider for devices running fully kiosk (#1032) --- .../server/providers/fully_kiosk/__init__.py | 253 ++++++++++++++++++ .../providers/fully_kiosk/manifest.json | 12 + requirements_all.txt | 1 + 3 files changed, 266 insertions(+) create mode 100644 music_assistant/server/providers/fully_kiosk/__init__.py create mode 100644 music_assistant/server/providers/fully_kiosk/manifest.json diff --git a/music_assistant/server/providers/fully_kiosk/__init__.py b/music_assistant/server/providers/fully_kiosk/__init__.py new file mode 100644 index 00000000..24d22c9e --- /dev/null +++ b/music_assistant/server/providers/fully_kiosk/__init__.py @@ -0,0 +1,253 @@ +"""FullyKiosk Player provider for Music Assistant.""" + +from __future__ import annotations + +import asyncio +import time +from typing import TYPE_CHECKING + +from fullykiosk import FullyKiosk + +from music_assistant.common.models.config_entries import ( + CONF_ENTRY_CROSSFADE, + CONF_ENTRY_CROSSFADE_DURATION, + ConfigEntry, + ConfigValueType, +) +from music_assistant.common.models.enums import ( + ConfigEntryType, + ContentType, + PlayerFeature, + PlayerState, + PlayerType, +) +from music_assistant.common.models.errors import PlayerUnavailableError, SetupFailedError +from music_assistant.common.models.player import DeviceInfo, Player +from music_assistant.common.models.queue_item import QueueItem +from music_assistant.constants import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT +from music_assistant.server.models.player_provider import PlayerProvider + +if TYPE_CHECKING: + from music_assistant.common.models.config_entries import ProviderConfig + from music_assistant.common.models.provider import ProviderManifest + from music_assistant.server import MusicAssistant + from music_assistant.server.controllers.streams import MultiClientStreamJob + from music_assistant.server.models import ProviderInstanceType + +AUDIOMANAGER_STREAM_MUSIC = 3 +CONF_ENFORCE_MP3 = "enforce_mp3" + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + prov = FullyKioskProvider(mass, manifest, config) + await prov.handle_setup() + return prov + + +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 ( + ConfigEntry( + key=CONF_IP_ADDRESS, + type=ConfigEntryType.STRING, + label="IP-Address (or hostname) of the device running Fully Kiosk/app.", + required=True, + ), + ConfigEntry( + key=CONF_PASSWORD, + type=ConfigEntryType.STRING, + label="Password to use to connect to the Fully Kiosk API.", + required=True, + ), + ConfigEntry( + key=CONF_PORT, + type=ConfigEntryType.STRING, + default_value="2323", + label="Port to use to connect to the Fully Kiosk API (default is 2323).", + required=True, + advanced=True, + ), + ) + + +class FullyKioskProvider(PlayerProvider): + """Player provider for FullyKiosk based players.""" + + _fully: FullyKiosk + + async def handle_setup(self) -> None: + """Handle async initialization of the provider.""" + self._fully = FullyKiosk( + self.mass.http_session, + self.config.get_value(CONF_IP_ADDRESS), + self.config.get_value(CONF_PORT), + self.config.get_value(CONF_PASSWORD), + ) + try: + async with asyncio.timeout(15): + await self._fully.getDeviceInfo() + self._handle_player_init() + self._handle_player_update() + except Exception as err: + raise SetupFailedError( + f"Unable to start the FullyKiosk connection ({str(err)}" + ) from err + + def _handle_player_init(self) -> None: + """Process FullyKiosk add to Player controller.""" + player_id = self._fully.deviceInfo["deviceID"] + player = self.mass.players.get(player_id, raise_unavailable=False) + address = ( + f"http://{self.config.get_value(CONF_IP_ADDRESS)}:{self.config.get_value(CONF_PORT)}" + ) + if not player: + player = Player( + player_id=player_id, + provider=self.instance_id, + type=PlayerType.PLAYER, + name=self._fully.deviceInfo["deviceName"], + available=True, + powered=False, + device_info=DeviceInfo( + model=self._fully.deviceInfo["deviceModel"], + manufacturer=self._fully.deviceInfo["deviceManufacturer"], + address=address, + ), + supported_features=(PlayerFeature.VOLUME_SET,), + ) + self.mass.players.register_or_update(player) + + def _handle_player_update(self) -> None: + """Update FullyKiosk player attributes.""" + player_id = self._fully.deviceInfo["deviceID"] + player = self.mass.players.get(player_id) + player.name = self._fully.deviceInfo["deviceName"] + # player.volume_level = snap_client.volume + for volume_dict in self._fully.deviceInfo.get("audioVolumes", []): + if str(AUDIOMANAGER_STREAM_MUSIC) in volume_dict: + volume = volume_dict[str(AUDIOMANAGER_STREAM_MUSIC)] + player.volume_level = volume + break + player.current_item_id = self._fully.deviceInfo.get("soundUrlPlaying") + player.available = True + self.mass.players.update(player_id) + + async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the given player (if any).""" + base_entries = await super().get_player_config_entries(player_id) + return base_entries + ( + CONF_ENTRY_CROSSFADE, + CONF_ENTRY_CROSSFADE_DURATION, + ConfigEntry( + key=CONF_ENFORCE_MP3, + type=ConfigEntryType.BOOLEAN, + label="Enforce (lossy) mp3 stream", + default_value=False, + description="By default, Music Assistant sends lossless, high quality audio " + "to all players. Some devices can not deal with that and require " + "the stream to be packed into a lossy mp3 codec. Only enable when needed.", + advanced=True, + ), + ) + + async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + player = self.mass.players.get(player_id, raise_unavailable=False) + await self._fully.setAudioVolume(volume_level, AUDIOMANAGER_STREAM_MUSIC) + player.volume_level = volume_level + self.mass.players.update(player_id) + + async def cmd_stop(self, player_id: str) -> None: + """Send STOP command to given player.""" + player = self.mass.players.get(player_id, raise_unavailable=False) + await self._fully.stopSound() + player.state = PlayerState.IDLE + self.mass.players.update(player_id) + + async def play_media( + self, + player_id: str, + queue_item: QueueItem, + seek_position: int, + fade_in: bool, + ) -> None: + """Handle PLAY MEDIA on given player. + + This is called by the Queue controller to start playing a queue item on the given player. + The provider's own implementation should work out how to handle this request. + + - player_id: player_id of the player to handle the command. + - queue_item: The QueueItem that needs to be played on the player. + - seek_position: Optional seek to this position. + - fade_in: Optionally fade in the item at playback start. + """ + player = self.mass.players.get(player_id) + enforce_mp3 = await self.mass.config.get_player_config_value(player_id, CONF_ENFORCE_MP3) + url = await self.mass.streams.resolve_stream_url( + queue_item=queue_item, + output_codec=ContentType.MP3 if enforce_mp3 else ContentType.FLAC, + seek_position=seek_position, + fade_in=fade_in, + flow_mode=True, + ) + await self._fully.playSound(url, AUDIOMANAGER_STREAM_MUSIC) + player.current_item_id = queue_item.queue_id + player.elapsed_time = 0 + player.elapsed_time_last_updated = time.time() + player.state = PlayerState.PLAYING + self.mass.players.update(player_id) + + async def play_stream(self, player_id: str, stream_job: MultiClientStreamJob) -> None: + """Handle PLAY STREAM on given player. + + This is a special feature from the Universal Group provider. + """ + player = self.mass.players.get(player_id) + enforce_mp3 = await self.mass.config.get_player_config_value(player_id, CONF_ENFORCE_MP3) + output_codec = ContentType.MP3 if enforce_mp3 else ContentType.FLAC + url = stream_job.resolve_stream_url(player_id, output_codec) + await self._fully.playSound(url, AUDIOMANAGER_STREAM_MUSIC) + player.current_item_id = player_id + player.elapsed_time = 0 + player.elapsed_time_last_updated = time.time() + player.state = PlayerState.PLAYING + self.mass.players.update(player_id) + + async def poll_player(self, player_id: str) -> None: # noqa: ARG002 + """Poll player for state updates. + + This is called by the Player Manager; + - every 360 seconds if the player if not powered + - every 30 seconds if the player is powered + - every 10 seconds if the player is playing + + Use this method to request any info that is not automatically updated and/or + to detect if the player is still alive. + If this method raises the PlayerUnavailable exception, + the player is marked as unavailable until + the next successful poll or event where it becomes available again. + If the player does not need any polling, simply do not override this method. + """ + try: + async with asyncio.timeout(15): + await self._fully.getDeviceInfo() + self._handle_player_update() + except Exception as err: + raise PlayerUnavailableError( + f"Unable to start the FullyKiosk connection ({str(err)}" + ) from err diff --git a/music_assistant/server/providers/fully_kiosk/manifest.json b/music_assistant/server/providers/fully_kiosk/manifest.json new file mode 100644 index 00000000..43e48a14 --- /dev/null +++ b/music_assistant/server/providers/fully_kiosk/manifest.json @@ -0,0 +1,12 @@ +{ + "type": "player", + "domain": "fully_kiosk", + "name": "Fully Kiosk Browser", + "description": "Support for media players from the Fully Kiosk app.", + "codeowners": ["@music-assistant"], + "requirements": ["python-fullykiosk==0.0.12"], + "documentation": "", + "multi_instance": true, + "builtin": false, + "load_by_default": false +} diff --git a/requirements_all.txt b/requirements_all.txt index 0e4a4fc2..7e6873e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -24,6 +24,7 @@ plexapi==4.15.7 py-opensonic>=5.0.2 PyChromecast==13.0.8 pycryptodome==3.20.0 +python-fullykiosk==0.0.12 python-slugify==8.0.1 radios==0.3.0 shortuuid==1.0.11 -- 2.34.1