/
/
/
1"""Tests that ``play_index`` resets the queue's elapsed clock with the item switch."""
2
3from __future__ import annotations
4
5import time
6from unittest.mock import AsyncMock, MagicMock, Mock
7
8import pytest
9from music_assistant_models.errors import MediaNotFoundError
10from music_assistant_models.player_queue import PlayerQueue
11from music_assistant_models.queue_item import QueueItem
12
13from music_assistant.controllers.player_queues import PlayerQueuesController
14from music_assistant.controllers.player_queues.state import PlayerQueueData
15from music_assistant.models.music_provider import MusicProvider, ProviderStreamLimitError
16
17QUEUE_ID = "q1"
18STALE_ELAPSED = 43_217.7
19
20
21def _controller_with_stale_queue() -> tuple[
22 PlayerQueuesController, PlayerQueue, list[tuple[str | None, float]]
23]:
24 """Build a bare controller mid-way through item "old", recording signal_update states."""
25 ctrl = PlayerQueuesController.__new__(PlayerQueuesController)
26 queue = PlayerQueue(queue_id=QUEUE_ID, active=True, display_name="Q1", available=True, items=2)
27 items = [
28 QueueItem(queue_id=QUEUE_ID, queue_item_id="old", name="old", duration=50_000),
29 QueueItem(queue_id=QUEUE_ID, queue_item_id="new", name="new", duration=279),
30 ]
31 queue.current_index = 0
32 queue.current_item = items[0]
33 queue.elapsed_time = STALE_ELAPSED
34 queue.elapsed_time_last_updated = time.time() - 900
35 queue_data = PlayerQueueData(queue=queue)
36 queue_data.items = items
37 ctrl._queue_data = {QUEUE_ID: queue_data}
38
39 signals: list[tuple[str | None, float]] = []
40
41 def _record_signal(_queue_id: str, items_changed: bool = False) -> None: # noqa: ARG001
42 item = queue.current_item
43 signals.append((item.queue_item_id if item else None, queue.elapsed_time))
44
45 ctrl.signal_update = Mock(side_effect=_record_signal) # type: ignore[method-assign]
46 ctrl._check_player_permission = Mock() # type: ignore[method-assign]
47 ctrl._set_transitioning = Mock() # type: ignore[method-assign]
48 ctrl._load_item = AsyncMock() # type: ignore[method-assign]
49 ctrl._get_next_index = Mock(return_value=None) # type: ignore[method-assign]
50 ctrl.player_media_from_queue_item = AsyncMock() # type: ignore[method-assign]
51 ctrl.mass = MagicMock()
52 ctrl.mass.players.play_media = AsyncMock()
53 ctrl.logger = MagicMock() # the retry-fallback path logs a warning
54 return ctrl, queue, signals
55
56
57async def test_play_index_resets_elapsed_with_item_switch() -> None:
58 """Starting a new item zeroes elapsed_time and freshens its timestamp."""
59 ctrl, queue, signals = _controller_with_stale_queue()
60 before = time.time()
61
62 await ctrl.play_index(QUEUE_ID, 1)
63
64 assert queue.current_item is not None
65 assert queue.current_item.queue_item_id == "new"
66 assert queue.elapsed_time == 0
67 assert queue.elapsed_time_last_updated >= before
68 # no signaled state may pair the new item with the previous item's elapsed
69 assert all(elapsed == 0 for item_id, elapsed in signals if item_id == "new"), signals
70
71
72async def test_play_index_sets_elapsed_to_seek_position() -> None:
73 """A seeked start (resume, audiobook resume point) anchors elapsed at the seek."""
74 ctrl, queue, signals = _controller_with_stale_queue()
75
76 await ctrl.play_index(QUEUE_ID, 1, seek_position=30)
77
78 assert queue.elapsed_time == 30
79 assert all(elapsed == 30 for item_id, elapsed in signals if item_id == "new"), signals
80
81
82async def test_seek_publishes_target_before_restarting_stream() -> None:
83 """A seek publishes its target before play_index starts rebuilding the stream."""
84 ctrl, queue, _signals = _controller_with_stale_queue()
85 events: list[tuple[str, float]] = []
86 before = time.time()
87
88 def _record_signal(_queue_id: str, items_changed: bool = False) -> None: # noqa: ARG001
89 events.append(("signal", queue.elapsed_time))
90
91 async def _record_restart(*_args: object, **_kwargs: object) -> None:
92 events.append(("restart", queue.elapsed_time))
93
94 ctrl.signal_update = Mock(side_effect=_record_signal) # type: ignore[method-assign]
95 play_index = AsyncMock(side_effect=_record_restart)
96 ctrl.play_index = play_index # type: ignore[method-assign]
97
98 await ctrl.seek(QUEUE_ID, 30)
99
100 play_index.assert_awaited_once_with(QUEUE_ID, 0, seek_position=30)
101 assert events == [("signal", 30), ("restart", 30)]
102 assert queue.elapsed_time == 30
103 assert queue.elapsed_time_last_updated >= before
104
105
106async def test_play_index_retry_fallback_discards_failed_items_seek_position() -> None:
107 """Falling back to another item after a load failure zeroes elapsed_time, not seek_position."""
108 ctrl, queue, signals = _controller_with_stale_queue()
109 fallback_item = QueueItem(
110 queue_id=QUEUE_ID, queue_item_id="fallback", name="fallback", duration=200
111 )
112 ctrl._queue_data[QUEUE_ID].items.append(fallback_item)
113 ctrl._load_item = AsyncMock(side_effect=[MediaNotFoundError("unplayable"), None]) # type: ignore[method-assign]
114 ctrl._get_next_index = Mock(return_value=2) # type: ignore[method-assign]
115
116 await ctrl.play_index(QUEUE_ID, 1, seek_position=45)
117
118 assert queue.current_item is not None
119 assert queue.current_item.queue_item_id == "fallback"
120 assert queue.elapsed_time == 0
121 assert all(elapsed == 0 for item_id, elapsed in signals if item_id == "fallback"), signals
122
123
124async def test_play_index_stops_and_reports_a_source_capacity_failure() -> None:
125 """Exhausted source capacity stops the queue instead of skipping to another item."""
126 ctrl, _queue, _signals = _controller_with_stale_queue()
127 queue_item = ctrl._queue_data[QUEUE_ID].items[1]
128 provider = MagicMock(spec=MusicProvider)
129 provider.max_concurrent_streams = 1
130 provider.name = "Limited"
131 provider.instance_id = "limited--1"
132 ctrl._load_item = AsyncMock( # type: ignore[method-assign]
133 side_effect=ProviderStreamLimitError(provider, 15)
134 )
135 # another item is available, so a skip would otherwise be attempted
136 ctrl._get_next_index = Mock(return_value=0) # type: ignore[method-assign]
137 ctrl.stop = AsyncMock() # type: ignore[method-assign]
138
139 with pytest.raises(ProviderStreamLimitError):
140 await ctrl.play_index(QUEUE_ID, 1)
141
142 assert queue_item.available
143 assert ctrl._load_item.await_count == 1
144 ctrl.stop.assert_awaited_once_with(QUEUE_ID)
145