/
/
1"""Backend contract for the Spotify Connect provider."""
2
3from __future__ import annotations
4
5from abc import ABC, abstractmethod
6from typing import TYPE_CHECKING
7
8if TYPE_CHECKING:
9 from music_assistant_models.enums import RepeatMode
10 from music_assistant_models.media_items import AudioFormat
11
12 from music_assistant.providers.spotify_connect.models import (
13 AudioChunkReader,
14 BackendStreamSource,
15 )
16
17
18class SpotifyConnectBackend(ABC):
19 """
20 Contract between the SpotifyConnectProvider and a Spotify Connect implementation.
21
22 A backend owns everything specific to one way of talking to Spotify
23 (daemon lifecycle, credentials, wire protocol, audio delivery) and reports
24 state changes as normalized ``BackendEvent``s (see ``models.py``) through
25 the single async callback supplied at construction time. The provider
26 drives the backend exclusively through the methods below, so it never
27 needs to know which backend it is talking to.
28 """
29
30 @property
31 @abstractmethod
32 def audio_format(self) -> AudioFormat:
33 """Return the source audio format (advertised to clients for display)."""
34
35 @property
36 @abstractmethod
37 def decoded_audio_format(self) -> AudioFormat:
38 """Return the decoded PCM format the audio reader actually delivers."""
39
40 @property
41 def stream_ends_on_pause(self) -> bool:
42 """
43 Whether the audio stream reaches a clean end when playback pauses.
44
45 Pipe-fed backends deliver silence on pause instead; the provider then
46 stops the player actively on the paused state event.
47 """
48 return True
49
50 @property
51 def supports_queue_control(self) -> bool:
52 """
53 Whether the backend implements the queue-session verbs.
54
55 A backend returning True implements ``add_to_queue``, ``set_shuffle``,
56 ``set_repeat`` and ``request_queue`` and emits QUEUE_CHANGED /
57 OPTIONS_CHANGED events.
58 """
59 return False
60
61 @abstractmethod
62 async def start(self) -> None:
63 """Start the backend and its supervised Spotify Connect implementation."""
64
65 @abstractmethod
66 async def stop(self) -> None:
67 """Stop the backend and release all its resources."""
68
69 @abstractmethod
70 async def get_stream_source(self) -> BackendStreamSource:
71 """
72 Return how the streams controller should consume this backend's audio.
73
74 Called on every stream request â including queue preload, so this must
75 be side-effect-free. The result describes the live audio delivery
76 (stream type, optional pipe path and extra ffmpeg input arguments).
77 The delivered PCM is in ``decoded_audio_format``.
78 """
79
80 @abstractmethod
81 def get_audio_reader(self) -> AudioChunkReader | None:
82 """
83 Return a PCM chunk reader bound to the currently live audio pipe.
84
85 The reader yields raw PCM in ``decoded_audio_format`` and returns an
86 empty bytes object once that pipe closes (it does not follow a backend
87 restart). None is returned when no audio pipe is available.
88 """
89
90 @abstractmethod
91 async def play(self, uri: str, *, skip_to_uri: str | None = None) -> None:
92 """
93 Start playing a Spotify URI/context, making this device the active one.
94
95 :param uri: Spotify URI (track, album, playlist, ...) â typically a context.
96 :param skip_to_uri: Optional track URI within the context to start at.
97 """
98
99 @abstractmethod
100 async def resume(self) -> None:
101 """Resume playback on the active session."""
102
103 @abstractmethod
104 async def pause(self) -> None:
105 """Pause playback on the active session."""
106
107 @abstractmethod
108 async def deactivate(self) -> None:
109 """
110 Release this device as the active Spotify Connect device.
111
112 Ends the current session so the Spotify apps drop the device as their
113 playback target; the device stays available for reselection.
114 """
115
116 @abstractmethod
117 async def next(self) -> None:
118 """Skip to the next track."""
119
120 @abstractmethod
121 async def previous(self) -> None:
122 """Skip to the previous track (or rewind the current one)."""
123
124 @abstractmethod
125 async def seek(self, position_ms: int) -> None:
126 """
127 Seek to an absolute position in the current track.
128
129 :param position_ms: Target position in milliseconds.
130 """
131
132 @abstractmethod
133 async def set_volume(self, volume: int) -> None:
134 """
135 Set the Spotify-side playback volume.
136
137 :param volume: Absolute volume as a 0-100 percentage.
138 """
139
140 async def add_to_queue(self, uri: str) -> None:
141 """
142 Add a track to the session's play queue.
143
144 Only available on backends with ``supports_queue_control``.
145
146 :param uri: Spotify track URI to queue.
147 """
148 raise NotImplementedError
149
150 async def set_shuffle(self, enabled: bool) -> None:
151 """
152 Enable or disable shuffle on the active session.
153
154 Only available on backends with ``supports_queue_control``.
155
156 :param enabled: True to enable shuffle, False to disable it.
157 """
158 raise NotImplementedError
159
160 async def set_repeat(self, repeat: RepeatMode) -> None:
161 """
162 Set the repeat mode on the active session.
163
164 Only available on backends with ``supports_queue_control``. May await
165 the engine's acknowledgement, so the call can block and raise â never
166 call it from the backend event callback (the acknowledgement arrives
167 on the same loop and the wait could only time out).
168
169 :param repeat: OFF for no repeat, ONE for the current track, ALL for
170 the playing context.
171 """
172 raise NotImplementedError
173
174 async def request_queue(self, limit: int = 10) -> None:
175 """
176 Ask the session to (re)emit its queue view.
177
178 Only available on backends with ``supports_queue_control``. There is
179 no return value: the snapshot arrives as a QUEUE_CHANGED event.
180
181 :param limit: Maximum number of upcoming entries the snapshot should
182 include.
183 """
184 raise NotImplementedError
185