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