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