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