/
/
1"""Backend contract for the Spotify Connect provider."""
2
3from __future__ import annotations
4
5from abc import ABC, abstractmethod
6from typing import TYPE_CHECKING, Final
7
8from music_assistant_models.config_entries import ConfigValueOption
9from music_assistant_models.enums import ContentType
10from music_assistant_models.media_items import AudioFormat
11
12if TYPE_CHECKING:
13 from music_assistant_models.enums import RepeatMode
14
15 from music_assistant.providers.spotify_connect.models import (
16 AudioChunkReader,
17 BackendStreamSource,
18 )
19
20# Streaming quality tiers, named after the Spotify apps' own vocabulary for
21# the same bitrates. They express a ceiling, not a guarantee: Spotify still
22# downshifts on a slow connection and falls back when a track (or the account)
23# has no file at the requested tier. Each backend maps these onto whatever its
24# engine understands, clamping to what that engine can actually deliver.
25AUDIO_QUALITY_NORMAL: Final = "normal"
26AUDIO_QUALITY_HIGH: Final = "high"
27AUDIO_QUALITY_VERY_HIGH: Final = "very_high"
28AUDIO_QUALITY_LOSSLESS: Final = "lossless"
29
30# The tiers as a config-entry option list, in ascending order. Shared so the
31# Spotify music provider's own soloist playback offers the same choice.
32AUDIO_QUALITY_OPTIONS: Final = [
33 ConfigValueOption(AUDIO_QUALITY_NORMAL),
34 ConfigValueOption(AUDIO_QUALITY_HIGH),
35 ConfigValueOption(AUDIO_QUALITY_VERY_HIGH),
36 ConfigValueOption(AUDIO_QUALITY_LOSSLESS),
37]
38
39# The bitrate each tier maps onto in kbps, matching the Spotify apps' own
40# vocabulary. Both the engines' own bitrate setting and the format advertised for
41# display come from here, so what we ask for is what we claim. Spoken content is
42# never lossless, and neither is go-librespot, so the lossless tier falls back to
43# the highest lossy rate for both rather than claiming more than they can deliver.
44MAX_LOSSY_BIT_RATE: Final[int] = 320
45LOSSY_BIT_RATES: Final[dict[str, int]] = {
46 AUDIO_QUALITY_NORMAL: 96,
47 AUDIO_QUALITY_HIGH: 160,
48 AUDIO_QUALITY_VERY_HIGH: MAX_LOSSY_BIT_RATE,
49 AUDIO_QUALITY_LOSSLESS: MAX_LOSSY_BIT_RATE,
50}
51
52
53def spotify_source_audio_format(quality: str, *, lossless: bool) -> AudioFormat:
54 """
55 Return the format Spotify is asked to serve at a streaming tier.
56
57 No engine reveals what it actually fetched, so this describes the configured
58 ceiling â the same thing the Spotify apps show. An engine that decodes on our
59 behalf hands over its own PCM, which is what ``decoded_audio_format``
60 describes; this stays the source of those samples.
61
62 :param quality: The configured AUDIO_QUALITY_* tier.
63 :param lossless: Whether the stream is served losslessly, which needs both the
64 lossless tier and an engine and content that can deliver it.
65 """
66 if lossless:
67 return AudioFormat(
68 content_type=ContentType.FLAC,
69 codec_type=ContentType.FLAC,
70 sample_rate=44100,
71 bit_depth=24,
72 channels=2,
73 )
74 return AudioFormat(
75 content_type=ContentType.OGG,
76 codec_type=ContentType.VORBIS,
77 sample_rate=44100,
78 bit_depth=16,
79 channels=2,
80 bit_rate=LOSSY_BIT_RATES.get(quality, MAX_LOSSY_BIT_RATE),
81 )
82
83
84class SpotifyConnectBackend(ABC):
85 """
86 Contract between the SpotifyConnectProvider and a Spotify Connect implementation.
87
88 A backend owns everything specific to one way of talking to Spotify
89 (daemon lifecycle, credentials, wire protocol, audio delivery) and reports
90 state changes as normalized ``BackendEvent``s (see ``models.py``) through
91 the single async callback supplied at construction time. The provider
92 drives the backend exclusively through the methods below, so it never
93 needs to know which backend it is talking to.
94 """
95
96 @property
97 @abstractmethod
98 def audio_format(self) -> AudioFormat:
99 """Return the source audio format (advertised to clients for display)."""
100
101 @property
102 @abstractmethod
103 def decoded_audio_format(self) -> AudioFormat:
104 """Return the decoded PCM format the audio reader actually delivers."""
105
106 @property
107 def stream_ends_on_pause(self) -> bool:
108 """
109 Whether the audio stream reaches a clean end when playback pauses.
110
111 A backend returning False never reaches one; the provider then stops the
112 player actively on the paused state event.
113 """
114 return True
115
116 @property
117 def supports_queue_control(self) -> bool:
118 """
119 Whether the backend implements the queue-session verbs.
120
121 A backend returning True implements ``add_to_queue``, ``set_shuffle``,
122 ``set_repeat`` and ``request_queue`` and emits QUEUE_CHANGED /
123 OPTIONS_CHANGED events.
124 """
125 return False
126
127 @abstractmethod
128 async def start(self) -> None:
129 """Start the backend and its supervised Spotify Connect implementation."""
130
131 @abstractmethod
132 async def stop(self) -> None:
133 """Stop the backend and release all its resources."""
134
135 @abstractmethod
136 async def get_stream_source(self) -> BackendStreamSource:
137 """
138 Return how the streams controller should consume this backend's audio.
139
140 Called on every stream request â including queue preload, so this must
141 be side-effect-free. The result describes the live audio delivery
142 (stream type, optional pipe path and extra ffmpeg input arguments).
143 The delivered PCM is in ``decoded_audio_format``.
144 """
145
146 @abstractmethod
147 def get_audio_reader(self) -> AudioChunkReader | None:
148 """
149 Return a PCM chunk reader bound to the currently live audio pipe.
150
151 The reader yields raw PCM in ``decoded_audio_format`` and returns an
152 empty bytes object once that pipe closes (it does not follow a backend
153 restart). None is returned when no audio pipe is available.
154 """
155
156 @abstractmethod
157 async def play(self, uri: str, *, skip_to_uri: str | None = None) -> None:
158 """
159 Start playing a Spotify URI/context, making this device the active one.
160
161 :param uri: Spotify URI (track, album, playlist, ...) â typically a context.
162 :param skip_to_uri: Optional track URI within the context to start at.
163 """
164
165 @abstractmethod
166 async def resume(self) -> None:
167 """Resume playback on the active session."""
168
169 @abstractmethod
170 async def pause(self) -> None:
171 """Pause playback on the active session."""
172
173 @abstractmethod
174 async def deactivate(self) -> None:
175 """
176 Release this device as the active Spotify Connect device.
177
178 Ends the current session so the Spotify apps drop the device as their
179 playback target; the device stays available for reselection.
180 """
181
182 @abstractmethod
183 async def next(self) -> None:
184 """Skip to the next track."""
185
186 @abstractmethod
187 async def previous(self) -> None:
188 """Skip to the previous track (or rewind the current one)."""
189
190 @abstractmethod
191 async def seek(self, position_ms: int) -> None:
192 """
193 Seek to an absolute position in the current track.
194
195 :param position_ms: Target position in milliseconds.
196 """
197
198 @abstractmethod
199 async def set_volume(self, volume: int) -> None:
200 """
201 Set the Spotify-side playback volume.
202
203 :param volume: Absolute volume as a 0-100 percentage.
204 """
205
206 async def add_to_queue(self, uri: str) -> None:
207 """
208 Add a track to the session's play queue.
209
210 Only available on backends with ``supports_queue_control``.
211
212 :param uri: Spotify track URI to queue.
213 """
214 raise NotImplementedError
215
216 async def set_shuffle(self, enabled: bool) -> None:
217 """
218 Enable or disable shuffle on the active session.
219
220 Only available on backends with ``supports_queue_control``.
221
222 :param enabled: True to enable shuffle, False to disable it.
223 """
224 raise NotImplementedError
225
226 async def set_repeat(self, repeat: RepeatMode) -> None:
227 """
228 Set the repeat mode on the active session.
229
230 Only available on backends with ``supports_queue_control``. May await
231 the engine's acknowledgement, so the call can block and raise â never
232 call it from the backend event callback (the acknowledgement arrives
233 on the same loop and the wait could only time out).
234
235 :param repeat: OFF for no repeat, ONE for the current track, ALL for
236 the playing context.
237 """
238 raise NotImplementedError
239
240 async def request_queue(self, limit: int = 10) -> None:
241 """
242 Ask the session to (re)emit its queue view.
243
244 Only available on backends with ``supports_queue_control``. There is
245 no return value: the snapshot arrives as a QUEUE_CHANGED event.
246
247 :param limit: Maximum number of upcoming entries the snapshot should
248 include.
249 """
250 raise NotImplementedError
251