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