/
/
1"""HEOS Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7from copy import copy
8from typing import TYPE_CHECKING, cast
9
10from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature, PlayerType
11from music_assistant_models.errors import PlayerCommandFailed, SetupFailedError
12from music_assistant_models.player import DeviceInfo, PlayerSource
13from pyheos import Heos, HeosError, const
14from pyheos import PlayState as HeosPlayState
15
16from music_assistant.constants import EXTERNAL_PAUSE_IDLE_TIMEOUT, VERBOSE_LOG_LEVEL
17from music_assistant.models.player import Player, PlayerMedia
18from music_assistant.providers.heos.helpers import media_uri_from_now_playing_media
19
20from .constants import (
21 HEOS_MEDIA_TYPE_TO_MEDIA_TYPE,
22 HEOS_PLAY_STATE_TO_PLAYBACK_STATE,
23 NON_HIRES_HEOS_MODELS,
24)
25
26if TYPE_CHECKING:
27 from pyheos import HeosPlayer as PyHeosPlayer
28
29 from .provider import HeosPlayerProvider
30
31
32PLAYER_FEATURES = {
33 PlayerFeature.VOLUME_SET,
34 PlayerFeature.VOLUME_MUTE,
35 PlayerFeature.PAUSE,
36 PlayerFeature.NEXT_PREVIOUS,
37 PlayerFeature.SELECT_SOURCE,
38 PlayerFeature.SET_MEMBERS,
39 PlayerFeature.PLAY_MEDIA,
40}
41
42
43class HeosPlayer(Player):
44 """HeosPlayer in Music Assistant."""
45
46 # HEOS keeps a source it loaded itself reported as paused once the app walked away,
47 # and pushes no event when that session goes stale.
48 _attr_external_pause_idle_timeout = EXTERNAL_PAUSE_IDLE_TIMEOUT
49
50 _heos: Heos
51 _heos_queue: Heos
52 _device: PyHeosPlayer
53
54 @property
55 def requires_flow_mode(self) -> bool:
56 """Return if the player requires flow mode."""
57 return True
58
59 def __init__(self, provider: HeosPlayerProvider, device: PyHeosPlayer) -> None:
60 """Initialize the Player."""
61 super().__init__(provider, str(device.player_id))
62
63 self._device: PyHeosPlayer = device
64 self._ma_controls_playback = False
65 self._ma_playback_starting = False
66 self._ma_playback_transition_timer_id = f"heos_playback_transition_{self.player_id}"
67 self._on_unload_callbacks.append(self._cancel_ma_playback_transition)
68 self._queue_cleanup_lock = asyncio.Lock()
69 self._queue_cleanup_pending = False
70
71 if self._device.heos is None:
72 raise SetupFailedError("HEOS device has no controller assigned")
73
74 if provider._heos_queue is None:
75 raise SetupFailedError("HEOS queue controller is not set up")
76
77 # Keep internal reference so we don't need to check None on each call
78 self._heos = self._device.heos
79 self._heos_queue = provider._heos_queue
80
81 self._attr_type = PlayerType.PLAYER
82 self._attr_supported_features = PLAYER_FEATURES
83 self._attr_can_group_with = {self.provider.instance_id}
84
85 async def setup(self) -> None:
86 """Set up the player."""
87 self.set_device_info()
88 self.set_dynamic_attributes(update_media=True)
89
90 await self.mass.players.register_or_update(self)
91
92 self._on_unload_callbacks.append(
93 self._device.add_on_player_event(self._player_event_received)
94 )
95
96 await self.build_group_list()
97 await self.build_source_list()
98
99 def set_device_info(self) -> None:
100 """Set all device info attributes."""
101 # Extract manufacturer and model from device model string, if available
102 model_parts = self._device.model.split(maxsplit=1)
103 manufacturer = model_parts[0] if len(model_parts) == 2 else "HEOS"
104 model = model_parts[1] if len(model_parts) == 2 else self._device.model
105
106 _device_info = DeviceInfo(
107 model=model,
108 software_version=self._device.version,
109 manufacturer=manufacturer,
110 )
111 _device_info.ip_address = self._device.ip_address
112 self._attr_device_info = _device_info
113 self._attr_available = self._device.available
114 self._attr_name = self._device.name
115
116 # Gen 1 HEOS hardware is capped at 48kHz/16-bit; HS2 and newer models
117 # are hi-res capable up to 192kHz/24-bit
118 if model in NON_HIRES_HEOS_MODELS:
119 self._attr_supported_sample_rates = [(44100, 16), (48000, 16)]
120 else:
121 self._attr_supported_sample_rates = [
122 (sr, bd) for sr in (44100, 48000, 88200, 96000, 176400, 192000) for bd in (16, 24)
123 ]
124
125 async def build_group_list(self) -> None:
126 """Build group list based on group info from controller."""
127 # Group IDs are the player ID of the leader
128 if self._device.group_id is not None and str(self._device.group_id) == self.player_id:
129 group_info = await self._heos.get_group_info(self._device.group_id)
130 self._attr_group_members = [
131 str(group_info.lead_player_id),
132 *(str(member) for member in group_info.member_player_ids),
133 ]
134 else:
135 self._attr_group_members.clear()
136
137 self.update_state()
138
139 async def build_source_list(self) -> None:
140 """Build source list based on music source list, combined with player specific inputs."""
141 prov = cast("HeosPlayerProvider", self.provider)
142 self._attr_source_list = prov.music_source_list[:] # copy so we can modify
143
144 for input_source in prov.input_source_list:
145 # Only add input sources that belong to this player
146 if str(input_source.source_id) != self.player_id or input_source.media_id is None:
147 continue
148
149 self._attr_source_list.append(
150 PlayerSource(
151 id=input_source.media_id,
152 name=input_source.name,
153 can_play_pause=True,
154 )
155 )
156
157 self.update_state()
158
159 async def _player_event_received(self, event: str) -> None:
160 """Handle player device events."""
161 self.logger.log(
162 (
163 VERBOSE_LOG_LEVEL
164 if event == const.EVENT_PLAYER_NOW_PLAYING_PROGRESS
165 else logging.DEBUG
166 ),
167 "[%s] Event received: %s",
168 self._device.name,
169 event,
170 )
171 match event:
172 case const.EVENT_PLAYER_STATE_CHANGED:
173 self._update_player_state()
174 self._update_player_current_media()
175 self._schedule_queue_cleanup()
176
177 case const.EVENT_PLAYER_NOW_PLAYING_CHANGED:
178 self._update_player_current_media()
179 self._update_player_playing_progress()
180 self._schedule_queue_cleanup()
181
182 case const.EVENT_PLAYER_QUEUE_CHANGED:
183 self._schedule_queue_cleanup()
184
185 case const.EVENT_PLAYER_NOW_PLAYING_PROGRESS:
186 self._update_player_playing_progress()
187
188 case const.EVENT_PLAYER_VOLUME_CHANGED:
189 self._update_player_volume()
190
191 case const.EVENT_PLAYER_PLAYBACK_ERROR:
192 self.logger.error(
193 "[%s] Playback error: %s", self._device.name, self._device.playback_error
194 )
195 self._queue_cleanup_pending = False
196 self.set_dynamic_attributes()
197
198 case _:
199 # Update everything on other events
200 self.set_dynamic_attributes()
201
202 self.update_state()
203
204 def _update_player_volume(self) -> None:
205 """Update volume properties."""
206 self._attr_volume_level = self._device.volume
207 self._attr_volume_muted = self._device.is_muted
208
209 def _update_player_state(self) -> None:
210 """Update playback state."""
211 self._attr_playback_state = HEOS_PLAY_STATE_TO_PLAYBACK_STATE.get(
212 self._device.state, PlaybackState.UNKNOWN
213 )
214
215 def _update_player_current_media(self) -> None:
216 """Update current media properties."""
217 now_playing = self._device.now_playing_media
218 if self._device.state == HeosPlayState.STOP:
219 self.logger.debug(
220 "[%s] Ignoring now playing change while stopped: %s",
221 self._device.name,
222 now_playing,
223 )
224 return
225
226 if self._ma_playback_starting:
227 self.logger.debug(
228 "[%s] Ignoring now playing change while MA playback starts: %s",
229 self._device.name,
230 now_playing,
231 )
232 return
233
234 # Only update if we're not playing from our queue
235 # HEOS does not make a distinction on source ID when playing from a DLNA server, USB stick,
236 # generic URL (like MA), or other local source.
237 # We can only know we're playing from MA if we started this session.
238 # When MA controls playback it serves a generic URL stream whose metadata HEOS
239 # cannot parse (it reports "Url Stream"). Ignore that unreliable now-playing even
240 # when active_source is momentarily stale (e.g. the play_url race before play_media
241 # sets it) so MA's own, correct current_media is preserved. See support #5614.
242 if (now_playing.source_id != const.MUSIC_SOURCE_LOCAL_MUSIC) or (
243 self._attr_active_source != self.player_id and not self._ma_controls_playback
244 ):
245 self._ma_controls_playback = False
246 self._queue_cleanup_pending = False
247 self.logger.debug(
248 "[%s] Now playing changed externally: %s", self._device.name, now_playing
249 )
250
251 if now_playing.source_id == const.MUSIC_SOURCE_AUX_INPUT:
252 self._attr_active_source = str(now_playing.media_id)
253 else:
254 self._attr_active_source = str(now_playing.source_id)
255
256 # HEOS reports position and duration in milliseconds, PlayerMedia expects seconds
257 self._attr_current_media = PlayerMedia(
258 uri=now_playing.media_id or media_uri_from_now_playing_media(now_playing),
259 media_type=HEOS_MEDIA_TYPE_TO_MEDIA_TYPE.get(
260 now_playing.type,
261 MediaType.UNKNOWN,
262 ),
263 title=now_playing.song,
264 artist=now_playing.artist,
265 album=now_playing.album,
266 image_url=now_playing.image_url,
267 duration=int(now_playing.duration / 1000) if now_playing.duration else None,
268 source_id=str(now_playing.source_id),
269 elapsed_time=(
270 int(now_playing.current_position / 1000)
271 if now_playing.current_position is not None
272 else None
273 ),
274 elapsed_time_last_updated=(
275 now_playing.current_position_updated.timestamp()
276 if now_playing.current_position_updated
277 else None
278 ),
279 # TODO: We can use custom_data to set the IDs
280 )
281
282 def _update_player_playing_progress(self) -> None:
283 """Update current media progress properties."""
284 now_playing = self._device.now_playing_media
285
286 self._attr_elapsed_time = (
287 now_playing.current_position / 1000
288 if now_playing.current_position is not None
289 else None
290 )
291 self._attr_elapsed_time_last_updated = (
292 now_playing.current_position_updated.timestamp()
293 if now_playing.current_position_updated
294 else None
295 )
296
297 def set_dynamic_attributes(self, update_media: bool = False) -> None:
298 """Update all player dynamic attributes."""
299 self._update_player_volume()
300 self._update_player_state()
301
302 if update_media:
303 self._update_player_current_media()
304
305 self._update_player_playing_progress()
306
307 async def volume_set(self, volume_level: int) -> None:
308 """Handle VOLUME_SET command on the player."""
309 await self._device.set_volume(volume_level)
310
311 async def volume_mute(self, muted: bool) -> None:
312 """Handle VOLUME MUTE command on the player."""
313 if muted:
314 await self._device.mute()
315 else:
316 await self._device.unmute()
317
318 async def play(self) -> None:
319 """Handle PLAY command on the player."""
320 await self._device.play()
321
322 async def stop(self) -> None:
323 """Handle STOP command on the player."""
324 await self._device.stop()
325
326 async def pause(self) -> None:
327 """Handle PAUSE command on the player."""
328 await self._device.pause()
329
330 async def next_track(self) -> None:
331 """Handle NEXT_TRACK command on the player."""
332 await self._device.play_next()
333
334 async def previous_track(self) -> None:
335 """Handle PREVIOUS_TRACK command on the player."""
336 await self._device.play_previous()
337
338 async def play_media(self, media: PlayerMedia) -> None:
339 """Handle PLAY MEDIA command on given player."""
340 self.logger.debug(
341 "[%s] Received PLAY_MEDIA command with media_type=%s uri=%s",
342 self._device.name,
343 media.media_type,
344 media.uri,
345 )
346
347 url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
348 self._cancel_ma_playback_transition()
349 self._ma_playback_starting = True
350 self._ma_controls_playback = True
351 try:
352 await self._device.play_url(url)
353 except HeosError as err:
354 self._cancel_ma_playback_transition()
355 self._ma_controls_playback = False
356 self._queue_cleanup_pending = False
357 raise PlayerCommandFailed("Failed to start playback.") from err
358
359 self._attr_current_media = media
360 self._attr_active_source = self.player_id
361 self._queue_cleanup_pending = True
362
363 self.mass.call_later(
364 5,
365 self._finish_ma_playback_transition,
366 task_id=self._ma_playback_transition_timer_id,
367 )
368
369 self.update_state()
370
371 def _schedule_queue_cleanup(self) -> None:
372 """Debounce queue cleanup so rapid queue changes only trigger one follow-up."""
373 if (
374 not self._ma_controls_playback
375 or not self._queue_cleanup_pending
376 or self._attr_playback_state != PlaybackState.PLAYING
377 ):
378 return
379
380 self.mass.call_later(
381 1,
382 self._start_queue_cleanup_task,
383 task_id=f"heos_queue_cleanup_timer_{self.player_id}",
384 )
385
386 def _start_queue_cleanup_task(self) -> None:
387 """Start the queue cleanup task if not already running."""
388 if (
389 not self._ma_controls_playback
390 or not self._queue_cleanup_pending
391 or self._queue_cleanup_lock.locked()
392 ):
393 return
394
395 self.mass.create_task(
396 self._cleanup_heos_queue(),
397 task_id=f"heos_queue_cleanup_task_{self.player_id}",
398 )
399
400 async def _cleanup_heos_queue(self) -> None:
401 async with self._queue_cleanup_lock:
402 if not self._ma_controls_playback:
403 self._queue_cleanup_pending = False
404 return
405 if not self._queue_cleanup_pending:
406 return
407 if self._attr_playback_state != PlaybackState.PLAYING:
408 self.logger.debug(
409 "[%s] Queue cleanup postponed (state=%s)",
410 self._device.name,
411 self._attr_playback_state,
412 )
413 return
414 try:
415 self.logger.debug("[%s] Queue cleanup started", self._device.name)
416 queue_items = await self._heos_queue.player_get_queue(self._device.player_id)
417 now_playing = await self._heos_queue.get_now_playing_media(self._device.player_id)
418 current_queue_id = now_playing.queue_id
419 if current_queue_id is None:
420 self.logger.debug(
421 "[%s] Queue cleanup postponed (no current qid yet)",
422 self._device.name,
423 )
424 self._schedule_queue_cleanup()
425 return
426
427 queue_ids_to_remove = [
428 item.queue_id for item in queue_items if item.queue_id != current_queue_id
429 ]
430 self.logger.debug(
431 "[%s] Queue cleanup removing %s (current qid=%s)",
432 self._device.name,
433 queue_ids_to_remove,
434 current_queue_id,
435 )
436 if queue_ids_to_remove:
437 await self._heos_queue.player_remove_from_queue(
438 self._device.player_id, queue_ids_to_remove
439 )
440 self._queue_cleanup_pending = False
441
442 except HeosError as err:
443 self.logger.warning(
444 "[%s] Failed to handle HEOS queue after queue change: %s",
445 self._device.name,
446 err,
447 )
448
449 async def set_members(
450 self,
451 player_ids_to_add: list[str] | None = None,
452 player_ids_to_remove: list[str] | None = None,
453 ) -> None:
454 """Handle SET MEMBERS command on player."""
455 if player_ids_to_add is None and player_ids_to_remove is None:
456 return
457
458 members: list[str] = copy(self._attr_group_members)
459
460 # Make sure we are always in the group
461 if self.player_id not in members:
462 members = [self.player_id, *members]
463
464 for added_player_id in player_ids_to_add or []:
465 members.append(added_player_id)
466
467 for removed_player_id in player_ids_to_remove or []:
468 members.remove(removed_player_id)
469
470 if len(members) <= 1:
471 await self._heos.remove_group(self._device.player_id)
472 else:
473 await self._heos.set_group([int(player) for player in members])
474 # group_members will be updated when group_changed event is handled
475
476 async def select_source(self, source: str) -> None:
477 """Handle SELECT SOURCE command on the player."""
478 self.logger.debug("[%s] Selecting source %s", self._device.name, source)
479 self._cancel_ma_playback_transition()
480 self._ma_controls_playback = False
481 self._queue_cleanup_pending = False
482 await self._device.play_input_source(source)
483
484 def _cancel_ma_playback_transition(self) -> None:
485 """Cancel the transition to MA-controlled playback."""
486 self.mass.cancel_timer(self._ma_playback_transition_timer_id)
487 self._ma_playback_starting = False
488
489 def _finish_ma_playback_transition(self) -> None:
490 """Apply the latest HEOS state after MA playback starts."""
491 self._ma_playback_starting = False
492 self._update_player_current_media()
493 self.update_state()
494