/
/
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 source for display and
113 ``decoded_audio_format`` the PCM actually delivered, which a plugin that
114 decoded the source itself has to set; ``stream_metadata`` carries the initial
115 live metadata (and can be updated at runtime via
116 ``mass.players.update_source_metadata(player_id, ...)``).
117
118 Silence-during-pause contract:
119 the player consuming the stream needs a continuous byte flow or it will
120 disconnect after a few seconds. The server keeps the connection alive
121 differently depending on ``stream_type``:
122
123 - ``StreamType.CUSTOM`` â the server wraps ``get_audio_stream`` with a
124 silence-keepalive so a paused upstream device (no bytes yielded) does
125 NOT cause the player to drop out. The plugin can just stop yielding
126 while paused; the wrapper inserts silence frames at the declared PCM
127 format.
128 - ``StreamType.NAMED_PIPE`` â the underlying process MUST keep writing
129 silence to the pipe during pause states (shairport-sync and librespot
130 in pipe/passthrough mode both do this by default). If the producer
131 binary actually stops writing, the consuming ffmpeg will block and
132 the player will eventually disconnect.
133
134 :param item_id: The provider-scoped id of the item requested for playback:
135 an ``AudioSource.item_id`` or the id of another item this plugin owns.
136 :param media_type: The media type of the requested item.
137 """
138 raise NotImplementedError
139
140 async def get_audio_stream(
141 self, streamdetails: StreamDetails, seek_position: int = 0
142 ) -> AsyncGenerator[bytes]:
143 """
144 Return the (custom) audio stream for an AudioSource.
145
146 Will only be called when the StreamDetails returned by get_stream_details
147 has ``stream_type=StreamType.CUSTOM``. The yielded bytes must be in the PCM
148 format declared by ``streamdetails.decoded_audio_format``, falling back to
149 ``audio_format`` when the plugin delivers its source untouched.
150
151 Pausing is fine: when the upstream device is paused the plugin can stop
152 yielding bytes. The server wraps this generator with a silence-keepalive
153 that keeps the player connected by inserting silence at the declared PCM
154 format during quiet periods. The plugin should release any per-session
155 state in a ``try/finally`` â the consumer closes the generator when
156 playback ends or another queue takes over.
157
158 :param streamdetails: The StreamDetails previously returned by get_stream_details.
159 :param seek_position: Ignored for live AudioSources (no seek through the bytestream).
160 """
161 raise NotImplementedError
162 # unreachable, but the yield keeps this method an async generator
163 # so an unimplemented provider fails deterministically without emitting
164 # a stray empty chunk to the downstream consumer first.
165 yield b"" # type: ignore[unreachable]
166
167 def delivers_normalized_audio(self, streamdetails: StreamDetails) -> bool | None:
168 """
169 Return whether this plugin normalizes the live audio it delivers, if known.
170
171 :param streamdetails: Stream details of the active AudioSource.
172 """
173 return None
174
175 def delivers_crossfaded_audio(self, streamdetails: StreamDetails) -> bool | None:
176 """
177 Return whether this plugin crossfades the live audio it delivers, if known.
178
179 :param streamdetails: Stream details of the active AudioSource.
180 """
181 return None
182
183 async def on_source_control(
184 self,
185 source_id: str,
186 action: SourceControl,
187 value: SourceControlValue = None,
188 ) -> None:
189 """
190 Handle a playback control command for an active AudioSource.
191
192 Called when the user (or an automation) issues a control command while
193 this AudioSource is the live source on a player. The player controller
194 gates the transport actions on the flag the source declares for each:
195 ``can_play_pause`` for PLAY/PAUSE, ``can_seek`` for SEEK and
196 ``can_next_previous`` for NEXT/PREVIOUS. SHUFFLE/REPEAT are forwarded
197 whatever ``can_shuffle`` / ``can_repeat`` say, because only the session
198 knows whether its current content can be reordered â those flags tell
199 clients what to offer, and a source declaring them is expected to report
200 the resulting state back via ``mass.players.update_source_options``.
201
202 :param source_id: The AudioSource.item_id the command applies to.
203 :param action: The control action to perform.
204 :param value: Optional payload for the action: seek position in seconds
205 for SEEK, volume level 0-100 for VOLUME, the enabled state (bool)
206 for SHUFFLE, the RepeatMode for REPEAT; None for other actions.
207 """
208 raise NotImplementedError
209
210 async def on_source_selected(
211 self,
212 source_id: str,
213 player_id: str,
214 owner_player_id: str,
215 stream_session_id: str,
216 ) -> None:
217 """
218 React to an AudioSource being selected for playback.
219
220 Plugins exposing an exclusive AudioSource MUST claim ownership in this
221 hook (rather than in ``get_stream_details``). This hook fires only on
222 the actual stream request â not on queue preload â so claiming here
223 keeps the preload path side-effect-free and lets cross-queue handoffs
224 succeed (the streams controller fires this **before**
225 ``get_stream_details`` so the plugin can stop the previous player and
226 replace its claim before the upcoming stream-details fetch).
227
228 ``stream_session_id`` is a fresh per-request token paired with the
229 matching ``on_source_unselected`` call. Plugins should store it (and
230 replace any previously stored value) so the unselect callback can be
231 rejected as stale when a same-queue reconnect interleaves with the
232 prior request's teardown â see ``on_source_unselected`` for details.
233
234 :param source_id: The AudioSource.item_id that was selected.
235 :param player_id: The player the audio is served to. For a source playing on
236 a player this is the owner itself; only direct-PCM consumers and the
237 legacy queue-item path pass a different (protocol or group member) player.
238 :param owner_player_id: The player that owns this playback session. Prefer this
239 for anything you store: it is the user-facing player and stays valid for
240 play_media and cmd_stop, where ``player_id`` can be an ephemeral protocol
241 bridge whose id is gone by the time you use it.
242 :param stream_session_id: Opaque controller-generated token identifying
243 this specific stream request. The matching ``on_source_unselected``
244 receives the same value.
245 """
246
247 async def on_source_unselected(
248 self,
249 source_id: str,
250 owner_player_id: str,
251 stream_session_id: str,
252 ) -> None:
253 """
254 React to MA tearing down an AudioSource stream from this queue.
255
256 Fired in the ``finally`` block of the queue-item streaming handler â so
257 it runs whether the stream ended normally, the player disconnected, the
258 queue moved on, or an exception interrupted streaming. Override to
259 release any per-queue state set in ``get_stream_details`` (notably the
260 exclusive lock used to reject cross-queue claims) so the source becomes
261 available to other queues without depending on an external session
262 event.
263
264 Implementations MUST guard on ``stream_session_id`` matching the value
265 last set in ``on_source_selected``. A owner_player_id-only check is not
266 sufficient: same-queue reconnects (player drops + reopens the same
267 stream URL before the original request's finally fires) would
268 otherwise let the old request's late callback clear the live claim of
269 the new stream, silently dropping metadata and volume sync.
270
271 :param source_id: The AudioSource.item_id whose stream ended.
272 :param owner_player_id: The player that owns the stream being torn down.
273 :param stream_session_id: The token paired with ``on_source_selected``
274 for this specific stream request. Ignore the callback if it does
275 not match the currently stored active session id.
276 """
277
278 async def on_source_released(self, source_id: str, player_id: str) -> None:
279 """
280 React to a player letting go of this AudioSource.
281
282 Fired when the player stops playing the source for good: another source was
283 selected on it, it was deselected, or the player went away. Not fired when a
284 stream merely ends â a paused source keeps the player, and its stream is torn
285 down without the player being done with it. Override to release state that
286 must not outlive the player's use of the source, such as an upstream session
287 still pointing at Music Assistant.
288
289 Guard on the player still being the one you hold: a source moving to another
290 player claims the new one before releasing the old, so this can arrive after
291 the source is already playing elsewhere.
292
293 :param source_id: The AudioSource.item_id that was released.
294 :param player_id: The player that let it go.
295 """
296
297 async def on_volume_change(self, source_id: str, volume: int) -> None:
298 """
299 React to a volume change on the player streaming this AudioSource.
300
301 Optional hook. Override when the plugin wants to sync the upstream
302 device's volume slider with MA (e.g. Spotify Connect updating the
303 Spotify app's volume display, Yandex Ynison forwarding the new
304 level back to the Yandex device). Fired only on the direct queue
305 owner â group volume changes fire once at the group level, not
306 per child.
307
308 :param source_id: The AudioSource.item_id currently streaming.
309 :param volume: The new volume level (0-100).
310 """
311
312 async def get_tts_engines(self) -> list[TTSEngine]:
313 """
314 Return the TTS engines this plugin exposes.
315
316 Will only be called if ProviderFeature.TTS is declared.
317
318 May change over time (e.g. when the backend adds or removes voices/entities).
319 The user picks one of these in the config of a consuming provider.
320
321 :return: A list of TTSEngine items. Return an empty list if the plugin
322 currently has no engines to expose (e.g. the backend is offline).
323 """
324 if ProviderFeature.TTS in self.supported_features:
325 raise NotImplementedError
326 return []
327
328 async def get_tts_message(
329 self,
330 message: str,
331 language: str | None = None,
332 engine_id: str | None = None,
333 options: dict[str, Any] | None = None,
334 ) -> StreamDetails:
335 """
336 Convert text to speech audio.
337
338 Will only be called if ProviderFeature.TTS is declared.
339
340 :param message: The text to convert to speech.
341 :param language: Optional language code.
342 :param engine_id: The provider-scoped id of the engine to use (``TTSEngine.id``,
343 not its ``uid``). Omit or pass None to use the plugin's own default engine.
344 :param options: Optional integration-specific options (for example a voice
345 tuning parameter), passed through to the engine as-is. Ignored by plugins
346 that have none.
347 :return: StreamDetails for the generated audio. ``path`` must be either a
348 fetchable http(s)/rtsp/rtmp URL or the absolute path of an existing local
349 file, and must stay resolvable for as long as consumers may play the clip.
350 """
351 raise NotImplementedError
352
353 async def get_ai_engines(self) -> list[AIEngine]:
354 """
355 Return the AI engines this plugin exposes.
356
357 Will only be called if ProviderFeature.AI_QUERY is declared.
358
359 May change over time (e.g. when the backend adds or removes entities).
360 The user picks one of these in the config of a consuming provider.
361
362 :return: A list of AIEngine items. Return an empty list if the plugin
363 currently has no engines to expose (e.g. the backend is offline).
364 """
365 if ProviderFeature.AI_QUERY in self.supported_features:
366 raise NotImplementedError
367 return []
368
369 async def ai_query(self, query: str, engine_id: str | None = None) -> str:
370 """
371 Handle an AI query.
372
373 Will only be called if ProviderFeature.AI_QUERY is declared.
374
375 :param query: The query/prompt to send.
376 :param engine_id: The provider-scoped id of the engine to use (``AIEngine.id``,
377 not its ``uid``). Omit or pass None to use the plugin's own default engine.
378 :return: The AI response as a string.
379 """
380 raise NotImplementedError
381
382 async def search(
383 self,
384 search_query: str,
385 media_types: list[MediaType],
386 limit: int = 5,
387 ) -> SearchResults:
388 """
389 Perform a search on this plugin.
390
391 Will only be called if ProviderFeature.SEARCH is declared.
392
393 :param search_query: Search query.
394 :param media_types: A list of media_types to include.
395 :param limit: Number of items to return in the search (per type).
396 """
397 if ProviderFeature.SEARCH in self.supported_features:
398 raise NotImplementedError
399 return SearchResults()
400
401 async def get_similar_tracks(self, track: Track, limit: int = 25) -> list[Track]:
402 """
403 Retrieve a list of similar tracks for the given track.
404
405 Will only be called if ProviderFeature.SIMILAR_TRACKS is declared.
406
407 :param track: The reference track.
408 :param limit: Maximum number of similar tracks to return.
409 """
410 if ProviderFeature.SIMILAR_TRACKS in self.supported_features:
411 raise NotImplementedError
412 return []
413
414 async def get_recommendations(self) -> list[RecommendationFolder]:
415 """
416 Get this plugin's available recommendation rows, without items.
417
418 Must be fast: return static or cached row descriptors only, without
419 live backend calls. The items for a row are fetched separately
420 through get_recommendation_items.
421
422 Will only be called if ProviderFeature.RECOMMENDATIONS is declared.
423 """
424 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
425 raise NotImplementedError
426 return []
427
428 async def get_recommendation_items(
429 self, item_id: str
430 ) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
431 """
432 Get the items for a single recommendation row.
433
434 Live backend fetches belong here. Will only be called if
435 ProviderFeature.RECOMMENDATIONS is declared.
436
437 :param item_id: The item_id of the row, as returned by get_recommendations.
438 """
439 if ProviderFeature.RECOMMENDATIONS in self.supported_features:
440 raise NotImplementedError
441 return UniqueList()
442
443 async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
444 """
445 Browse this plugin's contents.
446
447 Will only be called if ProviderFeature.BROWSE is declared.
448
449 :param path: The path to browse, in the form ``<instance_id>://<sub_path>``.
450 """
451 if ProviderFeature.BROWSE in self.supported_features:
452 raise NotImplementedError
453 return []
454
455 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
456 """
457 Return details of a single playlist owned by this plugin.
458
459 :param prov_playlist_id: Provider-scoped playlist id.
460 """
461 raise NotImplementedError
462
463 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
464 """
465 Return a page of tracks for a playlist owned by this plugin.
466
467 :param prov_playlist_id: Provider-scoped playlist id.
468 :param page: Zero-based page index for paginated results.
469 """
470 raise NotImplementedError
471
472 async def resolve_image(self, path: str) -> str | bytes:
473 """
474 Resolve an image from an image path.
475
476 This either returns (a generator to get) raw bytes of the image or
477 a string with an http(s) URL or local path that is accessible from the server.
478 """
479 return path
480