music-assistant-server

27.3 KBPY
test_provider.py
27.3 KB646 lines • python
1"""Tests for the Sendspin Source provider."""
2
3from __future__ import annotations
4
5import asyncio
6from typing import TYPE_CHECKING, Any
7
8import pytest
9from aiosendspin.audio import AudioFormat as SendspinAudioFormat
10from aiosendspin.server import (
11    ClientConnectedEvent,
12    SignalState,
13    SourceSignalChangedEvent,
14    SourceStreamStartedEvent,
15)
16from music_assistant_models.enums import MediaType, PlaybackState, QueueOption, StreamType
17from music_assistant_models.errors import AudioError, MediaNotFoundError
18from music_assistant_models.streamdetails import StreamDetails
19
20import music_assistant.providers.sendspin_source.provider as provider_module
21from music_assistant.constants import CONF_ENTRY_WARN_PREVIEW
22from music_assistant.providers.sendspin.constants import CONF_SOURCE_AUTOSTART_TARGET
23from music_assistant.providers.sendspin_source import get_config_entries
24from music_assistant.providers.sendspin_source.constants import (
25    CONF_TARGET_LATENCY,
26    DEFAULT_TARGET_LATENCY_MS,
27)
28from music_assistant.providers.sendspin_source.provider import OUTPUT_FORMAT
29
30from .conftest import (
31    _FakeClient,
32    get_config,
33    get_players,
34    get_queues,
35    get_server_api,
36    make_provider,
37)
38
39if TYPE_CHECKING:
40    from collections.abc import AsyncGenerator
41
42MARKER_BYTE = b"\x7f"
43NATIVE_FORMAT = SendspinAudioFormat(sample_rate=44100, bit_depth=16, channels=2)
44
45
46class _StubBridge:
47    """Bridge stand-in returning marker bytes and recording feeds."""
48
49    occupancy_us = 0
50
51    def __init__(self) -> None:
52        self.fed: list[tuple[bytes, int]] = []
53        self.flush_count = 0
54
55    def feed(self, pcm: bytes, capture_timestamp_us: int) -> None:
56        self.fed.append((pcm, capture_timestamp_us))
57
58    def read(self, frames: int) -> bytes:
59        return MARKER_BYTE * (frames * 4)
60
61    def flush(self) -> None:
62        self.flush_count += 1
63
64
65def _stream_details(item_id: str = "client-1") -> StreamDetails:
66    return StreamDetails(
67        provider="sendspin_source",
68        item_id=item_id,
69        audio_format=OUTPUT_FORMAT,
70        media_type=MediaType.AUDIO_SOURCE,
71        stream_type=StreamType.CUSTOM,
72    )
73
74
75async def test_config_entries_start_with_preview_warning() -> None:
76    """The alpha provider warns users before showing its options."""
77    mass: Any = None
78    entries = await get_config_entries(mass)
79    assert entries[0] is CONF_ENTRY_WARN_PREVIEW
80
81
82async def _take(stream: AsyncGenerator[bytes], count: int) -> list[bytes]:
83    chunks: list[bytes] = []
84    try:
85        async for chunk in stream:
86            chunks.append(chunk)
87            if len(chunks) >= count:
88                break
89    finally:
90        await stream.aclose()
91    return chunks
92
93
94async def _fake_handle(chunks: list[tuple[bytes, int]]) -> Any:
95    for chunk in chunks:
96        yield chunk
97
98
99async def test_audio_sources_follow_role_activation() -> None:
100    """Only connected clients with an active source role are listed."""
101    provider = await make_provider(
102        [
103            _FakeClient("with-role", name="Turntable"),
104            _FakeClient("no-role", name="Speaker", has_source_role=False),
105            _FakeClient("offline", name="Gone", connected=False),
106        ]
107    )
108    sources = await provider.get_audio_sources()
109    assert [s.item_id for s in sources] == ["with-role"]
110    assert sources[0].name == "Turntable"
111    assert sources[0].exclusive is True
112    assert sources[0].can_initiate is True
113
114
115async def test_stream_details_rejects_unknown_source() -> None:
116    """get_stream_details raises for a client that is not a connected source."""
117    provider = await make_provider([_FakeClient("no-role", has_source_role=False)])
118    with pytest.raises(MediaNotFoundError):
119        await provider.get_stream_details("no-role", MediaType.AUDIO_SOURCE)
120    with pytest.raises(MediaNotFoundError):
121        await provider.get_stream_details("unknown", MediaType.AUDIO_SOURCE)
122
123
124async def test_select_requests_start_and_subscribes(fake_client: _FakeClient) -> None:
125    """Selecting a source sends server/command start and attaches a client listener."""
126    provider = await make_provider([fake_client])
127    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
128    assert fake_client.source_role is not None
129    assert fake_client.source_role.start_requests == 1
130    assert len(fake_client.listeners) == 1
131
132
133async def test_unselect_stops_and_releases(fake_client: _FakeClient) -> None:
134    """Unselecting with the live session id sends stop and drops the session."""
135    provider = await make_provider([fake_client])
136    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
137    await provider.on_source_unselected("client-1", "queue-1", "session-1")
138    assert fake_client.source_role is not None
139    assert fake_client.source_role.stop_requests == 1
140    with pytest.raises(AudioError):
141        await _take(provider.get_audio_stream(_stream_details()), 1)
142    assert get_players(provider).stopped == []
143    # The signal watcher outlives the session, so autostart still works afterwards.
144    assert len(fake_client.listeners) == 1
145
146
147async def test_unselect_ignores_stale_session_id(
148    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
149) -> None:
150    """A stale unselect callback must not tear down the live session."""
151    provider = await make_provider([fake_client])
152    await _start_streaming(provider, fake_client, monkeypatch)
153    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-2")
154    await provider.on_source_unselected("client-1", "queue-1", "session-1")
155    assert fake_client.source_role is not None
156    assert fake_client.source_role.stop_requests == 0
157    assert await _take(provider.get_audio_stream(_stream_details()), 1)
158    assert len(fake_client.listeners) == 1
159
160
161async def _start_streaming(
162    provider: Any,
163    client: _FakeClient,
164    monkeypatch: pytest.MonkeyPatch,
165    chunks: list[tuple[bytes, int]] | None = None,
166) -> _StubBridge:
167    """Select the source and let one chunk through, so the stream is past its cold start."""
168    bridge = _StubBridge()
169    monkeypatch.setattr(provider, "_create_bridge", lambda *_args: bridge)
170    await provider.on_source_selected(client.client_id, "player-1", "queue-1", "session-1")
171    handle = _fake_handle(chunks if chunks is not None else [(b"\x01\x02\x03\x04", 1_000_000)])
172    client.emit(SourceStreamStartedEvent(audio_format=NATIVE_FORMAT, handle=handle))
173    await _settle()
174    return bridge
175
176
177async def _latency_passed_to_bridge(
178    provider: Any, client: _FakeClient, monkeypatch: pytest.MonkeyPatch
179) -> list[int]:
180    """Select the source and return the target latencies the bridge was built with."""
181    latencies: list[int] = []
182
183    def _create(_audio_format: Any, target_latency_ms: int) -> _StubBridge:
184        latencies.append(target_latency_ms)
185        return _StubBridge()
186
187    monkeypatch.setattr(provider, "_create_bridge", _create)
188    await provider.on_source_selected(client.client_id, "player-1", "queue-1", "session-1")
189    client.emit(SourceStreamStartedEvent(audio_format=NATIVE_FORMAT, handle=_fake_handle([])))
190    await _settle()
191    return latencies
192
193
194async def test_bridge_falls_back_to_the_default_latency(
195    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
196) -> None:
197    """A provider starts up before its own options are resolved, so the value is unset."""
198    provider = await make_provider([fake_client])
199    assert await _latency_passed_to_bridge(provider, fake_client, monkeypatch) == [
200        DEFAULT_TARGET_LATENCY_MS
201    ]
202
203
204async def test_a_configured_latency_reaches_the_bridge(
205    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
206) -> None:
207    """Once the user sets a latency it must win over the fallback."""
208    provider = await make_provider([fake_client], {CONF_TARGET_LATENCY: 1200})
209    assert await _latency_passed_to_bridge(provider, fake_client, monkeypatch) == [1200]
210
211
212async def test_stream_fails_when_the_source_never_starts(
213    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
214) -> None:
215    """A client that never answers the start command is a failed acquisition, not silence."""
216    monkeypatch.setattr(provider_module, "COLD_START_TIMEOUT_S", 0.01)
217    provider = await make_provider([fake_client])
218    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
219    with pytest.raises(AudioError):
220        await _take(provider.get_audio_stream(_stream_details()), 1)
221
222
223async def test_stream_switches_to_bridge_audio_after_stream_start(
224    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
225) -> None:
226    """A client_stream/start routes decoded chunks through the bridge into the stream."""
227    provider = await make_provider([fake_client])
228    bridge = await _start_streaming(provider, fake_client, monkeypatch)
229
230    chunks = await _take(provider.get_audio_stream(_stream_details()), 5)
231    assert all(chunk == MARKER_BYTE * (48000 * 25 // 1000 * 4) for chunk in chunks)
232    assert bridge.fed == [(b"\x01\x02\x03\x04", 1_000_000)]
233    await provider.on_source_unselected("client-1", "queue-1", "session-1")
234
235
236async def test_source_stream_end_flushes_the_live_bridge(
237    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
238) -> None:
239    """A normally ended source stream emits the resampler tail."""
240    provider = await make_provider([fake_client])
241    bridge = await _start_streaming(provider, fake_client, monkeypatch)
242    assert bridge.flush_count == 1
243
244
245async def test_stream_ends_after_source_timeout(
246    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
247) -> None:
248    """The generator ends on its own once no source audio arrives for the timeout."""
249    provider = await make_provider([fake_client])
250    await _start_streaming(provider, fake_client, monkeypatch)
251    monkeypatch.setattr(provider_module, "SOURCE_TIMEOUT_S", 0.05)
252    chunks = [chunk async for chunk in provider.get_audio_stream(_stream_details())]
253    assert 1 <= len(chunks) <= 10
254
255
256async def test_reconnect_re_requests_start(fake_client: _FakeClient) -> None:
257    """A reconnect clears the client's start request, so the provider sends it again."""
258    provider = await make_provider([fake_client])
259    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
260    assert fake_client.source_role is not None
261    assert fake_client.source_role.start_requests == 1
262    get_server_api(provider).emit(ClientConnectedEvent("client-1"))
263    await asyncio.sleep(0)
264    assert fake_client.source_role.start_requests == 2
265    await provider.on_source_unselected("client-1", "queue-1", "session-1")
266
267
268async def test_cold_reconnect_re_requests_start_once_roles_are_back(
269    fake_client: _FakeClient,
270) -> None:
271    """Roles attach after the connected signal, so the re-request must not run inside it."""
272    provider = await make_provider([fake_client])
273    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
274    role = fake_client.detach_roles()
275    get_server_api(provider).emit(ClientConnectedEvent("client-1"))
276    fake_client.attach_roles(role)
277    await _settle()
278    assert role is not None
279    assert role.start_requests == 2
280
281
282async def test_signal_watcher_arms_before_roles_attach() -> None:
283    """Watching keys off negotiated roles, which are known before the instances attach."""
284    client = _FakeClient("client-1", name="Turntable", connected=False)
285    provider = await make_provider([client])
286    assert client.listeners == []
287    client.is_connected = True
288    client.detach_roles()
289    get_server_api(provider).emit(ClientConnectedEvent("client-1"))
290    await _settle()
291    assert len(client.listeners) == 1
292
293
294async def test_reconnect_leaves_an_open_stream_alone(fake_client: _FakeClient) -> None:
295    """A client that kept its input stream across the event is not asked to start again."""
296    provider = await make_provider([fake_client])
297    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
298    assert fake_client.source_role is not None
299    fake_client.source_role.stream_active = True
300    get_server_api(provider).emit(ClientConnectedEvent("client-1"))
301    await asyncio.sleep(0)
302    assert fake_client.source_role.start_requests == 1
303    await provider.on_source_unselected("client-1", "queue-1", "session-1")
304
305
306async def test_handoff_stops_the_player_it_was_taken_from(fake_client: _FakeClient) -> None:
307    """Moving a source to another player stops the first, which would drain its buffer."""
308    provider = await make_provider([fake_client])
309    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
310    await provider.on_source_selected("client-1", "player-2", "queue-2", "session-2")
311    assert get_players(provider).stopped == ["player-1"]
312    # The client must be stopped too: only a fresh client_stream/start gives the
313    # replacement session a bridge, and the client sends one after a stop/start.
314    assert fake_client.source_role is not None
315    assert fake_client.source_role.stop_requests == 1
316
317
318async def test_reclaim_by_the_same_queue_keeps_the_client_streaming(
319    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
320) -> None:
321    """A renderer opening the stream url twice must not cost a stop/start of the source."""
322    provider = await make_provider([fake_client])
323    await _start_streaming(provider, fake_client, monkeypatch)
324    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-2")
325    assert fake_client.source_role is not None
326    assert fake_client.source_role.stop_requests == 0
327    assert fake_client.source_role.start_requests == 1
328    assert get_players(provider).stopped == []
329    assert await _take(provider.get_audio_stream(_stream_details()), 1)
330
331
332async def test_reclaim_by_the_same_queue_retires_the_previous_generator(
333    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
334) -> None:
335    """Only one generator may read the bridge, or the two requests split the audio."""
336    provider = await make_provider([fake_client])
337    await _start_streaming(provider, fake_client, monkeypatch)
338    stream = provider.get_audio_stream(_stream_details())
339    assert await anext(stream) is not None
340    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-2")
341    assert [chunk async for chunk in stream] == []
342
343
344async def test_reclaim_while_waiting_for_audio_retires_the_previous_generator(
345    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
346) -> None:
347    """A same-queue reclaim retires a generator that is still waiting for first audio."""
348    provider = await make_provider([fake_client])
349    bridge = _StubBridge()
350    monkeypatch.setattr(provider, "_create_bridge", lambda *_args: bridge)
351    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
352    previous = provider.get_audio_stream(_stream_details())
353    previous_chunk = asyncio.create_task(anext(previous))
354    await asyncio.sleep(0)
355
356    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-2")
357    replacement = provider.get_audio_stream(_stream_details())
358    replacement_chunk = asyncio.create_task(anext(replacement))
359    fake_client.emit(
360        SourceStreamStartedEvent(
361            audio_format=NATIVE_FORMAT,
362            handle=_fake_handle([(b"\x01\x02\x03\x04", 1_000_000)]),
363        )
364    )
365    await _settle()
366
367    with pytest.raises(StopAsyncIteration):
368        await previous_chunk
369    assert await replacement_chunk == MARKER_BYTE * (48000 * 25 // 1000 * 4)
370    await replacement.aclose()
371
372
373async def test_reclaim_by_the_same_player_on_another_queue_still_hands_off(
374    fake_client: _FakeClient,
375) -> None:
376    """A different queue is a real re-target, so the previous claim is torn down."""
377    provider = await make_provider([fake_client])
378    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
379    await provider.on_source_selected("client-1", "player-1", "queue-2", "session-2")
380    assert fake_client.source_role is not None
381    assert fake_client.source_role.stop_requests == 1
382    assert get_players(provider).stopped == []
383
384
385async def test_concurrent_handoffs_leave_the_newest_selection_active(
386    fake_client: _FakeClient,
387) -> None:
388    """Concurrent handoffs serialize so an older stop cannot overwrite the latest claim."""
389    provider = await make_provider([fake_client])
390    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
391    players = get_players(provider)
392    players.stop_started = asyncio.Event()
393    players.release_stop = asyncio.Event()
394
395    first = asyncio.create_task(
396        provider.on_source_selected("client-1", "player-2", "queue-2", "session-2")
397    )
398    await players.stop_started.wait()
399    second = asyncio.create_task(
400        provider.on_source_selected("client-1", "player-3", "queue-3", "session-3")
401    )
402    await asyncio.sleep(0)
403    players.release_stop.set()
404    await asyncio.gather(first, second)
405
406    await provider.on_source_unselected("client-1", "queue-3", "session-3")
407    assert players.stopped == ["player-1", "player-2"]
408    assert fake_client.source_role is not None
409    assert fake_client.source_role.stop_requests == 3
410
411
412async def test_two_sources_stream_concurrently(fake_client: _FakeClient) -> None:
413    """Selecting a second source must not stop the first: exclusivity is per source."""
414    other = _FakeClient("client-2", name="Aux")
415    provider = await make_provider([fake_client, other])
416    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
417    await provider.on_source_selected("client-2", "player-2", "queue-2", "session-2")
418    assert fake_client.source_role is not None
419    assert other.source_role is not None
420    assert fake_client.source_role.stop_requests == 0
421    assert other.source_role.start_requests == 1
422    assert get_players(provider).stopped == []
423
424
425async def test_new_selection_supersedes_running_stream(
426    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
427) -> None:
428    """Re-selecting the same source elsewhere makes the previous generator terminate."""
429    provider = await make_provider([fake_client])
430    await _start_streaming(provider, fake_client, monkeypatch)
431    stream = provider.get_audio_stream(_stream_details())
432    assert await anext(stream) is not None
433    await provider.on_source_selected("client-1", "player-2", "queue-2", "session-2")
434    chunks = [chunk async for chunk in stream]
435    assert len(chunks) <= 1
436
437
438def _signal(state: SignalState) -> SourceSignalChangedEvent:
439    return SourceSignalChangedEvent(signal=state)
440
441
442@pytest.fixture
443def fast_autostart(monkeypatch: pytest.MonkeyPatch) -> None:
444    """Collapse the debounce and hold so autostart timing is not a test dependency."""
445    monkeypatch.setattr(provider_module, "AUTOSTART_SIGNAL_DEBOUNCE_S", 0.0)
446    monkeypatch.setattr(provider_module, "AUTOSTART_SIGNAL_ABSENT_HOLD_S", 0.0)
447
448
449async def _settle() -> None:
450    for _ in range(4):
451        await asyncio.sleep(0)
452
453
454@pytest.mark.usefixtures("fast_autostart")
455async def test_signal_returning_autostarts_configured_target(fake_client: _FakeClient) -> None:
456    """A signal appearing after being absent starts the source on the configured player."""
457    provider = await make_provider([fake_client])
458    get_config(provider).values[("client-1", CONF_SOURCE_AUTOSTART_TARGET)] = "client-1"
459    fake_client.emit(_signal(SignalState.ABSENT))
460    fake_client.emit(_signal(SignalState.PRESENT))
461    await _settle()
462    assert get_queues(provider).played == [
463        ("client-1", "sendspin_source://audio_source/client-1", QueueOption.PLAY)
464    ]
465
466
467@pytest.mark.usefixtures("fast_autostart")
468async def test_autostart_uses_the_entry_default_when_nothing_was_saved(
469    fake_client: _FakeClient,
470) -> None:
471    """A device that plays its own line-in must work before the user ever saves the page."""
472    provider = await make_provider([fake_client])
473    get_config(provider).defaults[("client-1", CONF_SOURCE_AUTOSTART_TARGET)] = "client-1"
474    fake_client.emit(_signal(SignalState.ABSENT))
475    fake_client.emit(_signal(SignalState.PRESENT))
476    await _settle()
477    assert get_queues(provider).played == [
478        ("client-1", "sendspin_source://audio_source/client-1", QueueOption.PLAY)
479    ]
480
481
482@pytest.mark.usefixtures("fast_autostart")
483async def test_first_signal_report_never_autostarts(fake_client: _FakeClient) -> None:
484    """A needle already down at startup must not start playing by itself."""
485    provider = await make_provider([fake_client])
486    get_config(provider).values[("client-1", CONF_SOURCE_AUTOSTART_TARGET)] = "client-1"
487    fake_client.emit(_signal(SignalState.PRESENT))
488    await _settle()
489    assert get_queues(provider).played == []
490
491
492async def test_transient_signal_does_not_autostart(
493    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
494) -> None:
495    """A signal that disappears during the debounce never starts playback."""
496    monkeypatch.setattr(provider_module, "AUTOSTART_SIGNAL_DEBOUNCE_S", 60.0)
497    provider = await make_provider([fake_client])
498    get_config(provider).values[("client-1", CONF_SOURCE_AUTOSTART_TARGET)] = "client-1"
499    fake_client.emit(_signal(SignalState.ABSENT))
500    fake_client.emit(_signal(SignalState.PRESENT))
501    fake_client.emit(_signal(SignalState.ABSENT))
502    await _settle()
503    assert get_queues(provider).played == []
504    await provider.unload()
505
506
507async def test_manual_selection_cancels_a_fired_autostart(
508    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
509) -> None:
510    """A manual selection wins while a fired autostart is starting playback."""
511    provider = await make_provider([fake_client])
512    get_config(provider).values[("client-1", CONF_SOURCE_AUTOSTART_TARGET)] = "client-1"
513    queues = get_queues(provider)
514    queues.play_started = asyncio.Event()
515    queues.release_play = asyncio.Event()
516    monkeypatch.setattr(provider_module, "AUTOSTART_SIGNAL_DEBOUNCE_S", 0.0)
517    fake_client.emit(_signal(SignalState.ABSENT))
518    fake_client.emit(_signal(SignalState.PRESENT))
519    await queues.play_started.wait()
520
521    await provider.on_source_selected("client-1", "player-2", "queue-2", "session-1")
522    queues.release_play.set()
523    await _settle()
524    assert queues.played == []
525
526
527@pytest.mark.usefixtures("fast_autostart")
528async def test_delayed_autostart_stream_cannot_override_manual_selection(
529    fake_client: _FakeClient,
530) -> None:
531    """A delayed stream request from a completed autostart cannot reclaim the source."""
532    provider = await make_provider([fake_client])
533    get_config(provider).values[("client-1", CONF_SOURCE_AUTOSTART_TARGET)] = "client-1"
534    fake_client.emit(_signal(SignalState.ABSENT))
535    fake_client.emit(_signal(SignalState.PRESENT))
536    await _settle()
537
538    queues = get_queues(provider)
539    # the handoff suspends while it releases the autostart player, which is where the
540    # delayed request has to arrive for this to be the race it is testing
541    players = get_players(provider)
542    players.stop_started = asyncio.Event()
543    players.release_stop = asyncio.Event()
544    manual = asyncio.create_task(
545        provider.on_source_selected("client-1", "player-2", "queue-2", "session-1")
546    )
547    await players.stop_started.wait()
548    delayed = asyncio.create_task(
549        provider.on_source_selected("client-1", "client-1", "client-1", "session-2")
550    )
551    await asyncio.sleep(0)
552    players.release_stop.set()
553    await manual
554    with pytest.raises(RuntimeError, match="Superseded autostart"):
555        await delayed
556
557    await queues.play_media("client-1", "sendspin_source://audio_source/client-1")
558    await provider.on_source_selected("client-1", "client-1", "client-1", "session-3")
559    assert fake_client.source_role is not None
560    assert fake_client.source_role.start_requests == 2
561
562
563@pytest.mark.usefixtures("fast_autostart")
564async def test_autostart_stays_off_without_a_configured_target(fake_client: _FakeClient) -> None:
565    """With no target configured the signal is observed but never acted on."""
566    provider = await make_provider([fake_client])
567    fake_client.emit(_signal(SignalState.ABSENT))
568    fake_client.emit(_signal(SignalState.PRESENT))
569    await _settle()
570    assert get_queues(provider).played == []
571
572
573@pytest.mark.usefixtures("fast_autostart")
574async def test_autostart_interrupts_a_busy_target(fake_client: _FakeClient) -> None:
575    """A detected signal takes over a target that is already playing."""
576    provider = await make_provider([fake_client])
577    get_config(provider).values[("client-1", CONF_SOURCE_AUTOSTART_TARGET)] = "client-1"
578    get_players(provider).players["client-1"].queue.state = PlaybackState.PLAYING
579    fake_client.emit(_signal(SignalState.ABSENT))
580    fake_client.emit(_signal(SignalState.PRESENT))
581    await _settle()
582    assert get_queues(provider).played == [
583        ("client-1", "sendspin_source://audio_source/client-1", QueueOption.PLAY)
584    ]
585
586
587@pytest.mark.usefixtures("fast_autostart")
588async def test_autostart_skipped_while_the_source_already_streams(fake_client: _FakeClient) -> None:
589    """A source the user already routed somewhere must not be stolen back by a signal."""
590    provider = await make_provider([fake_client])
591    get_config(provider).values[("client-1", CONF_SOURCE_AUTOSTART_TARGET)] = "client-1"
592    await provider.on_source_selected("client-1", "player-2", "queue-2", "session-1")
593    fake_client.emit(_signal(SignalState.ABSENT))
594    fake_client.emit(_signal(SignalState.PRESENT))
595    await _settle()
596    assert get_queues(provider).played == []
597
598
599@pytest.mark.usefixtures("fast_autostart")
600async def test_signal_loss_stops_a_running_source(fake_client: _FakeClient) -> None:
601    """A record reaching its end stops the stream instead of playing silence forever."""
602    provider = await make_provider([fake_client])
603    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
604    fake_client.emit(_signal(SignalState.PRESENT))
605    fake_client.emit(_signal(SignalState.ABSENT))
606    await _settle()
607    # the source is given up on the player that owns it, so the player stops saying
608    # it is playing something that has gone quiet
609    assert get_players(provider).deselected == ["queue-1"]
610
611
612async def test_signal_return_cancels_pending_autostop(
613    fake_client: _FakeClient, monkeypatch: pytest.MonkeyPatch
614) -> None:
615    """A signal returning during the hold keeps the running source playing."""
616    monkeypatch.setattr(provider_module, "AUTOSTART_SIGNAL_ABSENT_HOLD_S", 60.0)
617    provider = await make_provider([fake_client])
618    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
619    fake_client.emit(_signal(SignalState.PRESENT))
620    fake_client.emit(_signal(SignalState.ABSENT))
621    fake_client.emit(_signal(SignalState.PRESENT))
622    await _settle()
623    assert get_queues(provider).stopped == []
624    await provider.unload()
625
626
627@pytest.mark.usefixtures("fast_autostart")
628async def test_a_reconnect_does_not_defuse_a_pending_autostop(fake_client: _FakeClient) -> None:
629    """A same-queue reconnect re-claims the source and must not cancel the pending stop."""
630    provider = await make_provider([fake_client])
631    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-1")
632    fake_client.emit(_signal(SignalState.PRESENT))
633    fake_client.emit(_signal(SignalState.ABSENT))
634    await provider.on_source_selected("client-1", "player-1", "queue-1", "session-2")
635    await _settle()
636    assert get_players(provider).deselected == ["queue-1"]
637
638
639async def test_deferred_reconnect_does_not_rewatch_after_unload(fake_client: _FakeClient) -> None:
640    """A connected callback queued before unload cannot restore its client listener."""
641    provider = await make_provider([fake_client])
642    get_server_api(provider).emit(ClientConnectedEvent("client-1"))
643    await provider.unload()
644    await _settle()
645    assert fake_client.listeners == []
646