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