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