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