/
/
/
1"""
2Unit tests for the Spotify Soloist playback backend.
3
4The backend spawns one engine run per Spotify URI (single-track mode) and
5streams the captured PCM as that item's audio. These tests lock down the pure
6logic around that: lead-silence trimming, the run's cushion and its sink
7backpressure, tail-padding suppression, startup and event handling, delivery
8validation, run acquisition (busy/replaced/superseded), paired-session
9adoption and setup. No real process or PulseAudio is involved.
10"""
11
12from __future__ import annotations
13
14import asyncio
15from collections.abc import AsyncGenerator
16from pathlib import Path
17from typing import Any, cast
18from unittest.mock import AsyncMock, MagicMock, patch
19
20import pytest
21from music_assistant_models.enums import ContentType, MediaType
22from music_assistant_models.errors import AudioError, LoginFailed
23from music_assistant_models.media_items import AudioFormat
24from music_assistant_models.streamdetails import StreamDetails
25
26from music_assistant.models.music_provider import ProviderStreamLimitError
27from music_assistant.providers.spotify.backends import StreamSupersededError
28from music_assistant.providers.spotify.backends import soloist as soloist_backend
29from music_assistant.providers.spotify.backends.soloist import (
30 _BYTES_PER_SECOND,
31 _FRAME_BYTES,
32 _MAX_LEAD_TRIM_S,
33 _TAIL_PAD_GRACE_S,
34 _TAIL_PAD_ZONE_S,
35 SoloistBackend,
36 _SingleTrackRun,
37 _trim_lead_silence,
38)
39from music_assistant.providers.spotify.constants import (
40 CONF_SOLOIST_API_KEY,
41 CONF_SOLOIST_CONSENT,
42)
43from music_assistant.providers.spotify.helpers import soloist_session_present
44from music_assistant.providers.spotify.provider import SpotifyProvider
45from music_assistant.providers.spotify_connect.soloist.runtime import (
46 WS_ADDR_FILE,
47 WS_PORT_FILE,
48)
49
50TRACK_A = "spotify:track:aaa"
51TRACK_B = "spotify:track:bbb"
52# an audiobook is one item whose chapters are separate Spotify URIs
53AUDIOBOOK = "spotify:show:book"
54CHAPTER_A = "spotify:episode:ch1"
55CHAPTER_B = "spotify:episode:ch2"
56CHAPTER_C = "spotify:episode:ch3"
57
58
59def test_trim_drops_an_all_zero_chunk_within_the_bound() -> None:
60 """A pure-silence chunk inside the trim budget is dropped entirely."""
61 chunk = b"\x00" * 1024
62 trimmed, skipped = _trim_lead_silence(chunk, 0)
63 assert trimmed == b""
64 assert skipped == 1024
65
66
67def test_trim_keeps_frame_alignment_when_audio_starts_mid_chunk() -> None:
68 """Audio starting mid-chunk is cut on a sample-frame boundary."""
69 # audio starts one byte into the third frame: the trim must keep that frame whole
70 chunk = b"\x00" * (_FRAME_BYTES * 2 + 1) + b"\x01" * 64
71 trimmed, skipped = _trim_lead_silence(chunk, 0)
72 assert skipped == _FRAME_BYTES * 2
73 assert len(trimmed) % _FRAME_BYTES == 1 # the partial frame's remainder is preserved
74 assert trimmed.endswith(b"\x01" * 64)
75
76
77def test_trim_passes_silence_through_once_the_bound_is_exceeded() -> None:
78 """Beyond the trim budget, silence is genuine content and is delivered."""
79 chunk = b"\x00" * 1024
80 trimmed, skipped = _trim_lead_silence(chunk, int(_MAX_LEAD_TRIM_S * _BYTES_PER_SECOND))
81 assert trimmed == chunk
82 assert skipped == 0
83
84
85def test_the_lead_trim_never_exceeds_its_budget() -> None:
86 """Silence beyond the budget is content, including where audio starts mid-chunk."""
87 budget = int(_MAX_LEAD_TRIM_S * _BYTES_PER_SECOND)
88 # already at the budget, with a chunk whose silence runs well past it
89 chunk = b"\x00" * 4096 + b"\x01" * 64
90 trimmed, skipped = _trim_lead_silence(chunk, budget - _FRAME_BYTES)
91 assert skipped == _FRAME_BYTES
92 assert len(trimmed) == len(chunk) - _FRAME_BYTES
93
94
95async def test_a_superseded_audiobook_stream_stops_instead_of_stitching_on(
96 tmp_path: Path,
97) -> None:
98 """The chapters after a seek belong to the stream that took over, not to this one."""
99 provider = _make_provider(tmp_path)
100 calls: list[str] = []
101
102 async def _cut(uri: str, *_args: Any, **_kwargs: Any) -> AsyncGenerator[bytes]:
103 calls.append(uri)
104 yield b"audio"
105 raise StreamSupersededError("replaced")
106
107 provider.backend = MagicMock(stream_spotify_uri=_cut)
108 streamdetails = MagicMock(
109 media_type=MediaType.AUDIOBOOK,
110 data={"chapters": [CHAPTER_A, CHAPTER_B], "chapters_data": []},
111 )
112 chunks = [chunk async for chunk in provider.get_audio_stream(streamdetails)]
113 assert chunks == [b"audio"]
114 assert calls == [CHAPTER_A]
115
116
117async def test_only_the_chapter_a_stream_starts_on_may_take_the_session(
118 tmp_path: Path,
119) -> None:
120 """The chapter a seek lands on starts the stream; the ones after it continue it."""
121 provider = _make_provider(tmp_path)
122 calls: list[tuple[str, bool]] = []
123
124 async def _stream(
125 uri: str, _seek: int = 0, *, continuation: bool = False, **_kwargs: Any
126 ) -> AsyncGenerator[bytes]:
127 calls.append((uri, continuation))
128 yield b"audio"
129
130 provider.backend = MagicMock(stream_spotify_uri=_stream)
131 streamdetails = MagicMock(
132 media_type=MediaType.AUDIOBOOK,
133 data={
134 "chapters": [CHAPTER_A, CHAPTER_B, CHAPTER_C],
135 "chapters_data": [{"duration_ms": 60_000}] * 3,
136 },
137 )
138 async for _ in provider.get_audio_stream(streamdetails, seek_position=70):
139 pass
140 assert calls == [(CHAPTER_B, False), (CHAPTER_C, True)]
141
142
143async def test_a_superseded_track_stream_ends_without_an_error(tmp_path: Path) -> None:
144 """A replaced stream is no failure: the item plays on the stream that took over."""
145 provider = _make_provider(tmp_path)
146
147 async def _cut(_uri: str, *_args: Any, **_kwargs: Any) -> AsyncGenerator[bytes]:
148 yield b"audio"
149 raise StreamSupersededError("replaced")
150
151 provider.backend = MagicMock(stream_spotify_uri=_cut)
152 streamdetails = MagicMock(media_type=MediaType.TRACK, item_id="aaa", data=None)
153 chunks = [chunk async for chunk in provider.get_audio_stream(streamdetails)]
154 assert chunks == [b"audio"]
155
156
157async def test_an_audiobook_gives_up_on_capacity_instead_of_burning_chapters(
158 tmp_path: Path,
159) -> None:
160 """Skipping ahead would cost the audiobook its availability and the caller its retry."""
161 provider = _make_provider(tmp_path)
162 calls: list[str] = []
163
164 async def _refuse(uri: str, *_args: Any, **_kwargs: Any) -> AsyncGenerator[bytes]:
165 calls.append(uri)
166 for _ in (): # never yields; only makes this an async generator
167 yield b""
168 raise soloist_backend.SoloistSessionBusyError(provider)
169
170 provider.backend = MagicMock(stream_spotify_uri=_refuse)
171 streamdetails = MagicMock(
172 media_type=MediaType.AUDIOBOOK,
173 data={"chapters": [TRACK_A, TRACK_B, "spotify:track:ccc"], "chapters_data": []},
174 )
175
176 with pytest.raises(ProviderStreamLimitError):
177 async for _ in provider.get_audio_stream(streamdetails):
178 pass
179 # the first chapter's refusal ends it: no chapter is skipped over
180 assert calls == [TRACK_A]
181
182
183def test_the_shaper_only_emits_whole_frames() -> None:
184 """A read that ends mid-frame must never split a frame across two items."""
185 shaper = soloist_backend._CaptureShaper()
186 # the session's first bytes are infrastructure silence, and are dropped
187 assert shaper.shape(b"\x00" * 4096) == b""
188 # a mis-aligned read emits whole frames and carries the remainder
189 first = shaper.shape(b"\x01" * (_FRAME_BYTES + 3))
190 assert len(first) == _FRAME_BYTES
191 # which is then completed by the next read, losing nothing
192 second = shaper.shape(b"\x02" * (_FRAME_BYTES - 3))
193 assert len(second) == _FRAME_BYTES
194 assert second[:3] == b"\x01" * 3
195 # an aligned read passes straight through
196 assert shaper.shape(b"\x03" * _FRAME_BYTES) == b"\x03" * _FRAME_BYTES
197
198
199def test_the_shaper_trims_lead_silence_only_once() -> None:
200 """Silence after the audio has started is content, not pre-roll."""
201 shaper = soloist_backend._CaptureShaper()
202 assert shaper.shape(b"\x01" * _FRAME_BYTES) == b"\x01" * _FRAME_BYTES
203 silence = b"\x00" * _FRAME_BYTES
204 assert shaper.shape(silence) == silence
205
206
207def test_the_engine_is_told_not_to_normalize(tmp_path: Path) -> None:
208 """MA normalizes this audio itself, so the engine's own normalization is switched off."""
209 backend = _make_backend(tmp_path)
210 prefs = backend._data_dir / "settings" / "Users" / "alice-user" / "prefs"
211 prefs.parent.mkdir(parents=True)
212 prefs.write_text("some.engine.key=1\n", encoding="utf-8")
213 backend._prepare_data_dir(normalize=False)
214 content = prefs.read_text(encoding="utf-8").splitlines()
215 assert "some.engine.key=1" in content
216 assert "audio.normalize_v2=false" in content
217 # MA mixes the queue's crossfade itself, so the engine's own is always off
218 assert "audio.crossfade_v2=false" in content
219 # the ceiling is stated rather than left to the engine's own default
220 assert "audio.play_bitrate_enumeration=5" in content
221 assert "audio.play_bitrate_non_metered_enumeration=5" in content
222 assert "audio.play_bitrate_non_metered_migrated=true" in content
223
224
225def test_disabling_crossfade_writes_the_boolean(tmp_path: Path) -> None:
226 """Crossfade off is written explicitly, so a stale 'on' cannot survive."""
227 backend = _make_backend(tmp_path)
228 prefs = backend._data_dir / "settings" / "prefs"
229 prefs.parent.mkdir(parents=True)
230 prefs.write_text("audio.crossfade_v2=true\naudio.crossfade.time_v2=8000\n", encoding="utf-8")
231 backend._prepare_data_dir(normalize=False)
232 content = prefs.read_text(encoding="utf-8").splitlines()
233 assert "audio.crossfade_v2=false" in content
234 assert not any(line.startswith("audio.crossfade.time_v2") for line in content)
235
236
237async def test_setup_requires_an_api_key(tmp_path: Path) -> None:
238 """Without a stored API key the user must be sent back through the setup flow."""
239 backend = _make_backend(tmp_path)
240 with pytest.raises(LoginFailed) as err:
241 await backend.setup()
242 assert err.value.translation_key == "soloist_pairing_required"
243
244
245async def test_setup_requires_a_paired_session(
246 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
247) -> None:
248 """An API key without a paired session also routes back to the setup flow."""
249 backend = _make_backend(tmp_path, {CONF_SOLOIST_API_KEY: "k" * 20, CONF_SOLOIST_CONSENT: True})
250 _install_fake_binary_manager(monkeypatch)
251 with pytest.raises(LoginFailed) as err:
252 await backend.setup()
253 assert err.value.translation_key == "soloist_pairing_required"
254
255
256async def test_streaming_without_setup_is_refused(tmp_path: Path) -> None:
257 """A backend whose setup never ran refuses to stream instead of half-starting."""
258 backend = _make_backend(tmp_path)
259 with pytest.raises(AudioError, match="not started"):
260 async for _ in backend.stream_spotify_uri(TRACK_A):
261 pass
262
263
264def test_session_present_detection(tmp_path: Path) -> None:
265 """Only the engine's per-account state counts as paired."""
266 data_dir = tmp_path / "soloist-data"
267 assert soloist_session_present(data_dir) is False
268 data_dir.mkdir()
269 (data_dir / WS_ADDR_FILE).write_text("127.0.0.1", encoding="utf-8")
270 (data_dir / WS_PORT_FILE).write_text("1234", encoding="utf-8")
271 assert soloist_session_present(data_dir) is False
272 # everything a spawn leaves behind outlives the pairing it ran on: the engine
273 # keeps its identity, lock, cache and crash handler in the data dir even
274 # though it is given a cache dir of its own, and Music Assistant writes the
275 # prefs there before every spawn
276 (data_dir / "settings").mkdir()
277 (data_dir / "settings" / "prefs").write_text("audio.normalize_v2=false\n", encoding="utf-8")
278 (data_dir / ".device_id").write_text("6b6c2a07", encoding="utf-8")
279 (data_dir / ".lock").write_bytes(b"")
280 (data_dir / "cache" / "Users" / "spotify-user-user").mkdir(parents=True)
281 (data_dir / "crashpad").mkdir()
282 assert soloist_session_present(data_dir) is False
283 (data_dir / "settings" / "Users" / "spotify-user-user").mkdir(parents=True)
284 assert soloist_session_present(data_dir) is True
285
286
287def _make_provider(tmp_path: Path, setup_data: dict[str, Any] | None = None) -> SpotifyProvider:
288 """Return a SpotifyProvider (bypassing __init__) with the given setup_data."""
289 prov = object.__new__(SpotifyProvider)
290 config = MagicMock(instance_id="spotify--test")
291 config.get_value = MagicMock(return_value=None)
292 config.values = {}
293 prov.config = config
294 prov.manifest = MagicMock(domain="spotify")
295 prov.logger = MagicMock()
296 prov.available = True
297 mass = MagicMock()
298 mass.storage_path = str(tmp_path / "storage")
299 mass.cache_path = str(tmp_path / "cache")
300 # get_setup_value reads the live setup_data blob from the store
301 mass.config.get = MagicMock(return_value=setup_data or {})
302 mass.config.get_raw_provider_config_value = MagicMock(return_value=None)
303 # the store keeps values encrypted; decrypt is an identity map for the test
304 mass.config.decrypt_string = MagicMock(side_effect=lambda value: value)
305 prov.mass = mass
306 return prov
307
308
309def _make_backend(tmp_path: Path, setup_data: dict[str, Any] | None = None) -> SoloistBackend:
310 """Return a SoloistBackend on a mocked provider."""
311 return SoloistBackend(_make_provider(tmp_path, setup_data))
312
313
314def _streamdetails_for(
315 *,
316 queue_id: str | None = "player1",
317 uri: str = TRACK_A,
318 media_type: MediaType = MediaType.TRACK,
319) -> StreamDetails:
320 """Return stream details for a Spotify item served by the test instance."""
321 return StreamDetails(
322 provider="spotify--test",
323 item_id=uri.rsplit(":", 1)[1],
324 audio_format=AudioFormat(content_type=ContentType.PCM_S16LE),
325 media_type=media_type,
326 queue_id=queue_id,
327 )
328
329
330def _install_fake_binary_manager(monkeypatch: pytest.MonkeyPatch) -> None:
331 """Replace the shared binary manager so no download or exec is attempted."""
332 manager = MagicMock()
333 manager.ensure_fresh = AsyncMock(return_value=Path("/nonexistent/soloist"))
334 monkeypatch.setattr(soloist_backend, "SoloistBinaryManager", MagicMock(return_value=manager))
335
336
337def _make_run(
338 tmp_path: Path,
339 uri: str = TRACK_A,
340 seek_ms: int = 0,
341 duration: int | None = 180,
342 media_key: str | None = None,
343) -> _SingleTrackRun:
344 """Return a run with its process/sink/client replaced by mocks."""
345 streamdetails = _streamdetails_for(uri=uri)
346 if duration is not None:
347 streamdetails.duration = duration
348 run = _SingleTrackRun(_make_backend(tmp_path), uri, seek_ms, streamdetails)
349 if media_key is not None:
350 run.media_key = media_key
351 run._sink = AsyncMock()
352 run._client = AsyncMock()
353 run._proc = MagicMock(returncode=None)
354 run._logged_in = True
355 run._sink_running = True
356 return run
357
358
359async def _collect(run: _SingleTrackRun) -> bytes:
360 """Return everything the run streams, up to where it ended the item."""
361 collected = bytearray()
362 async for chunk in run.stream():
363 collected.extend(chunk)
364 return bytes(collected)
365
366
367def test_scrub_leaves_mid_track_silence_alone(tmp_path: Path) -> None:
368 """A quiet passage outside the tail zone is content (the shaper owns the lead)."""
369 run = _make_run(tmp_path)
370 run._read_bytes = 10 * _BYTES_PER_SECOND
371 assert run._scrub(b"\x00" * 1024) == b"\x00" * 1024
372 assert run._scrub(b"\x01" * 64) == b"\x01" * 64
373
374
375def test_scrub_refuses_padding_in_the_items_tail_zone(tmp_path: Path) -> None:
376 """Zeros inside the tail zone are the sink idling out the engine's end."""
377 run = _make_run(tmp_path, duration=60)
378 second = _BYTES_PER_SECOND
379 run._read_bytes = 55 * second
380 grace = int(_TAIL_PAD_GRACE_S * second)
381 # the first moment of padding is kept, the rest refused
382 assert run._scrub(b"\x00" * grace) == b"\x00" * grace
383 run._tail_zeros = grace
384 assert run._scrub(b"\x00" * second) == b""
385 # real audio resets the run: the zeros were a quiet passage after all
386 assert run._scrub(b"\x01" * 64) == b"\x01" * 64
387 assert run._tail_zeros == 0
388
389
390def test_scrub_leaves_a_short_items_silence_alone(tmp_path: Path) -> None:
391 """An item no longer than the zone has no distinguishable tail."""
392 run = _make_run(tmp_path, duration=int(_TAIL_PAD_ZONE_S))
393 run._read_bytes = int(_TAIL_PAD_ZONE_S - 1) * _BYTES_PER_SECOND
394 chunk = b"\x00" * (2 * int(_TAIL_PAD_GRACE_S * _BYTES_PER_SECOND))
395 assert run._scrub(chunk) == chunk
396
397
398async def test_a_full_cushion_pauses_the_engine(tmp_path: Path) -> None:
399 """When the consumer stops taking audio, the sink is suspended, not overflowed."""
400 run = _make_run(tmp_path)
401 sink = cast("AsyncMock", run._sink)
402 while not run._chunks.full():
403 run._chunks.put_nowait(b"\x01")
404
405 blocked = asyncio.ensure_future(run._hand_over(b"\x02"))
406 await asyncio.sleep(0.01)
407 assert not blocked.done()
408 sink.suspend.assert_awaited_once()
409
410 # the consumer takes a chunk: the write lands and the engine resumes
411 run._engine_playing = True
412 assert run._chunks.get_nowait() == b"\x01"
413 assert await blocked is True
414 sink.resume.assert_awaited()
415
416
417async def test_a_full_cushion_still_ends_the_stream(tmp_path: Path) -> None:
418 """The end of delivery survives a cushion with no room left for the sentinel."""
419 run = _make_run(tmp_path, duration=1)
420 while not run._chunks.full():
421 run._chunks.put_nowait(b"\x01" * 64)
422 run._finish_delivery()
423 # nothing may re-signal the end once the consumer drains: the flag carries it
424 assert len(await _collect(run)) == run._chunks.maxsize * 64
425
426
427async def test_a_failed_run_surfaces_its_error_to_the_stream(tmp_path: Path) -> None:
428 """The consumer sees the run's real failure, not a clean end."""
429 run = _make_run(tmp_path)
430 run._chunks.put_nowait(b"\x01" * 64)
431 run._fail("the engine broke")
432 with pytest.raises(AudioError, match="the engine broke"):
433 await _collect(run)
434
435
436async def test_short_delivery_is_rejected_as_incomplete(tmp_path: Path) -> None:
437 """An engine that refuses an item must not read as a completed stream."""
438 run = _make_run(tmp_path, duration=152)
439 run._chunks.put_nowait(b"\x01" * _FRAME_BYTES)
440 run._finish_delivery()
441 with pytest.raises(AudioError, match="incomplete"):
442 await _collect(run)
443
444
445async def test_a_stopped_run_is_not_judged_incomplete(tmp_path: Path) -> None:
446 """A consumer that left early is the normal end of an aborted stream."""
447 run = _make_run(tmp_path, duration=152)
448 run._chunks.put_nowait(b"\x01" * _FRAME_BYTES)
449 run._stopped = True
450 run._finish_delivery()
451 assert await _collect(run) == b"\x01" * _FRAME_BYTES
452
453
454async def test_a_seek_counts_towards_the_delivery(tmp_path: Path) -> None:
455 """Audio skipped by the seek is not audio the engine failed to deliver."""
456 run = _make_run(tmp_path, duration=60, seek_ms=55_000)
457 run._chunks.put_nowait(b"\x01" * (6 * _BYTES_PER_SECOND))
458 run._finish_delivery()
459 await _collect(run)
460
461
462def test_the_own_item_report_starts_the_run_and_refines_the_duration(tmp_path: Path) -> None:
463 """The engine reaching the item is what playback start means."""
464 run = _make_run(tmp_path, duration=180)
465 run._observe_item(TRACK_A, 179_000)
466 assert run._started.is_set()
467 assert run._duration_ms == 179_000
468
469
470def test_a_seek_is_confirmed_only_near_its_target(tmp_path: Path) -> None:
471 """A pre-seek position report cannot confirm the seek."""
472 run = _make_run(tmp_path, seek_ms=60_000)
473 run._observe_position(0)
474 assert not run._seek_confirmed.is_set()
475 run._observe_position(58_000)
476 assert run._seek_confirmed.is_set()
477
478
479def test_single_track_args_carry_the_uri(tmp_path: Path) -> None:
480 """The engine is spawned on exactly one URI, in single-track mode."""
481 backend = _make_backend(tmp_path, {CONF_SOLOIST_API_KEY: "k" * 20})
482 backend._binary = tmp_path / "soloist-bin"
483 args = backend._session_args(TRACK_A)
484 assert "--single-track" in args
485 assert args[args.index("--single-track") + 1] == TRACK_A
486 # the binary refuses to start without a device name, even though
487 # single-track mode never advertises one
488 assert "--device-name" in args
489
490
491async def test_a_run_for_another_item_reports_capacity(tmp_path: Path) -> None:
492 """A live run is one stream slot: anything else waits or resolves elsewhere."""
493 backend = _make_backend(tmp_path)
494 backend._run = _make_run(tmp_path, uri=TRACK_A, media_key=TRACK_A)
495 with pytest.raises(ProviderStreamLimitError):
496 await backend._acquire_run(TRACK_B, 0, _streamdetails_for(uri=TRACK_B), continuation=False)
497
498
499async def test_a_replaced_streams_continuation_is_superseded(tmp_path: Path) -> None:
500 """A continuation must not take the run back from the stream that replaced it."""
501 backend = _make_backend(tmp_path)
502 streamdetails = _streamdetails_for(uri=AUDIOBOOK, media_type=MediaType.AUDIOBOOK)
503 run = _make_run(tmp_path, uri=CHAPTER_B, media_key=streamdetails.uri)
504 backend._run = run
505 with pytest.raises(StreamSupersededError):
506 await backend._acquire_run(CHAPTER_A, 0, streamdetails, continuation=True)
507
508
509def test_session_normalizes_answers_only_for_the_items_own_run(tmp_path: Path) -> None:
510 """Another item's run says nothing about this one."""
511 backend = _make_backend(tmp_path)
512 run = _make_run(tmp_path, uri=TRACK_A, media_key=_streamdetails_for(uri=TRACK_A).uri)
513 run.engine_normalizes = True
514 backend._run = run
515 assert backend.session_normalizes(_streamdetails_for(uri=TRACK_A)) is True
516 assert backend.session_normalizes(_streamdetails_for(uri=TRACK_B)) is None
517
518
519async def test_the_engine_wandering_on_after_delivery_ends_the_run_cleanly(
520 tmp_path: Path,
521) -> None:
522 """Autoplay reaching the next track right before exit is this item's natural end."""
523 run = _make_run(tmp_path, duration=60)
524 run._observe_item(TRACK_A, 60_000)
525 run.mass.create_task = MagicMock() # type: ignore[method-assign]
526 run._chunks.put_nowait(b"\x01" * (60 * _BYTES_PER_SECOND))
527 run._observe_item(TRACK_B, 100_000)
528 assert run._error is None
529 assert run._item_over is True
530 assert await _collect(run) == b"\x01" * (60 * _BYTES_PER_SECOND)
531 run.mass.create_task.assert_called_once()
532
533
534def test_the_engine_starting_on_the_wrong_item_fails_the_run(tmp_path: Path) -> None:
535 """Before this run's item ever played, a foreign report is not an ending."""
536 run = _make_run(tmp_path)
537 run.mass.create_task = MagicMock() # type: ignore[method-assign]
538 run._observe_item(TRACK_B, 100_000)
539 assert run._error is not None
540
541
542async def test_a_seek_replaces_the_held_run(tmp_path: Path) -> None:
543 """A positive seek restarts the item's run; only a prefetch must never steal it."""
544 backend = _make_backend(tmp_path, {CONF_SOLOIST_API_KEY: "k" * 20})
545 backend._server = MagicMock()
546 backend._binary = tmp_path / "soloist-bin"
547 held = _make_run(tmp_path, uri=TRACK_A, media_key=_streamdetails_for(uri=TRACK_A).uri)
548 held.stop = AsyncMock() # type: ignore[method-assign]
549 backend._run = held
550
551 with (
552 patch(
553 "music_assistant.providers.spotify.backends.soloist.SoloistBinaryManager.ensure_fresh",
554 AsyncMock(),
555 ),
556 patch.object(soloist_backend._SingleTrackRun, "start", AsyncMock()),
557 ):
558 run = await backend._acquire_run(
559 TRACK_A, 30, _streamdetails_for(uri=TRACK_A), continuation=False
560 )
561 held.stop.assert_awaited_once()
562 assert run is not held
563 assert backend._run is run
564