/
/
1"""Tests for the HEOS player."""
2
3from __future__ import annotations
4
5from datetime import UTC, datetime
6from unittest.mock import MagicMock
7
8from music_assistant_models.enums import MediaType
9from pyheos import PlayState as HeosPlayState
10from pyheos import const as heos_const
11
12from music_assistant.models.player import PlayerMedia
13from music_assistant.providers.heos.player import HeosPlayer
14
15
16def _url_stream_now_playing() -> MagicMock:
17 """HEOS now-playing for a generic URL stream it cannot parse (MA's own source)."""
18 now_playing = MagicMock()
19 now_playing.source_id = heos_const.MUSIC_SOURCE_LOCAL_MUSIC
20 now_playing.type = "song"
21 now_playing.song = "Url Stream"
22 now_playing.album = "Url Stream"
23 now_playing.artist = "Url Stream"
24 now_playing.image_url = ""
25 now_playing.media_id = "1"
26 now_playing.album_id = "1"
27 now_playing.current_position = None
28 now_playing.current_position_updated = None
29 now_playing.duration = None
30 return now_playing
31
32
33def _external_now_playing(current_position: int | None, duration: int | None = 245000) -> MagicMock:
34 """HEOS now-playing for an external source, with millisecond progress values."""
35 now_playing = _url_stream_now_playing()
36 now_playing.source_id = 9999
37 now_playing.song = "External Track"
38 now_playing.current_position = current_position
39 now_playing.current_position_updated = datetime(2026, 1, 1, tzinfo=UTC)
40 now_playing.duration = duration
41 return now_playing
42
43
44def _make_player(now_playing: MagicMock) -> HeosPlayer:
45 """Build a HeosPlayer backed by a mocked device/controller."""
46 provider = MagicMock()
47 provider._heos_queue = MagicMock()
48 device = MagicMock()
49 device.player_id = "1"
50 device.name = "Kitchen"
51 device.heos = MagicMock()
52 device.state = HeosPlayState.PLAY
53 device.now_playing_media = now_playing
54 return HeosPlayer(provider, device)
55
56
57def test_url_stream_now_playing_preserves_ma_media_while_ma_controls() -> None:
58 """
59 Preserve MA's current_media against a HEOS "Url Stream" report.
60
61 HEOS cannot parse metadata from the generic URL stream MA serves, so it
62 reports ``Url Stream``. When MA controls playback that report must be
63 ignored (even if ``active_source`` is momentarily stale from the
64 ``play_url`` race) so MA's own, correct metadata is preserved.
65
66 See https://github.com/music-assistant/support/issues/5614
67 """
68 player = _make_player(_url_stream_now_playing())
69 correct_media = PlayerMedia(
70 uri="http://ma/stream/foo",
71 media_type=MediaType.TRACK,
72 title="Real Track",
73 artist="Real Artist",
74 album="Real Album",
75 )
76 player._attr_current_media = correct_media
77 player._ma_controls_playback = True
78 # Stale: a previous external source is still the recorded active source.
79 player._attr_active_source = "external_radio"
80
81 player._update_player_current_media()
82
83 # MA's correct media is preserved; the bogus "Url Stream" did not clobber it.
84 assert player._attr_current_media is correct_media
85 assert player._attr_current_media.title == "Real Track"
86 # Playback control was not latched away to the phantom external source.
87 assert player._ma_controls_playback is True
88
89
90def test_external_source_now_playing_still_updates_media() -> None:
91 """A genuinely external (non-local) source must still update current_media."""
92 now_playing = _url_stream_now_playing()
93 now_playing.source_id = 9999 # an external source, not MA's local stream
94 now_playing.song = "External Track"
95 now_playing.artist = "External Artist"
96 player = _make_player(now_playing)
97 player._attr_current_media = PlayerMedia(
98 uri="http://ma/stream/foo", media_type=MediaType.TRACK, title="MA Track"
99 )
100 player._ma_controls_playback = True
101 player._attr_active_source = player.player_id
102
103 player._update_player_current_media()
104
105 assert player._attr_current_media.title == "External Track"
106
107
108def test_media_position_and_duration_reported_in_seconds() -> None:
109 """HEOS reports milliseconds; the media must carry seconds."""
110 player = _make_player(_external_now_playing(113000))
111 player._attr_active_source = "9999"
112
113 player._update_player_current_media()
114
115 assert player._attr_current_media is not None
116 assert player._attr_current_media.elapsed_time == 113
117 assert player._attr_current_media.duration == 245
118
119
120def test_media_and_player_position_agree() -> None:
121 """The media-level and player-level positions must use the same unit."""
122 player = _make_player(_external_now_playing(113000))
123 player._attr_active_source = "9999"
124
125 player.set_dynamic_attributes(update_media=True)
126
127 assert player._attr_current_media is not None
128 assert player._attr_current_media.elapsed_time == player._attr_elapsed_time == 113
129
130
131def test_position_zero_is_reported_not_dropped() -> None:
132 """Position 0 is a real position at the start of a track, not a missing one."""
133 player = _make_player(_external_now_playing(0))
134 player._attr_active_source = "9999"
135
136 player.set_dynamic_attributes(update_media=True)
137
138 assert player._attr_current_media is not None
139 assert player._attr_current_media.elapsed_time == 0
140 assert player._attr_elapsed_time == 0
141
142
143def test_missing_progress_stays_none() -> None:
144 """Without progress info, no position or duration is reported."""
145 player = _make_player(_external_now_playing(None, duration=None))
146 player._attr_active_source = "9999"
147
148 player.set_dynamic_attributes(update_media=True)
149
150 assert player._attr_current_media is not None
151 assert player._attr_current_media.elapsed_time is None
152 assert player._attr_current_media.duration is None
153 assert player._attr_elapsed_time is None
154