/
/
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 by the player controller when the user (or an automation) issues
175 a control command and the active queue item is an AudioSource whose
176 capability flag for the action is True (e.g. ``can_next_previous`` for
177 NEXT/PREVIOUS), and by the queue controller when the queue's current
178 item is an AudioSource declaring ``queue_capabilities`` (SHUFFLE/REPEAT).
179
180 :param source_id: The AudioSource.item_id the command applies to.
181 :param action: The control action to perform.
182 :param value: Optional payload for the action: seek position in seconds
183 for SEEK, volume level 0-100 for VOLUME, the enabled state (bool)
184 for SHUFFLE, the RepeatMode for REPEAT; None for other actions.
185 """
186 raise NotImplementedError
187
188 async def on_source_selected(
189 self,
190 source_id: str,
191 player_id: str,
192 queue_id: str,
193 stream_session_id: str,
194 ) -> None:
195 """
196 React to an AudioSource being selected for playback.
197
198 Plugins exposing an exclusive AudioSource MUST claim ownership in this
199 hook (rather than in ``get_stream_details``). This hook fires only on
200 the actual stream request â not on queue preload â so claiming here
201 keeps the preload path side-effect-free and lets cross-queue handoffs
202 succeed (the streams controller fires this **before**
203 ``get_stream_details`` so the plugin can stop the previous player and
204 replace its claim before the upcoming stream-details fetch).
205
206 ``stream_session_id`` is a fresh per-request token paired with the
207 matching ``on_source_unselected`` call. Plugins should store it (and
208 replace any previously stored value) so the unselect callback can be
209 rejected as stale when a same-queue reconnect interleaves with the
210 prior request's teardown â see ``on_source_unselected`` for details.
211
212 :param source_id: The AudioSource.item_id that was selected.
213 :param player_id: The player that will receive the stream.
214 :param queue_id: The queue that owns this playback session.
215 :param stream_session_id: Opaque controller-generated token identifying
216 this specific stream request. The matching ``on_source_unselected``
217 receives the same value.
218 """
219
220 async def on_source_unselected(
221 self,
222 source_id: str,
223 queue_id: str,
224 stream_session_id: str,
225 ) -> None:
226 """
227 React to MA tearing down an AudioSource stream from this queue.
228
229 Fired in the ``finally`` block of the queue-item streaming handler â so
230 it runs whether the stream ended normally, the player disconnected, the
231 queue moved on, or an exception interrupted streaming. Override to
232 release any per-queue state set in ``get_stream_details`` (notably the
233 exclusive lock used to reject cross-queue claims) so the source becomes
234 available to other queues without depending on an external session
235 event.
236
237 Implementations MUST guard on ``stream_session_id`` matching the value
238 last set in ``on_source_selected``. A queue_id-only check is not
239 sufficient: same-queue reconnects (player drops + reopens the same
240 stream URL before the original request's finally fires) would
241 otherwise let the old request's late callback clear the live claim of
242 the new stream, silently dropping metadata and volume sync.
243
244 :param source_id: The AudioSource.item_id whose stream ended.
245 :param queue_id: The queue whose stream is being torn down.
246 :param stream_session_id: The token paired with ``on_source_selected``
247 for this specific stream request. Ignore the callback if it does
248 not match the currently stored active session id.
249 """
250
251 async def on_source_removed(self, source_id: str, queue_id: str) -> None:
252 """
253 React to a queue dropping this AudioSource from its items.
254
255 Fired when the source leaves a queue that held it â whether or not it
256 was the one playing: the user cleared that queue, or started media that
257 took the source's place in it. Unlike ``on_source_unselected`` this is
258 not tied to a stream, so it also fires when the stream was already torn
259 down earlier (a paused source, for example) â the one moment where
260 nothing else tells the plugin that MA is done with the source. Override
261 to release state that must not outlive the queue, such as an upstream
262 session still pointing at MA.
263
264 Media that leaves the source among the queue's items does NOT fire
265 this, nor does starting that very same source again, nor handing the
266 queue to another player (see ``on_source_transferred``).
267
268 May fire while a stream for this source is still being torn down, so
269 the release has to be safe to run alongside ``on_source_unselected``.
270
271 :param source_id: The AudioSource.item_id that was removed.
272 :param queue_id: The queue that dropped the source.
273 """
274
275 async def on_source_transferred(
276 self, source_id: str, from_queue_id: str, to_queue_id: str
277 ) -> None:
278 """
279 React to this AudioSource being handed over to another queue.
280
281 Fired for every live source a transferred queue held, whether or not it
282 was the one playing. A transfer of a *playing* source re-selects it on
283 the target by itself, but one that was paused or merely queued is moved
284 without a stream request, so this is the only signal that the source
285 changed hands. Override to re-point the queue the plugin tracks as its
286 owner, so later callbacks (this hook's ``on_source_removed`` sibling
287 included) arrive for the right queue.
288
289 Only long-lived ownership belongs here. Anything scoped to a stream â
290 an exclusive claim, the ``on_source_selected`` session id â must be
291 left alone: no stream exists on the target until it starts one, and
292 ``on_source_selected`` re-establishes those when it does.
293
294 :param source_id: The AudioSource.item_id that was transferred.
295 :param from_queue_id: The queue that gave the source up.
296 :param to_queue_id: The queue that took it over.
297 """
298
299 async def on_volume_change(self, source_id: str, volume: int) -> None:
300 """
301 React to a volume change on the player streaming this AudioSource.
302
303 Optional hook. Override when the plugin wants to sync the upstream
304 device's volume slider with MA (e.g. Spotify Connect updating the
305 Spotify app's volume display, Yandex Ynison forwarding the new
306 level back to the Yandex device). Fired only on the direct queue
307 owner â group volume changes fire once at the group level, not
308 per child.
309
310 :param source_id: The AudioSource.item_id currently streaming.
311 :param volume: The new volume level (0-100).
312 """
313
314 async def get_tts_engines(self) -> list[TTSEngine]:
315 """
316 Return the TTS engines this plugin exposes.
317
318 Will only be called if ProviderFeature.TTS is declared.
319
320 May change over time (e.g. when the backend adds or removes voices/entities).
321 The user picks one of these in the config of a consuming provider.
322
323 :return: A list of TTSEngine items. Return an empty list if the plugin
324 currently has no engines to expose (e.g. the backend is offline).
325 """
326 if ProviderFeature.TTS in self.supported_features:
327 raise NotImplementedError
328 return []
329
330 async def get_tts_message(
331 self,
332 message: str,
333 language: str | None = None,
334 engine_id: str | None = None,
335 options: dict[str, Any] | None = None,
336 ) -> StreamDetails:
337 """
338 Convert text to speech audio.
339
340 Will only be called if ProviderFeature.TTS is declared.
341
342 :param message: The text to convert to speech.
343 :param language: Optional language code.
344 :param engine_id: The provider-scoped id of the engine to use (``TTSEngine.id``,
345 not its ``uid``). Omit or pass None to use the plugin's own default engine.
346 :param options: Optional integration-specific options (for example a voice
347 tuning parameter), passed through to the engine as-is. Ignored by plugins
348 that have none.
349 :return: StreamDetails for the generated audio. ``path`` must be either a
350 fetchable http(s)/rtsp/rtmp URL or the absolute path of an existing local
351 file, and must stay resolvable for as long as consumers may play the clip.
352 """
353 raise NotImplementedError
354
355 async def get_ai_engines(self) -> list[AIEngine]:
356 """
357 Return the AI engines this plugin exposes.
358
359 Will only be called if ProviderFeature.AI_QUERY is declared.
360
361 May change over time (e.g. when the backend adds or removes entities).
362 The user picks one of these in the config of a consuming provider.
363
364 :return: A list of AIEngine items. Return an empty list if the plugin
365 currently has no engines to expose (e.g. the backend is offline).
366 """
367 if ProviderFeature.AI_QUERY in self.supported_features:
368 raise NotImplementedError
369 return []
370
371 async def ai_query(self, query: str, engine_id: str | None = None) -> str:
372 """
373 Handle an AI query.
374
375 Will only be called if ProviderFeature.AI_QUERY is declared.
376
377 :param query: The query/prompt to send.
378 :param engine_id: The provider-scoped id of the engine to use (``AIEngine.id``,
379 not its ``uid``). Omit or pass None to use the plugin's own default engine.
380 :return: The AI response as a string.
381 """
382 raise NotImplementedError
383
384 async def search(
385 self,
386 search_query: str,
387 media_types: list[MediaType],
388 limit: int = 5,
389 ) -> SearchResults:
390 """
391 Perform a search on this plugin.
392
393 Will only be called if ProviderFeature.SEARCH is declared.
394
395 :param search_query: Search query.
396 :param media_types: A list of media_types to include.
397 :param limit: Number of items to return in the search (per type).
398 """
399 if ProviderFeature.SEARCH in self.supported_features:
400 raise NotImplementedError
401 return SearchResults()
402
403 async def get_similar_tracks(self, track: Track, limit: int = 25) -> list[Track]:
404 """
405 Retrieve a list of similar tracks for the given track.
406
407 Will only be called if ProviderFeature.SIMILAR_TRACKS is declared.
408
409 :param track: The reference track.
410 :param limit: Maximum number of similar tracks to return.
411 """
412 if ProviderFeature.SIMILAR_TRACKS in self.supported_features:
413 raise NotImplementedError
414 return []
415
416 async def get_recommendations(self) -> list[RecommendationFolder]:
417 """
418 Get this plugin's available recommendation rows, without items.
419
420 Must be fast: return static or cached row descriptors only, without
421 live backend calls. The items for a row are fetched separately
422 through get_recommendation_items.
423
424 Will only be called if ProviderFeature.RECOMMENDATIONS is declared.
425 """
426 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
427 raise NotImplementedError
428 return []
429
430 async def get_recommendation_items(
431 self, item_id: str
432 ) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
433 """
434 Get the items for a single recommendation row.
435
436 Live backend fetches belong here. Will only be called if
437 ProviderFeature.RECOMMENDATIONS is declared.
438
439 :param item_id: The item_id of the row, as returned by get_recommendations.
440 """
441 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
442 raise NotImplementedError
443 return UniqueList()
444
445 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
446 """
447 Browse this plugin's contents.
448
449 Will only be called if ProviderFeature.BROWSE is declared.
450
451 :param path: The path to browse, in the form ``<instance_id>://<sub_path>``.
452 """
453 if ProviderFeature.BROWSE in self.supported_features:
454 raise NotImplementedError
455 return []
456
457 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
458 """
459 Return details of a single playlist owned by this plugin.
460
461 :param prov_playlist_id: Provider-scoped playlist id.
462 """
463 raise NotImplementedError
464
465 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
466 """
467 Return a page of tracks for a playlist owned by this plugin.
468
469 :param prov_playlist_id: Provider-scoped playlist id.
470 :param page: Zero-based page index for paginated results.
471 """
472 raise NotImplementedError
473
474 async def resolve_image(self, path: str) -> str | bytes:
475 """
476 Resolve an image from an image path.
477
478 This either returns (a generator to get) raw bytes of the image or
479 a string with an http(s) URL or local path that is accessible from the server.
480 """
481 return path
482