music-assistant-server

12.4 KBPY
player.py
12.4 KB345 lines • python
1"""Media Assistant Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from typing import TYPE_CHECKING, Any, cast
8from urllib.parse import urlencode
9
10from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature
11
12from music_assistant.constants import CONF_ENTRY_HTTP_PROFILE
13from music_assistant.models.player import Player, PlayerMedia
14
15from .constants import CONF_ROKU_APP_ID
16
17if TYPE_CHECKING:
18    from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
19    from rokuecp import Roku
20
21    from .provider import MediaAssistantprovider
22
23
24class MediaAssistantPlayer(Player):
25    """MediaAssistantPlayer in Music Assistant."""
26
27    def __init__(
28        self,
29        provider: MediaAssistantprovider,
30        player_id: str,
31        roku_name: str,
32        roku: Roku,
33        queued: PlayerMedia | None = None,
34    ) -> None:
35        """Initialize the Player."""
36        super().__init__(provider, player_id)
37        # init some static variables
38        self.roku = roku
39        self.queued = queued
40        self._attr_name = roku_name
41        self._attr_supported_features = {
42            PlayerFeature.PLAY_MEDIA,
43            PlayerFeature.POWER,  # if the player can be turned on/off
44            PlayerFeature.PAUSE,
45            PlayerFeature.VOLUME_MUTE,
46            PlayerFeature.ENQUEUE,
47        }
48        self._attr_volume_muted = False
49        self._attr_volume_level = 100
50        self.lock = asyncio.Lock()  # Held when connecting or disconnecting the device
51
52    async def setup(self) -> None:
53        """Set up player in MA."""
54        self._attr_available = False
55        self._attr_powered = False
56        await self.mass.players.register_or_update(self)
57
58    @property
59    def needs_poll(self) -> bool:
60        """Return if the player needs to be polled for state updates."""
61        return True
62
63    @property
64    def poll_interval(self) -> int:
65        """Return the interval in seconds to poll the player for state updates."""
66        return 5 if self.powered else 30
67
68    async def get_config_entries(
69        self,
70        action: str | None = None,
71        values: dict[str, ConfigValueType] | None = None,
72    ) -> list[ConfigEntry]:
73        """Return all (provider/player specific) Config Entries for the player."""
74        default_entries = await super().get_config_entries(action=action, values=values)
75        return [
76            *default_entries,
77            CONF_ENTRY_HTTP_PROFILE,
78        ]
79
80    async def power(self, powered: bool) -> None:
81        """Handle POWER command on the player."""
82        logger = self.provider.logger.getChild(self.player_id)
83        logger.info("Received POWER command on player %s", self.display_name)
84
85        try:
86            device_info = await self.roku.update()
87            app_running = False
88            if device_info.app is not None:
89                app_running = device_info.app.app_id == self.provider.config.get_value(
90                    CONF_ROKU_APP_ID
91                )
92        except Exception:
93            self.logger.error("Failed to get app state on: %s", self.name)
94
95        try:
96            # There's no real way to "Power" on the app since device wake up / app start
97            # is handled by The roku once it receives the Play Media request
98            if not powered:
99                if app_running:
100                    await self.roku.remote("home")
101                    await self.roku.remote("power")
102        except Exception:
103            self.logger.error("Failed to change Power state on: %s", self.name)
104
105        # update the player state in the player manager
106        self.update_state()
107
108    async def volume_mute(self, muted: bool) -> None:
109        """Handle VOLUME MUTE command on the player."""
110        await self.roku.remote("volume_mute")
111
112        logger = self.provider.logger.getChild(self.player_id)
113        logger.info(
114            "Received VOLUME_MUTE command on player %s with muted %s", self.display_name, muted
115        )
116        self._attr_volume_muted = muted
117        self.update_state()
118
119    async def play(self) -> None:
120        """Play command."""
121        await self.roku.remote("play")
122
123        logger = self.provider.logger.getChild(self.player_id)
124        logger.info("Received PLAY command on player %s", self.display_name)
125        self._attr_playback_state = PlaybackState.PLAYING
126        self.update_state()
127
128    async def stop(self) -> None:
129        """Stop command."""
130        try:
131            device_info = await self.roku.update()
132
133            app_running = False
134
135            if device_info.app is not None:
136                app_running = device_info.app.app_id == self.provider.config.get_value(
137                    CONF_ROKU_APP_ID
138                )
139
140            if app_running:
141                # The closet thing the app has to playback stop,
142                # is sending a empty media object.
143                # I hope to implement a better solution into the app.
144                await self.roku_input(
145                    {
146                        "u": " ",
147                        "t": "a",
148                        "songName": "Music Assistant",
149                        "artistName": "Waiting for Playback...",
150                    },
151                )
152
153            logger = self.provider.logger.getChild(self.player_id)
154            logger.info("Received STOP command on player %s", self.display_name)
155            self._attr_playback_state = PlaybackState.IDLE
156            self._attr_current_media = None
157            self.update_state()
158        except Exception:
159            self.logger.error("Failed to send stop signal to: %s", self.name)
160
161    async def pause(self) -> None:
162        """Pause command."""
163        await self.roku.remote("play")
164
165        logger = self.provider.logger.getChild(self.player_id)
166        logger.info("Received PAUSE command on player %s", self.display_name)
167        self.update_state()
168
169    async def play_media(self, media: PlayerMedia) -> None:
170        """Play media command."""
171        stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
172        try:
173            device_info = await self.roku.update()
174
175            app_running = False
176
177            if device_info.app is not None:
178                app_running = (
179                    device_info.app.app_id == self.provider.config.get_value(CONF_ROKU_APP_ID)
180                    if not device_info.app.screensaver
181                    else False
182                )
183
184            f_media = {
185                "u": stream_url,
186                "t": "a",
187                "albumName": media.album or "",
188                "songName": media.title,
189                "artistName": (
190                    "Music Assistant Radio"
191                    if media.media_type == MediaType.RADIO
192                    else media.artist
193                    if media.artist is not None
194                    else ("Flow Mode" if self.flow_mode else "Music Assistant")
195                ),
196                "albumArt": ("" if self.flow_mode else media.image_url or ""),
197                "songFormat": "flac",
198                "duration": media.duration or "",
199                "isLive": (
200                    "true"
201                    if media.media_type == MediaType.RADIO
202                    or media.duration is None
203                    or self.flow_mode
204                    else ""
205                ),
206            }
207
208            if app_running:
209                await self.roku_input(f_media)
210            else:
211                await self.roku.launch(
212                    cast("str", self.provider.config.get_value(CONF_ROKU_APP_ID)),
213                    f_media,
214                )
215
216            logger = self.provider.logger.getChild(self.player_id)
217            logger.info(
218                "Received PLAY_MEDIA command on player %s with uri %s", self.display_name, media.uri
219            )
220            self._attr_powered = True
221            self._attr_current_media = media
222            self.update_state()
223        except Exception:
224            self.logger.error("Failed to Play Media on: %s", self.name)
225            return
226
227    async def enqueue_next_media(self, media: PlayerMedia) -> None:
228        """Handle enqueuing of the next (queue) item on the player."""
229        try:
230            device_info = await self.roku.update()
231
232            app_running = False
233
234            if device_info.app is not None:
235                app_running = device_info.app.app_id == self.provider.config.get_value(
236                    CONF_ROKU_APP_ID
237                )
238
239            if app_running:
240                await self.roku_input(
241                    {
242                        "u": media.uri,
243                        "t": "a",
244                        "albumName": media.album,
245                        "songName": media.title,
246                        "artistName": media.artist,
247                        "albumArt": media.image_url,
248                        "songFormat": "flac",
249                        "duration": media.duration,
250                        "enqueue": "true",
251                    },
252                )
253                self.queued = media
254        except Exception:
255            self.logger.error("Failed to Enqueue Media on: %s", self.name)
256            return
257
258    async def poll(self) -> None:
259        """Poll player for state updates."""
260        # Pull Device State
261        try:
262            device_info = await self.roku.update()
263            self._attr_available = True
264        except Exception:
265            self._attr_available = False
266            self.logger.error("Failed to retrieve Update from: %s", self.name)
267            self.update_state()
268            return
269
270        app_running = False
271
272        if device_info.app is not None:
273            app_running = device_info.app.app_id == self.provider.config.get_value(CONF_ROKU_APP_ID)
274
275        self._attr_powered = app_running
276
277        # If Media's Playing update its state
278        if self.powered and app_running:
279            try:
280                media_state = await self.roku._get_media_state()
281
282                play_states: dict[str, PlaybackState] = {
283                    "play": PlaybackState.PLAYING,
284                    "pause": PlaybackState.PAUSED,
285                }
286
287                self._attr_playback_state = play_states.get(
288                    media_state["@state"], PlaybackState.IDLE
289                )
290
291                if "position" in media_state:
292                    try:
293                        position = int(media_state["position"].split(" ", 1)[0]) / 1000
294                        if self._attr_elapsed_time is not None:
295                            if abs(position - self._attr_elapsed_time) > 10:
296                                self._attr_current_media = self.queued
297                        self._attr_elapsed_time = position
298                        self._attr_elapsed_time_last_updated = time.time()
299                    except Exception:
300                        self.logger.info(
301                            "Playback Position received from %s Was Invalid", self.name
302                        )
303
304                self.update_state()
305
306                if (
307                    not self.state.current_media
308                    or self._attr_playback_state != PlaybackState.PLAYING
309                ):
310                    return
311
312                image_url = self.state.current_media.image_url or ""
313
314                album_name = self.state.current_media.album or ""
315                song_name = self.state.current_media.title or ""
316                artist_name = self.state.current_media.artist or ""
317                if app_running and self.flow_mode:
318                    await self.roku_input(
319                        {
320                            "u": "",
321                            "t": "m",
322                            "albumName": album_name,
323                            "songName": song_name,
324                            "artistName": artist_name,
325                            "albumArt": image_url,
326                            "isLive": "true",
327                        },
328                    )
329            except Exception:
330                self.logger.warning("Failed to update media state for: %s", self.name)
331
332        self.update_state()
333
334    async def roku_input(self, params: dict[str, Any] | None = None) -> None:
335        """Send request to the running application on the Roku device."""
336        if params is None:
337            params = {}
338
339        encoded = urlencode(params)
340        await self.roku._request(f"input?{encoded}", method="POST", encoded=True)
341
342    async def on_unload(self) -> None:
343        """Handle logic when the player is unloaded from the Player controller."""
344        self.logger.info("Player %s unloaded", self.name)
345