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