/
/
/
1"""
2Tests for progress metadata being pushed on playback-state transitions.
3
4Regression coverage for: resuming a paused Sendspin group did not send a
5server/state with metadata.progress, because current_media's identity is
6unchanged across pause/resume, so the debounced media-updated callback never
7fired. Also covers the stale-anchor bug this uncovered: the player's own
8elapsed-time anchor (_attr_elapsed_time_last_updated) is only advanced by the
9audio commit loop, which stops while paused, so it must be fast-forwarded on
10resume - and the snapshot the metadata push actually reads (self.state.current_media)
11must be patched directly, since the queue controller that rebuilds it only
12refreshes its own cached anchor via a 500ms-debounced callback.
13"""
14
15from __future__ import annotations
16
17import time
18from types import SimpleNamespace
19from unittest.mock import AsyncMock, Mock, PropertyMock, patch
20
21from aiosendspin.models.types import PlaybackStateType
22from aiosendspin.server.events import GroupStateChangedEvent
23from music_assistant_models.enums import MediaType, PlaybackState
24from music_assistant_models.player import PlayerMedia
25
26from music_assistant.providers.sendspin.player import SendspinPlayer
27
28
29def _player(
30 *, playback_state: PlaybackState, current_media: PlayerMedia | None
31) -> tuple[SendspinPlayer, Mock, AsyncMock]:
32 """Build a real (but un-__init__'d) SendspinPlayer so super() calls resolve correctly."""
33 player = object.__new__(SendspinPlayer)
34 player._player_id = "p1"
35 player._attr_playback_state = playback_state
36 player._attr_current_media = current_media
37 player._attr_elapsed_time_last_updated = 1.0
38 # update_state() is mocked out (its real implementation needs a fully-initialized
39 # player/mass), so the snapshot it would normally rebuild is seeded directly here.
40 # The fix under test reads/mutates this via the `state` property, not update_state().
41 player._state = SimpleNamespace(current_media=current_media) # type: ignore[assignment]
42 player.update_state = Mock() # type: ignore[method-assign,misc]
43 player.send_current_media_metadata = AsyncMock() # type: ignore[method-assign]
44 create_task = Mock(side_effect=lambda coro, **_kwargs: coro.close())
45 player.mass = Mock(create_task=create_task)
46 return player, create_task, player.send_current_media_metadata
47
48
49def test_resume_from_pause_pushes_progress_and_refreshes_anchors() -> None:
50 """Resuming the same track refreshes both anchors and schedules a progress push."""
51 stale_media = PlayerMedia(
52 uri="track-1", media_type=MediaType.TRACK, elapsed_time=14, elapsed_time_last_updated=1.0
53 )
54 player, create_task, send_metadata = _player(
55 playback_state=PlaybackState.PAUSED, current_media=stale_media
56 )
57
58 with patch.object(SendspinPlayer, "synced_to", new_callable=PropertyMock) as synced_to:
59 synced_to.return_value = None
60 before = time.time()
61 SendspinPlayer.group_event_cb(
62 player, Mock(), GroupStateChangedEvent(state=PlaybackStateType.PLAYING)
63 )
64 after = time.time()
65
66 # the player's own timing base (what the queue controller uses to recompute
67 # its cached anchor) must be fast-forwarded
68 player_anchor = player._attr_elapsed_time_last_updated
69 assert player_anchor is not None
70 assert before <= player_anchor <= after
71 # the snapshot object the imminent metadata push actually reads must also be
72 # patched directly, since the queue controller's own refresh is debounced
73 refreshed = stale_media.elapsed_time_last_updated
74 assert refreshed is not None
75 assert before <= refreshed <= after
76 send_metadata.assert_called_once()
77 create_task.assert_called_once()
78 assert create_task.call_args.kwargs["task_id"] == "sendspin_metadata_p1"
79 assert create_task.call_args.kwargs["abort_existing"] is True
80
81
82def test_pause_pushes_progress_without_touching_anchors() -> None:
83 """Pausing schedules a progress push but does not rewrite either elapsed-time anchor."""
84 media = PlayerMedia(
85 uri="track-1", media_type=MediaType.TRACK, elapsed_time=14, elapsed_time_last_updated=1.0
86 )
87 player, _create_task, send_metadata = _player(
88 playback_state=PlaybackState.PLAYING, current_media=media
89 )
90
91 with patch.object(SendspinPlayer, "synced_to", new_callable=PropertyMock) as synced_to:
92 synced_to.return_value = None
93 SendspinPlayer.group_event_cb(
94 player, Mock(), GroupStateChangedEvent(state=PlaybackStateType.PAUSED)
95 )
96
97 assert player._attr_elapsed_time_last_updated == 1.0
98 assert media.elapsed_time_last_updated == 1.0
99 send_metadata.assert_called_once()
100
101
102def test_synced_follower_does_no_resume_work() -> None:
103 """
104 A synced (non-leader) player never pushes metadata nor touches any anchor.
105
106 A follower's self.state.current_media is a reference to the leader's object,
107 so touching it here would corrupt the leader's anchor - the resume handling
108 must be leader-only.
109 """
110 media = PlayerMedia(
111 uri="track-1", media_type=MediaType.TRACK, elapsed_time=14, elapsed_time_last_updated=1.0
112 )
113 player, _create_task, send_metadata = _player(
114 playback_state=PlaybackState.PAUSED, current_media=media
115 )
116
117 with patch.object(SendspinPlayer, "synced_to", new_callable=PropertyMock) as synced_to:
118 synced_to.return_value = "leader-1"
119 SendspinPlayer.group_event_cb(
120 player, Mock(), GroupStateChangedEvent(state=PlaybackStateType.PLAYING)
121 )
122
123 send_metadata.assert_not_called()
124 assert player._attr_elapsed_time_last_updated == 1.0
125 assert media.elapsed_time_last_updated == 1.0
126
127
128def test_fresh_play_from_idle_does_not_refresh_anchors() -> None:
129 """A first play (not a pause->play resume) leaves the fresh media's anchor untouched."""
130 fresh_media = PlayerMedia(
131 uri="track-1", media_type=MediaType.TRACK, elapsed_time=0, elapsed_time_last_updated=42.0
132 )
133 player, _create_task, send_metadata = _player(
134 playback_state=PlaybackState.IDLE, current_media=fresh_media
135 )
136
137 with patch.object(SendspinPlayer, "synced_to", new_callable=PropertyMock) as synced_to:
138 synced_to.return_value = None
139 SendspinPlayer.group_event_cb(
140 player, Mock(), GroupStateChangedEvent(state=PlaybackStateType.PLAYING)
141 )
142
143 assert player._attr_elapsed_time_last_updated == 1.0
144 assert fresh_media.elapsed_time_last_updated == 42.0
145 send_metadata.assert_called_once()
146