/
/
/
1"""
2Tests for how ChromecastPlayer releases a Cast device once playback ends by itself.
3
4Playback that finishes on its own never gets a stop command: the queue simply runs
5out, or an announcement ends. Without a release the receiver app stays loaded, which
6on a TV or Nest Hub means Music Assistant stays on screen instead of the backdrop.
7"""
8
9from __future__ import annotations
10
11from functools import partial
12from typing import cast
13from unittest.mock import MagicMock
14
15import pytest
16from music_assistant_models.enums import PlaybackState, PlayerType
17from pychromecast import IDLE_APP_ID
18
19from music_assistant.providers.chromecast.constants import (
20 APP_QUIT_DELAY,
21 MASS_APP_ID,
22)
23from music_assistant.providers.chromecast.player import ChromecastPlayer
24
25
26def _handle_media_status(fake: MagicMock, status: MagicMock) -> None:
27 ChromecastPlayer._handle_media_status(cast("ChromecastPlayer", fake), status)
28
29
30def _fake_player(
31 *,
32 prev_state: PlaybackState = PlaybackState.PLAYING,
33 player_type: PlayerType = PlayerType.PLAYER,
34 active_cast_group: str | None = None,
35) -> MagicMock:
36 """
37 Build a MagicMock Cast player that handles media status like the real one.
38
39 :param prev_state: Playback state the player was in before the status arrives.
40 :param player_type: Type of the player.
41 :param active_cast_group: Player id of the cast group the player plays through, if any.
42 """
43 fake = MagicMock()
44 fake.type = player_type
45 fake.active_cast_group = active_cast_group
46 fake.powered = False
47 fake.group_members = []
48 fake.cc.app_id = MASS_APP_ID
49 fake._app_quit_task_id = "cast_quit_app_test"
50 fake._attr_playback_state = prev_state
51 fake._flow_stream_underrun.return_value = False
52 fake._schedule_app_release = partial(
53 ChromecastPlayer._schedule_app_release, cast("ChromecastPlayer", fake)
54 )
55 return fake
56
57
58def _media_status(
59 *,
60 playing: bool = False,
61 paused: bool = False,
62 buffering: bool = False,
63 content_id: str = "",
64) -> MagicMock:
65 """
66 Build a MediaStatus as the receiver reports it.
67
68 :param playing: Whether the receiver reports playback.
69 :param paused: Whether the receiver reports paused playback.
70 :param buffering: Whether the receiver reports buffering, which it counts as playing.
71 :param content_id: Content id the receiver has loaded, if any.
72 """
73 status = MagicMock()
74 status.content_id = content_id
75 status.player_state = (
76 "BUFFERING" if buffering else "PLAYING" if playing else "PAUSED" if paused else "IDLE"
77 )
78 status.player_is_playing = playing or buffering
79 status.player_is_paused = paused
80 status.player_is_idle = not playing and not paused and not buffering
81 return status
82
83
84def _assert_release_scheduled(fake: MagicMock) -> None:
85 fake.mass.call_later.assert_called_once_with(
86 APP_QUIT_DELAY, fake._quit_app_when_unused, task_id=fake._app_quit_task_id
87 )
88
89
90@pytest.mark.parametrize("prev_state", [PlaybackState.PLAYING, PlaybackState.PAUSED])
91def test_playback_ending_releases_the_device(prev_state: PlaybackState) -> None:
92 """Nothing follows up on playback that ended on its own, so the device is released."""
93 fake = _fake_player(prev_state=prev_state)
94
95 _handle_media_status(fake, _media_status())
96
97 assert fake._attr_playback_state == PlaybackState.IDLE
98 _assert_release_scheduled(fake)
99
100
101def test_an_announcement_on_an_idle_player_releases_the_device() -> None:
102 """
103 An announcement claims an idle device, so it must hand it back afterwards.
104
105 The player is never stopped in this flow (there was nothing to stop), which is
106 what used to leave the receiver app loaded for good.
107 """
108 fake = _fake_player(prev_state=PlaybackState.IDLE)
109
110 _handle_media_status(fake, _media_status(playing=True, content_id="http://mass/announce.mp3"))
111 assert fake._attr_playback_state == PlaybackState.PLAYING
112 fake.mass.call_later.assert_not_called()
113
114 _handle_media_status(fake, _media_status())
115
116 _assert_release_scheduled(fake)
117
118
119def test_a_device_that_ran_dry_at_the_end_of_the_flow_stream_releases_it() -> None:
120 """A device stuck buffering at flow EOF counts as finished, so it is released too."""
121 fake = _fake_player()
122 fake._flow_stream_underrun.return_value = True
123
124 _handle_media_status(fake, _media_status(buffering=True, content_id="http://mass/flow.flac"))
125
126 assert fake._attr_playback_state == PlaybackState.IDLE
127 _assert_release_scheduled(fake)
128
129
130@pytest.mark.parametrize("app_id", [None, IDLE_APP_ID])
131def test_a_released_device_does_not_become_a_source(app_id: str | None) -> None:
132 """A released device sits on its backdrop, which is not something to select."""
133 fake = _fake_player()
134 fake.cc.app_id = app_id
135 fake._attr_source_list = []
136
137 _handle_media_status(fake, _media_status())
138
139 assert fake._attr_active_source is None
140 assert fake._attr_source_list == []
141
142
143def test_pausing_keeps_the_device_claimed() -> None:
144 """A paused player is meant to be resumed, so it keeps its Cast session."""
145 fake = _fake_player()
146
147 _handle_media_status(fake, _media_status(paused=True, content_id="http://mass/track.flac"))
148
149 assert fake._attr_playback_state == PlaybackState.PAUSED
150 fake.mass.call_later.assert_not_called()
151
152
153def test_an_idle_player_is_not_released_again() -> None:
154 """A player that was already idle has no session of its own to release."""
155 fake = _fake_player(prev_state=PlaybackState.IDLE)
156
157 _handle_media_status(fake, _media_status())
158
159 fake.mass.call_later.assert_not_called()
160
161
162@pytest.mark.parametrize(
163 ("content_id", "playing", "paused"),
164 [
165 ("https://cast.music-assistant.io/dashboard-keepalive.mp4", True, False),
166 ("https://cast.music-assistant.io/keepalive.png", False, True),
167 ],
168)
169def test_a_dashboard_keepalive_does_not_release_the_device(
170 content_id: str, playing: bool, paused: bool
171) -> None:
172 """The receiver is showing a dashboard, so the device is deliberately kept claimed."""
173 fake = _fake_player()
174
175 _handle_media_status(fake, _media_status(playing=playing, paused=paused, content_id=content_id))
176
177 assert fake._attr_playback_state == PlaybackState.IDLE
178 fake.mass.call_later.assert_not_called()
179
180
181def test_a_cast_group_is_not_released_when_playback_ends() -> None:
182 """A cast group is released by its power control, not by playback ending."""
183 fake = _fake_player(player_type=PlayerType.GROUP)
184
185 _handle_media_status(fake, _media_status())
186
187 assert fake._attr_playback_state == PlaybackState.IDLE
188 fake.mass.call_later.assert_not_called()
189
190
191def test_a_group_member_is_not_released_when_the_group_stops() -> None:
192 """A member follows the group's Cast session, so it has none of its own to release."""
193 fake = _fake_player(active_cast_group="group_player_1")
194 # the handler reads the group's status instead of its own, but only for a real cast player
195 group = MagicMock(spec=ChromecastPlayer)
196 group.cc = MagicMock()
197 group.cc.media_controller.status = _media_status()
198 fake.mass.players.get_player.return_value = group
199
200 _handle_media_status(fake, _media_status())
201
202 assert fake._attr_playback_state == PlaybackState.IDLE
203 fake.mass.call_later.assert_not_called()
204