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