/
/
1"""Model/base for a Plugin Provider implementation."""
2
3from __future__ import annotations
4
5from dataclasses import dataclass
6from typing import TYPE_CHECKING, Any
7
8from music_assistant_models.enums import ProviderFeature
9from music_assistant_models.media_items import SearchResults, UniqueList
10
11from .provider import Provider
12
13if TYPE_CHECKING:
14 from collections.abc import AsyncGenerator, Sequence
15
16 from music_assistant_models.enums import MediaType, SourceControl
17 from music_assistant_models.media_items import (
18 AudioSource,
19 BrowseFolder,
20 ItemMapping,
21 MediaItemType,
22 Playlist,
23 RecommendationFolder,
24 Track,
25 )
26 from music_assistant_models.streamdetails import StreamDetails
27
28
29# separator between the owning provider's instance_id and the provider-scoped engine id;
30# occurs in neither MA instance_ids nor Home Assistant entity_ids
31ENGINE_UID_SEPARATOR = "/"
32
33
34@dataclass(kw_only=True)
35class PluginEngine:
36 """
37 A single selectable backend exposed by a plugin provider.
38
39 One plugin can expose several engines (for example one per Home Assistant entity),
40 so consumers offer them as options in a config picker rather than treating the
41 plugin itself as the unit of choice. The chosen engine is stored in config by its
42 ``uid`` and handed back to the owning provider as the provider-scoped ``id``.
43
44 Server-side only: never serialized to clients.
45 """
46
47 id: str
48 name: str
49 provider: PluginProvider
50
51 @property
52 def uid(self) -> str:
53 """Return the globally unique id for this engine, as stored in config."""
54 return f"{self.provider.instance_id}{ENGINE_UID_SEPARATOR}{self.id}"
55
56
57@dataclass(kw_only=True)
58class AIEngine(PluginEngine):
59 """An engine that answers AI queries, invoked through ``PluginProvider.ai_query``."""
60
61
62@dataclass(kw_only=True)
63class TTSEngine(PluginEngine):
64 """An engine that renders speech, invoked through ``PluginProvider.get_tts_message``."""
65
66
67class PluginProvider(Provider):
68 """
69 Base representation of a Plugin for Music Assistant.
70
71 Plugin Provider implementations should inherit from this base model.
72 """
73
74 async def get_audio_sources(self) -> list[AudioSource]:
75 """
76 Return all AudioSources this plugin currently exposes.
77
78 Will only be called if ProviderFeature.AUDIO_SOURCE is declared.
79
80 May change over time (e.g. when a paired hardware device adds/removes
81 favorites). Each AudioSource is a regular MediaItem and will be browsable
82 under the global "Live Inputs" node and playable via the standard play_media flow.
83
84 :return: A list of AudioSource items. Return an empty list if the plugin
85 currently has no sources to expose (e.g. hardware is offline).
86 """
87 if ProviderFeature.AUDIO_SOURCE in self.supported_features:
88 raise NotImplementedError
89 return []
90
91 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
92 """
93 Return StreamDetails for a streamable item owned by this plugin.
94
95 Called for a playable item this plugin exposes; ``media_type`` says which kind.
96 AudioSource items require ProviderFeature.AUDIO_SOURCE to be declared.
97
98 MUST be side-effect-free. MA calls this from both the streaming path
99 and from queue preload (``_load_item``); claiming ownership here would
100 let a preload accidentally reserve an exclusive source and block a
101 subsequent cross-queue handoff at the actual stream request. Ownership
102 is claimed in ``on_source_selected`` (which fires only on the real
103 stream request, paired with ``on_source_unselected`` in the finally).
104
105 The returned StreamDetails uses the standard fields:
106 ``stream_type`` selects between a custom async generator and a path
107 (e.g. NAMED_PIPE); ``audio_format`` describes the PCM format the source
108 emits; ``stream_metadata`` carries the initial live metadata (and can
109 be updated at runtime via ``mass.streams.update_stream_metadata(queue_id, ...)``,
110 the same channel ICY radio metadata uses).
111
112 Silence-during-pause contract:
113 the player consuming the stream needs a continuous byte flow or it will
114 disconnect after a few seconds. The server keeps the connection alive
115 differently depending on ``stream_type``:
116
117 - ``StreamType.CUSTOM`` â the server wraps ``get_audio_stream`` with a
118 silence-keepalive so a paused upstream device (no bytes yielded) does
119 NOT cause the player to drop out. The plugin can just stop yielding
120 while paused; the wrapper inserts silence frames at the declared PCM
121 format.
122 - ``StreamType.NAMED_PIPE`` â the underlying process MUST keep writing
123 silence to the pipe during pause states (shairport-sync and librespot
124 in pipe/passthrough mode both do this by default). If the producer
125 binary actually stops writing, the consuming ffmpeg will block and
126 the player will eventually disconnect.
127
128 :param item_id: The provider-scoped id of the item requested for playback:
129 an ``AudioSource.item_id`` or the id of another item this plugin owns.
130 :param media_type: The media type of the requested item.
131 """
132 raise NotImplementedError
133
134 async def get_audio_stream(
135 self, streamdetails: StreamDetails, seek_position: int = 0
136 ) -> AsyncGenerator[bytes]:
137 """
138 Return the (custom) audio stream for an AudioSource.
139
140 Will only be called when the StreamDetails returned by get_stream_details
141 has ``stream_type=StreamType.CUSTOM``. The yielded bytes must be in
142 the PCM format declared by ``streamdetails.audio_format``.
143
144 Pausing is fine: when the upstream device is paused the plugin can stop
145 yielding bytes. The server wraps this generator with a silence-keepalive
146 that keeps the player connected by inserting silence at the declared PCM
147 format during quiet periods. The plugin should release any per-session
148 state in a ``try/finally`` â the consumer closes the generator when
149 playback ends or another queue takes over.
150
151 :param streamdetails: The StreamDetails previously returned by get_stream_details.
152 :param seek_position: Ignored for live AudioSources (no seek through the bytestream).
153 """
154 raise NotImplementedError
155 # unreachable, but the yield keeps this method an async generator
156 # so an unimplemented provider fails deterministically without emitting
157 # a stray empty chunk to the downstream consumer first.
158 yield b"" # type: ignore[unreachable]
159
160 async def on_source_control(
161 self,
162 source_id: str,
163 action: SourceControl,
164 value: int | None = None,
165 ) -> None:
166 """
167 Handle a playback control command for an active AudioSource.
168
169 Called by the player controller when the user (or an automation) issues
170 a control command and the active queue item is an AudioSource whose
171 capability flag for the action is True (e.g. ``can_next_previous`` for
172 NEXT/PREVIOUS).
173
174 :param source_id: The AudioSource.item_id the command applies to.
175 :param action: The control action to perform.
176 :param value: Optional numeric value: seek position in seconds for SEEK,
177 volume level 0-100 for VOLUME, ignored for other actions.
178 """
179 raise NotImplementedError
180
181 async def on_source_selected(
182 self,
183 source_id: str,
184 player_id: str,
185 queue_id: str,
186 stream_session_id: str,
187 ) -> None:
188 """
189 React to an AudioSource being selected for playback.
190
191 Plugins exposing an exclusive AudioSource MUST claim ownership in this
192 hook (rather than in ``get_stream_details``). This hook fires only on
193 the actual stream request â not on queue preload â so claiming here
194 keeps the preload path side-effect-free and lets cross-queue handoffs
195 succeed (the streams controller fires this **before**
196 ``get_stream_details`` so the plugin can stop the previous player and
197 replace its claim before the upcoming stream-details fetch).
198
199 ``stream_session_id`` is a fresh per-request token paired with the
200 matching ``on_source_unselected`` call. Plugins should store it (and
201 replace any previously stored value) so the unselect callback can be
202 rejected as stale when a same-queue reconnect interleaves with the
203 prior request's teardown â see ``on_source_unselected`` for details.
204
205 :param source_id: The AudioSource.item_id that was selected.
206 :param player_id: The player that will receive the stream.
207 :param queue_id: The queue that owns this playback session.
208 :param stream_session_id: Opaque controller-generated token identifying
209 this specific stream request. The matching ``on_source_unselected``
210 receives the same value.
211 """
212
213 async def on_source_unselected(
214 self,
215 source_id: str,
216 queue_id: str,
217 stream_session_id: str,
218 ) -> None:
219 """
220 React to MA tearing down an AudioSource stream from this queue.
221
222 Fired in the ``finally`` block of the queue-item streaming handler â so
223 it runs whether the stream ended normally, the player disconnected, the
224 queue moved on, or an exception interrupted streaming. Override to
225 release any per-queue state set in ``get_stream_details`` (notably the
226 exclusive lock used to reject cross-queue claims) so the source becomes
227 available to other queues without depending on an external session
228 event.
229
230 Implementations MUST guard on ``stream_session_id`` matching the value
231 last set in ``on_source_selected``. A queue_id-only check is not
232 sufficient: same-queue reconnects (player drops + reopens the same
233 stream URL before the original request's finally fires) would
234 otherwise let the old request's late callback clear the live claim of
235 the new stream, silently dropping metadata and volume sync.
236
237 :param source_id: The AudioSource.item_id whose stream ended.
238 :param queue_id: The queue whose stream is being torn down.
239 :param stream_session_id: The token paired with ``on_source_selected``
240 for this specific stream request. Ignore the callback if it does
241 not match the currently stored active session id.
242 """
243
244 async def on_source_removed(self, source_id: str, queue_id: str) -> None:
245 """
246 React to a queue dropping this AudioSource from its items.
247
248 Fired when the source leaves a queue that held it â whether or not it
249 was the one playing: the user cleared that queue, or started media that
250 took the source's place in it. Unlike ``on_source_unselected`` this is
251 not tied to a stream, so it also fires when the stream was already torn
252 down earlier (a paused source, for example) â the one moment where
253 nothing else tells the plugin that MA is done with the source. Override
254 to release state that must not outlive the queue, such as an upstream
255 session still pointing at MA.
256
257 Media that leaves the source among the queue's items does NOT fire
258 this, nor does starting that very same source again, nor handing the
259 queue to another player (see ``on_source_transferred``).
260
261 May fire while a stream for this source is still being torn down, so
262 the release has to be safe to run alongside ``on_source_unselected``.
263
264 :param source_id: The AudioSource.item_id that was removed.
265 :param queue_id: The queue that dropped the source.
266 """
267
268 async def on_source_transferred(
269 self, source_id: str, from_queue_id: str, to_queue_id: str
270 ) -> None:
271 """
272 React to this AudioSource being handed over to another queue.
273
274 Fired for every live source a transferred queue held, whether or not it
275 was the one playing. A transfer of a *playing* source re-selects it on
276 the target by itself, but one that was paused or merely queued is moved
277 without a stream request, so this is the only signal that the source
278 changed hands. Override to re-point the queue the plugin tracks as its
279 owner, so later callbacks (this hook's ``on_source_removed`` sibling
280 included) arrive for the right queue.
281
282 Only long-lived ownership belongs here. Anything scoped to a stream â
283 an exclusive claim, the ``on_source_selected`` session id â must be
284 left alone: no stream exists on the target until it starts one, and
285 ``on_source_selected`` re-establishes those when it does.
286
287 :param source_id: The AudioSource.item_id that was transferred.
288 :param from_queue_id: The queue that gave the source up.
289 :param to_queue_id: The queue that took it over.
290 """
291
292 async def on_volume_change(self, source_id: str, volume: int) -> None:
293 """
294 React to a volume change on the player streaming this AudioSource.
295
296 Optional hook. Override when the plugin wants to sync the upstream
297 device's volume slider with MA (e.g. Spotify Connect updating the
298 Spotify app's volume display, Yandex Ynison forwarding the new
299 level back to the Yandex device). Fired only on the direct queue
300 owner â group volume changes fire once at the group level, not
301 per child.
302
303 :param source_id: The AudioSource.item_id currently streaming.
304 :param volume: The new volume level (0-100).
305 """
306
307 async def get_tts_engines(self) -> list[TTSEngine]:
308 """
309 Return the TTS engines this plugin exposes.
310
311 Will only be called if ProviderFeature.TTS is declared.
312
313 May change over time (e.g. when the backend adds or removes voices/entities).
314 The user picks one of these in the config of a consuming provider.
315
316 :return: A list of TTSEngine items. Return an empty list if the plugin
317 currently has no engines to expose (e.g. the backend is offline).
318 """
319 if ProviderFeature.TTS in self.supported_features:
320 raise NotImplementedError
321 return []
322
323 async def get_tts_message(
324 self,
325 message: str,
326 language: str | None = None,
327 engine_id: str | None = None,
328 options: dict[str, Any] | None = None,
329 ) -> StreamDetails:
330 """
331 Convert text to speech audio.
332
333 Will only be called if ProviderFeature.TTS is declared.
334
335 :param message: The text to convert to speech.
336 :param language: Optional language code.
337 :param engine_id: The provider-scoped id of the engine to use (``TTSEngine.id``,
338 not its ``uid``). Omit or pass None to use the plugin's own default engine.
339 :param options: Optional integration-specific options (for example a voice
340 tuning parameter), passed through to the engine as-is. Ignored by plugins
341 that have none.
342 :return: StreamDetails for the generated audio. ``path`` must be either a
343 fetchable http(s)/rtsp/rtmp URL or the absolute path of an existing local
344 file, and must stay resolvable for as long as consumers may play the clip.
345 """
346 raise NotImplementedError
347
348 async def get_ai_engines(self) -> list[AIEngine]:
349 """
350 Return the AI engines this plugin exposes.
351
352 Will only be called if ProviderFeature.AI_QUERY is declared.
353
354 May change over time (e.g. when the backend adds or removes entities).
355 The user picks one of these in the config of a consuming provider.
356
357 :return: A list of AIEngine items. Return an empty list if the plugin
358 currently has no engines to expose (e.g. the backend is offline).
359 """
360 if ProviderFeature.AI_QUERY in self.supported_features:
361 raise NotImplementedError
362 return []
363
364 async def ai_query(self, query: str, engine_id: str | None = None) -> str:
365 """
366 Handle an AI query.
367
368 Will only be called if ProviderFeature.AI_QUERY is declared.
369
370 :param query: The query/prompt to send.
371 :param engine_id: The provider-scoped id of the engine to use (``AIEngine.id``,
372 not its ``uid``). Omit or pass None to use the plugin's own default engine.
373 :return: The AI response as a string.
374 """
375 raise NotImplementedError
376
377 async def search(
378 self,
379 search_query: str,
380 media_types: list[MediaType],
381 limit: int = 5,
382 ) -> SearchResults:
383 """
384 Perform a search on this plugin.
385
386 Will only be called if ProviderFeature.SEARCH is declared.
387
388 :param search_query: Search query.
389 :param media_types: A list of media_types to include.
390 :param limit: Number of items to return in the search (per type).
391 """
392 if ProviderFeature.SEARCH in self.supported_features:
393 raise NotImplementedError
394 return SearchResults()
395
396 async def get_similar_tracks(self, track: Track, limit: int = 25) -> list[Track]:
397 """
398 Retrieve a list of similar tracks for the given track.
399
400 Will only be called if ProviderFeature.SIMILAR_TRACKS is declared.
401
402 :param track: The reference track.
403 :param limit: Maximum number of similar tracks to return.
404 """
405 if ProviderFeature.SIMILAR_TRACKS in self.supported_features:
406 raise NotImplementedError
407 return []
408
409 async def get_recommendations(self) -> list[RecommendationFolder]:
410 """
411 Get this plugin's available recommendation rows, without items.
412
413 Must be fast: return static or cached row descriptors only, without
414 live backend calls. The items for a row are fetched separately
415 through get_recommendation_items.
416
417 Will only be called if ProviderFeature.RECOMMENDATIONS is declared.
418 """
419 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
420 raise NotImplementedError
421 return []
422
423 async def get_recommendation_items(
424 self, item_id: str
425 ) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
426 """
427 Get the items for a single recommendation row.
428
429 Live backend fetches belong here. Will only be called if
430 ProviderFeature.RECOMMENDATIONS is declared.
431
432 :param item_id: The item_id of the row, as returned by get_recommendations.
433 """
434 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
435 raise NotImplementedError
436 return UniqueList()
437
438 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
439 """
440 Browse this plugin's contents.
441
442 Will only be called if ProviderFeature.BROWSE is declared.
443
444 :param path: The path to browse, in the form ``<instance_id>://<sub_path>``.
445 """
446 if ProviderFeature.BROWSE in self.supported_features:
447 raise NotImplementedError
448 return []
449
450 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
451 """
452 Return details of a single playlist owned by this plugin.
453
454 :param prov_playlist_id: Provider-scoped playlist id.
455 """
456 raise NotImplementedError
457
458 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
459 """
460 Return a page of tracks for a playlist owned by this plugin.
461
462 :param prov_playlist_id: Provider-scoped playlist id.
463 :param page: Zero-based page index for paginated results.
464 """
465 raise NotImplementedError
466
467 async def resolve_image(self, path: str) -> str | bytes:
468 """
469 Resolve an image from an image path.
470
471 This either returns (a generator to get) raw bytes of the image or
472 a string with an http(s) URL or local path that is accessible from the server.
473 """
474 return path
475