/
/
/
1"""
2Audio Source Mixin for the Player Controller.
3
4Holds the live external AudioSource playing on a player, independently of that
5player's queue. A queue is Music Assistant's or it is not a queue: while an
6external source plays, the player's queue keeps its own items and goes inactive,
7exactly as it does for a line-in or TV input.
8
9This module provides the AudioSourceMixin class which is inherited by
10PlayerController to add per-player audio source sessions.
11"""
12
13from __future__ import annotations
14
15import logging
16import time
17from dataclasses import dataclass, field
18from typing import TYPE_CHECKING
19from uuid import uuid4
20
21from music_assistant_models.enums import ProviderFeature, RepeatMode
22
23from music_assistant.models.plugin import PluginProvider
24
25if TYPE_CHECKING:
26 from music_assistant_models.audio_processing import ActiveSourceAudioDetails
27 from music_assistant_models.media_items import AudioSource
28 from music_assistant_models.streamdetails import StreamDetails, StreamMetadata
29
30 from music_assistant.mass import MusicAssistant
31
32
33@dataclass
34class AudioSourceSession:
35 """
36 A live external AudioSource playing on a player.
37
38 Carries what the source is, who owns it, and what it reports about itself,
39 so none of it has to be read out of a queue item.
40
41 ``streamdetails`` and ``stream_session_id`` are independent of the session's
42 own existence: a paused external source keeps the player while its stream is
43 torn down, so neither is cleared by a stream ending. Both are None until the
44 first stream request, which is what makes ``stream_session_id`` the record of
45 whether this selection was ever streamed. The ``playback_session_id``
46 identifies the current selection through pauses and stream reconnects, and is
47 refreshed when the source is explicitly reselected.
48 """
49
50 player_id: str
51 source: AudioSource
52 provider_instance_id: str
53 # identifies the current selection in its stream URLs
54 playback_session_id: str = field(default_factory=lambda: uuid4().hex)
55 started_at: float = field(default_factory=time.time)
56 streamdetails: StreamDetails | None = None
57 active_source_audio: ActiveSourceAudioDetails | None = None
58 stream_metadata: StreamMetadata | None = None
59 stream_metadata_last_updated: float | None = None
60 # an adopted placeholder stays replaceable by a later one, a report does not
61 stream_metadata_reported: bool = False
62 # the ordering the source reports for its own session; None = it has not said
63 shuffle_enabled: bool | None = None
64 repeat_mode: RepeatMode | None = None
65 # token of the stream request currently holding the source's claim
66 stream_session_id: str | None = None
67
68 @property
69 def source_id(self) -> str:
70 """
71 Return the AudioSource.item_id this session plays.
72
73 Provider-scoped rather than unique: plugins reuse ids like "main" or a
74 player id across instances. Use ``source_uri`` wherever the identifier
75 has to be unique server-wide, such as a player's active source.
76 """
77 return self.source.item_id
78
79 @property
80 def source_uri(self) -> str | None:
81 """Return the server-wide unique uri of the AudioSource this session plays."""
82 return self.source.uri
83
84 @property
85 def active_source(self) -> str:
86 """Return what the player publishes as its active source while this session runs."""
87 return self.source_uri or self.player_id
88
89 def attach_streamdetails(self, streamdetails: StreamDetails) -> None:
90 """
91 Record the stream details resolved for this session's source.
92
93 Adopts the metadata they carry unless the source has reported something
94 itself: while a source is still selected on a player, what it reported is
95 what the session reports, however long ago it said it. Until it says
96 anything, the placeholder every plugin sets in ``get_stream_details``
97 stands in â for vban_receiver and sendspin_source that is the only
98 metadata there is, and a later placeholder replaces an earlier one so
99 those two still follow a reconnect that changed what they describe.
100
101 :param streamdetails: The stream details resolved for this source.
102 """
103 self.streamdetails = streamdetails
104 if streamdetails.stream_metadata is not None and not self.stream_metadata_reported:
105 self.stream_metadata = streamdetails.stream_metadata
106 self.stream_metadata_last_updated = time.time()
107
108
109class AudioSourceMixin:
110 """
111 Mixin class providing live audio source sessions for PlayerController.
112
113 Handles:
114 - Tracking which external AudioSource is playing on which player
115 - Committing a source's move between players once its stream is claimed
116 - Resolving that source (and its owning plugin) for command proxying
117 - Receiving the live metadata the owning plugin pushes about the source
118
119 This mixin expects to be mixed with a class that provides:
120 - mass: MusicAssistant instance
121 - logger: logging.Logger instance
122 - _source_sessions: dict of live sessions, keyed on player_id
123 - trigger_player_update(): method to signal a player state change
124 """
125
126 # Type hints for attributes provided by the class this mixin is used with
127 if TYPE_CHECKING:
128 mass: MusicAssistant
129 logger: logging.Logger
130 _source_sessions: dict[str, AudioSourceSession]
131
132 def trigger_player_update(self, player_id: str) -> None: ... # noqa: D102
133
134 def get_audio_source_session(self, player_id: str) -> AudioSourceSession | None:
135 """
136 Return the live AudioSource session on the given player, if any.
137
138 :param player_id: The player to inspect.
139 """
140 return self._source_sessions.get(player_id)
141
142 def is_live_audio_source(self, source: str) -> bool:
143 """
144 Return whether the given source string names a live AudioSource session.
145
146 MA pulls a live source's audio from its plugin and streams it out itself, so
147 such a source is MA-managed exactly like a queue is: it can be distributed to
148 a group, and a player playing one has not been taken over by anything.
149
150 Answered across every player, because a group member publishes the source
151 of the group it plays with rather than one of its own.
152
153 :param source: The source string to check.
154 :return: True if a live session publishes this source, False otherwise.
155 """
156 return any(session.active_source == source for session in self._source_sessions.values())
157
158 def get_player_audio_source(self, player_id: str) -> tuple[AudioSource, PluginProvider] | None:
159 """
160 Return the AudioSource playing on the given player and its owning PluginProvider.
161
162 Resolves the given player alone, so a group member playing its group's
163 source has to be asked for by the group's id.
164
165 Returns None when no source is playing on the player, or when the owning
166 plugin provider is no longer available.
167
168 :param player_id: The player whose source to resolve.
169 """
170 if (session := self._source_sessions.get(player_id)) is None:
171 return None
172 provider = self.mass.get_provider(session.provider_instance_id)
173 if not isinstance(provider, PluginProvider):
174 return None
175 # a provider can drop the feature at runtime (reload, config change), which
176 # would leave the control hooks raising NotImplementedError
177 if ProviderFeature.AUDIO_SOURCE not in provider.supported_features:
178 return None
179 return session.source, provider
180
181 def claim_audio_source_session(
182 self,
183 session: AudioSourceSession,
184 playback_session_id: str,
185 stream_session_id: str,
186 ) -> bool:
187 """
188 Register a stream request as the one serving a live source session.
189
190 Called once the owning plugin has accepted the stream request, which is
191 where a source moving between players commits: whichever other player
192 still held it is evicted on the selection's first stream request, so a
193 takeover that never gets one leaves it untouched on the player that has
194 it. Returns False when the session is no longer the live one on its
195 player, in which case the caller must not serve it.
196
197 :param session: The session the stream request resolved.
198 :param playback_session_id: The playback session the request set out to serve.
199 :param stream_session_id: Token identifying this stream request.
200 """
201 if (
202 self._source_sessions.get(session.player_id) is not session
203 or session.playback_session_id != playback_session_id
204 ):
205 return False
206 # a source plays on one player at a time, so it leaves whichever other player
207 # was holding it: two players both reporting it would let a command on the one
208 # that lost it drive the one that has it. Only the first request for this
209 # selection evicts: a reconnect on the player already streaming the source
210 # would otherwise take away a player it is being handed to, before that one
211 # has had its chance to start.
212 if session.stream_session_id is None:
213 for other_id, other in list(self._source_sessions.items()):
214 if (
215 other_id != session.player_id
216 and other.source_id == session.source_id
217 and other.provider_instance_id == session.provider_instance_id
218 ):
219 self.mass.streams.audio_processing.clear_source(
220 other_id, other.playback_session_id
221 )
222 del self._source_sessions[other_id]
223 self.trigger_player_update(other_id)
224 session.stream_session_id = stream_session_id
225 return True
226
227 def update_source_metadata(
228 self,
229 player_id: str,
230 source_id: str,
231 provider_instance_id: str,
232 stream_metadata: StreamMetadata,
233 ) -> None:
234 """
235 Push a live metadata update for the AudioSource playing on a player.
236
237 Used by plugin providers exposing an AudioSource (e.g. AirPlay receiver,
238 Spotify Connect) to surface live track-change info without restarting the
239 stream. Accepted from the moment the source is selected, so a provider can
240 report what it already knows before any stream exists.
241
242 The update is rejected silently unless the source playing on the player is
243 owned by ``provider_instance_id`` with ``item_id == source_id``.
244
245 :param player_id: The player whose session should receive the update.
246 :param source_id: The AudioSource.item_id emitting this metadata.
247 :param provider_instance_id: The provider instance id emitting this metadata.
248 :param stream_metadata: The new stream metadata to attach.
249 """
250 session = self._source_sessions.get(player_id)
251 if (
252 session is None
253 or session.source_id != source_id
254 or session.provider_instance_id != provider_instance_id
255 ):
256 self.logger.debug(
257 "Rejected source update for player %s from provider %s source %s "
258 "(playing: provider %s source %s)",
259 player_id,
260 provider_instance_id,
261 source_id,
262 session.provider_instance_id if session else None,
263 session.source_id if session else None,
264 )
265 return
266 session.stream_metadata = stream_metadata
267 session.stream_metadata_last_updated = time.time()
268 session.stream_metadata_reported = True
269 self.trigger_player_update(player_id)
270
271 def refresh_source(self, player_id: str, source: AudioSource) -> None:
272 """
273 Replace the AudioSource a session is publishing with a rebuilt one.
274
275 A plugin rebuilds its source whenever its capability flags change, and the
276 controls the player publishes come from the object the session holds, so it
277 has to be handed the new one for the change to reach a client. Rejected
278 silently unless it is the same source, from the same provider, as the one
279 playing.
280
281 :param player_id: The player whose session should publish the new object.
282 :param source: The rebuilt AudioSource.
283 """
284 session = self._source_sessions.get(player_id)
285 if (
286 session is None
287 or session.source_id != source.item_id
288 or session.provider_instance_id != source.provider
289 ):
290 return
291 session.source = source
292 self.trigger_player_update(player_id)
293
294 def update_source_options(
295 self,
296 player_id: str,
297 source_id: str,
298 provider_instance_id: str,
299 *,
300 shuffle_enabled: bool | None,
301 repeat_mode: RepeatMode | None,
302 ) -> None:
303 """
304 Record the ordering a live source reports for its own session.
305
306 A None value leaves that option as it was, as does ``RepeatMode.UNKNOWN``:
307 neither is the source saying anything. Rejected silently unless the source
308 playing on the player is owned by ``provider_instance_id`` with
309 ``item_id == source_id``.
310
311 :param player_id: The player whose session should receive the update.
312 :param source_id: The AudioSource.item_id emitting this update.
313 :param provider_instance_id: The provider instance id emitting this update.
314 :param shuffle_enabled: The session's shuffle state, or None to leave it.
315 :param repeat_mode: The session's repeat mode, or None to leave it.
316 """
317 session = self._source_sessions.get(player_id)
318 if (
319 session is None
320 or session.source_id != source_id
321 or session.provider_instance_id != provider_instance_id
322 ):
323 self.logger.debug(
324 "Rejected source options for player %s from provider %s source %s",
325 player_id,
326 provider_instance_id,
327 source_id,
328 )
329 return
330 changed = False
331 if shuffle_enabled is not None and session.shuffle_enabled != shuffle_enabled:
332 session.shuffle_enabled = shuffle_enabled
333 changed = True
334 if repeat_mode not in (None, RepeatMode.UNKNOWN) and session.repeat_mode != repeat_mode:
335 session.repeat_mode = repeat_mode
336 changed = True
337 if changed:
338 self.trigger_player_update(player_id)
339
340 def _start_audio_source_session(
341 self,
342 player_id: str,
343 source: AudioSource,
344 provider_instance_id: str,
345 ) -> AudioSourceSession:
346 """
347 Record that an AudioSource is now playing on the given player.
348
349 Re-selecting the source already playing keeps its session and re-stamps
350 the stream token, so a player that drops and reconnects keeps the metadata
351 and stream details it had. The source object itself is always taken from
352 this call: a plugin rebuilds it whenever its capability flags change, and
353 the session has to report the current ones. Selecting a different source
354 replaces the session: a player outputs one source at a time. A source
355 playing on another player stays there for now: that player is only
356 evicted when a stream request claims the new session, so a start that
357 never gets that far leaves the source where it was.
358
359 :param player_id: The player the source plays on.
360 :param source: The AudioSource that was selected.
361 :param provider_instance_id: Instance id of the plugin exposing it.
362 """
363 session = self._source_sessions.get(player_id)
364 if (
365 session is not None
366 and session.source_id == source.item_id
367 and session.provider_instance_id == provider_instance_id
368 ):
369 self.mass.streams.audio_processing.clear_source(
370 player_id,
371 session.playback_session_id,
372 preserve_details=True,
373 )
374 session.source = source
375 session.playback_session_id = uuid4().hex
376 session.stream_session_id = None
377 return session
378 if session is not None:
379 self.mass.streams.audio_processing.clear_source(player_id, session.playback_session_id)
380 session = AudioSourceSession(
381 player_id=player_id,
382 source=source,
383 provider_instance_id=provider_instance_id,
384 )
385 self._source_sessions[player_id] = session
386 return session
387
388 def _end_audio_source_session(self, player_id: str) -> AudioSourceSession | None:
389 """
390 Drop the AudioSource session on the given player and return it.
391
392 Not tied to a stream: a paused source keeps the player while its stream is
393 torn down, so this is only for the player being done with the source.
394
395 :param player_id: The player whose session ended.
396 :return: The session that was ended, or None if there was none.
397 """
398 session = self._source_sessions.get(player_id)
399 if session is not None:
400 self.mass.streams.audio_processing.clear_source(player_id, session.playback_session_id)
401 return self._source_sessions.pop(player_id, None)
402