/
/
/
1"""Tests for audiobook progress sync via play_audio in on_played/on_streamed."""
2
3from __future__ import annotations
4
5import asyncio
6from unittest.mock import AsyncMock, Mock
7
8import pytest
9from music_assistant_models.enums import MediaType
10from music_assistant_models.errors import ResourceTemporarilyUnavailable
11from music_assistant_models.streamdetails import StreamDetails
12
13from music_assistant.providers.yandex_music.provider import YandexMusicProvider
14
15
16@pytest.fixture
17def provider_mock() -> Mock:
18 """Return a provider mock wired for audiobook progress reporting."""
19 provider = Mock(spec=YandexMusicProvider)
20 provider.domain = "yandex_music"
21 provider.instance_id = "yandex_music_instance"
22 provider.logger = Mock()
23 provider.client = AsyncMock()
24 provider.client.play_audio = AsyncMock(return_value=True)
25 provider._audiobook_chapter_cache = {}
26 provider._audiobook_play_ids = {}
27 # real method so we don't have to replicate seek math in tests
28 provider._resolve_audiobook_seek = YandexMusicProvider._resolve_audiobook_seek.__get__(
29 provider, YandexMusicProvider
30 )
31 provider._audiobook_progress_point = YandexMusicProvider._audiobook_progress_point.__get__(
32 provider, YandexMusicProvider
33 )
34 provider._resolve_audiobook_chapter_map = AsyncMock()
35 return provider
36
37
38@pytest.mark.asyncio
39async def test_on_played_audiobook_reports_progress(provider_mock: Mock) -> None:
40 """on_played(AUDIOBOOK) resolves chapter from cache and calls play_audio."""
41 chapter_ids = ["c1", "c2", "c3"]
42 chapter_durations_ms = [60_000, 120_000, 90_000]
43 provider_mock._resolve_audiobook_chapter_map.return_value = (
44 chapter_ids,
45 chapter_durations_ms,
46 )
47
48 # position = 60 (end of c1) + 30 â middle of c2
49 await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-42", 90)
50
51 provider_mock.client.play_audio.assert_awaited_once()
52 kwargs = provider_mock.client.play_audio.await_args.kwargs
53 assert kwargs["track_id"] == "c2"
54 assert kwargs["album_id"] == "abook-42"
55 assert kwargs["track_length_seconds"] == 120
56 assert kwargs["total_played_seconds"] == 30
57 assert kwargs["end_position_seconds"] == 30
58 # play_id created and persisted for the session
59 assert provider_mock._audiobook_play_ids["abook-42"] == kwargs["play_id"]
60
61
62@pytest.mark.asyncio
63async def test_on_played_audiobook_skips_when_no_chapter_map(provider_mock: Mock) -> None:
64 """No chapter map â skip play_audio (log at debug)."""
65 provider_mock._resolve_audiobook_chapter_map.return_value = ([], [])
66
67 await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-42", 90)
68
69 provider_mock.client.play_audio.assert_not_awaited()
70
71
72@pytest.mark.asyncio
73async def test_on_played_audiobook_reuses_play_id(provider_mock: Mock) -> None:
74 """Successive on_played calls for the same audiobook share one play_id."""
75 provider_mock._resolve_audiobook_chapter_map.return_value = (["c1"], [60_000])
76
77 await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-1", 10)
78 await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-1", 20)
79
80 calls = provider_mock.client.play_audio.await_args_list
81 assert calls[0].kwargs["play_id"] == calls[1].kwargs["play_id"]
82
83
84@pytest.mark.asyncio
85async def test_on_streamed_audiobook_sends_final_position(provider_mock: Mock) -> None:
86 """on_streamed uses seek_position + seconds_streamed as absolute position."""
87 sd = StreamDetails(
88 provider="yandex_music",
89 item_id="abook-7",
90 audio_format=Mock(),
91 media_type=MediaType.AUDIOBOOK,
92 data={
93 "chapter_ids": ["c1", "c2", "c3"],
94 "chapter_durations_ms": [60_000, 120_000, 600_000],
95 },
96 )
97 sd.seek_position = 300 # 5 minutes in â inside c3 (starts at 180s, 10min long)
98 sd.seconds_streamed = 15.0 # stopped at 315s total
99 # pre-stash a play_id so we can assert it pops
100 provider_mock._audiobook_play_ids["abook-7"] = "stable-id"
101
102 await YandexMusicProvider._report_audiobook_final(provider_mock, sd, sd.data)
103
104 provider_mock.client.play_audio.assert_awaited_once()
105 kwargs = provider_mock.client.play_audio.await_args.kwargs
106 assert kwargs["track_id"] == "c3"
107 assert kwargs["album_id"] == "abook-7"
108 # absolute 315s â chapter 3 (starts at 180s) offset 135s
109 assert kwargs["total_played_seconds"] == 135
110 assert kwargs["end_position_seconds"] == 135
111 assert kwargs["play_id"] == "stable-id"
112 # session play_id cleared after final report
113 assert "abook-7" not in provider_mock._audiobook_play_ids
114
115
116@pytest.mark.asyncio
117async def test_on_streamed_audiobook_ignores_empty_data(provider_mock: Mock) -> None:
118 """Missing chapter_ids in data â no play_audio call."""
119 sd = StreamDetails(
120 provider="yandex_music",
121 item_id="abook-9",
122 audio_format=Mock(),
123 media_type=MediaType.AUDIOBOOK,
124 data={},
125 )
126
127 await YandexMusicProvider._report_audiobook_final(provider_mock, sd, sd.data)
128
129 provider_mock.client.play_audio.assert_not_awaited()
130
131
132@pytest.mark.asyncio
133async def test_on_played_audiobook_swallows_upstream_unavailable(
134 provider_mock: Mock,
135) -> None:
136 """ResourceTemporarilyUnavailable from chapter_map resolution must not propagate."""
137 provider_mock._resolve_audiobook_chapter_map.side_effect = ResourceTemporarilyUnavailable(
138 "rate limited"
139 )
140
141 # Must not raise; progress report is advisory.
142 await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-42", 30)
143
144 provider_mock.client.play_audio.assert_not_awaited()
145
146
147@pytest.mark.asyncio
148async def test_on_played_audiobook_swallows_unexpected_exception(
149 provider_mock: Mock,
150) -> None:
151 """Any non-cancellation exception while resolving the chapter map is swallowed."""
152
153 class SomeAuthError(Exception):
154 pass
155
156 provider_mock._resolve_audiobook_chapter_map.side_effect = SomeAuthError("token expired")
157
158 await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-42", 30)
159
160 provider_mock.client.play_audio.assert_not_awaited()
161
162
163@pytest.mark.asyncio
164async def test_on_played_audiobook_propagates_cancellation(
165 provider_mock: Mock,
166) -> None:
167 """asyncio.CancelledError must propagate â never suppressed."""
168 provider_mock._resolve_audiobook_chapter_map.side_effect = asyncio.CancelledError()
169
170 with pytest.raises(asyncio.CancelledError):
171 await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-42", 30)
172
173
174@pytest.mark.asyncio
175async def test_on_streamed_audiobook_evicts_cache_entry(provider_mock: Mock) -> None:
176 """After on_streamed, the chapter-map cache entry for this book is dropped."""
177 sd = StreamDetails(
178 provider="yandex_music",
179 item_id="abook-7",
180 audio_format=Mock(),
181 media_type=MediaType.AUDIOBOOK,
182 data={"chapter_ids": ["c1"], "chapter_durations_ms": [60_000]},
183 )
184 provider_mock._audiobook_chapter_cache["abook-7"] = (["c1"], [60_000])
185 provider_mock._audiobook_chapter_cache["abook-OTHER"] = (["x"], [1000])
186
187 await YandexMusicProvider._report_audiobook_final(provider_mock, sd, sd.data)
188
189 assert "abook-7" not in provider_mock._audiobook_chapter_cache
190 # Other audiobooks in cache are untouched
191 assert "abook-OTHER" in provider_mock._audiobook_chapter_cache
192
193
194@pytest.mark.asyncio
195async def test_on_streamed_audiobook_reports_end_of_last_chapter_at_eof(
196 provider_mock: Mock,
197) -> None:
198 """Natural EOF must report end_position_seconds = last chapter's length, not 0."""
199 sd = StreamDetails(
200 provider="yandex_music",
201 item_id="abook-eof",
202 audio_format=Mock(),
203 media_type=MediaType.AUDIOBOOK,
204 data={
205 "chapter_ids": ["c1", "c2"],
206 "chapter_durations_ms": [60_000, 120_000],
207 },
208 )
209 # Total duration = 180s. Reached exactly the end.
210 sd.seek_position = 0
211 sd.seconds_streamed = 180.0
212
213 await YandexMusicProvider._report_audiobook_final(provider_mock, sd, sd.data)
214
215 provider_mock.client.play_audio.assert_awaited_once()
216 kwargs = provider_mock.client.play_audio.await_args.kwargs
217 assert kwargs["track_id"] == "c2"
218 assert kwargs["track_length_seconds"] == 120
219 assert kwargs["end_position_seconds"] == 120
220 assert kwargs["total_played_seconds"] == 120
221
222
223@pytest.mark.asyncio
224async def test_on_streamed_audiobook_clamps_zero_duration_chapter(
225 provider_mock: Mock,
226) -> None:
227 """Missing duration_ms (coerced to 0) must never send track_length_seconds=0."""
228 sd = StreamDetails(
229 provider="yandex_music",
230 item_id="abook-bad",
231 audio_format=Mock(),
232 media_type=MediaType.AUDIOBOOK,
233 data={"chapter_ids": ["c1"], "chapter_durations_ms": [0]},
234 )
235 sd.seek_position = 0
236 sd.seconds_streamed = 0.0
237
238 await YandexMusicProvider._report_audiobook_final(provider_mock, sd, sd.data)
239
240 kwargs = provider_mock.client.play_audio.await_args.kwargs
241 assert kwargs["track_length_seconds"] >= 1
242 assert 0 <= kwargs["end_position_seconds"] <= kwargs["track_length_seconds"]
243
244
245@pytest.mark.asyncio
246async def test_on_streamed_audiobook_without_data_still_cleans_up(
247 provider_mock: Mock,
248) -> None:
249 """StreamDetails.data missing or stripped â caches still evicted, no play_audio call."""
250 sd = StreamDetails(
251 provider="yandex_music",
252 item_id="abook-x",
253 audio_format=Mock(),
254 media_type=MediaType.AUDIOBOOK,
255 )
256 provider_mock._audiobook_chapter_cache["abook-x"] = (["c"], [1000])
257 provider_mock._audiobook_play_ids["abook-x"] = "sess-id"
258
259 await YandexMusicProvider._report_audiobook_final(provider_mock, sd, {})
260
261 assert "abook-x" not in provider_mock._audiobook_chapter_cache
262 assert "abook-x" not in provider_mock._audiobook_play_ids
263 provider_mock.client.play_audio.assert_not_awaited()
264
265
266@pytest.mark.asyncio
267async def test_on_streamed_audiobook_branch_taken_even_without_chapter_data(
268 provider_mock: Mock,
269) -> None:
270 """
271 AUDIOBOOK stream without chapter_ids still routes to audiobook cleanup.
272
273 Previously the gate required ``"chapter_ids" in data`` and fell through
274 to the radio path when data was missing, leaving caches stale.
275 """
276 provider_mock._report_audiobook_final = AsyncMock()
277 provider_mock.client.send_rotor_station_feedback = AsyncMock()
278 sd = StreamDetails(
279 provider="yandex_music",
280 item_id="abook-y",
281 audio_format=Mock(),
282 media_type=MediaType.AUDIOBOOK,
283 # data=None (not a dict) â previously would fall through to radio
284 )
285
286 await YandexMusicProvider.on_streamed(provider_mock, sd)
287
288 provider_mock._report_audiobook_final.assert_awaited_once()
289 provider_mock.client.send_rotor_station_feedback.assert_not_awaited()
290
291
292@pytest.mark.asyncio
293async def test_on_played_routes_audiobook_and_skips_radio_branch(
294 provider_mock: Mock,
295) -> None:
296 """on_played with MediaType.AUDIOBOOK early-returns before radio feedback."""
297 provider_mock._resolve_audiobook_chapter_map.return_value = (["c1"], [60_000])
298 provider_mock._report_audiobook_progress = AsyncMock()
299 provider_mock.client.send_rotor_station_feedback = AsyncMock()
300
301 await YandexMusicProvider.on_played(
302 provider_mock,
303 MediaType.AUDIOBOOK,
304 "abook-1",
305 fully_played=False,
306 position=30,
307 media_item=Mock(),
308 is_playing=True,
309 )
310
311 provider_mock._report_audiobook_progress.assert_awaited_once_with("abook-1", 30)
312 provider_mock.client.send_rotor_station_feedback.assert_not_awaited()
313