/
/
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 def attach_streamdetails(self, streamdetails: StreamDetails) -> None:
85 """
86 Record the stream details resolved for this session's source.
87
88 Adopts the metadata they carry unless the source has reported something
89 itself: while a source is still selected on a player, what it reported is
90 what the session reports, however long ago it said it. Until it says
91 anything, the placeholder every plugin sets in ``get_stream_details``
92 stands in — for vban_receiver and sendspin_source that is the only
93 metadata there is, and a later placeholder replaces an earlier one so
94 those two still follow a reconnect that changed what they describe.
95
96 :param streamdetails: The stream details resolved for this source.
97 """
98 self.streamdetails = streamdetails
99 if streamdetails.stream_metadata is not None and not self.stream_metadata_reported:
100 self.stream_metadata = streamdetails.stream_metadata
101 self.stream_metadata_last_updated = time.time()
102
103
104class AudioSourceMixin:
105 """
106 Mixin class providing live audio source sessions for PlayerController.
107
108 Handles:
109 - Tracking which external AudioSource is playing on which player
110 - Committing a source's move between players once its stream is claimed
111 - Resolving that source (and its owning plugin) for command proxying
112 - Receiving the live metadata the owning plugin pushes about the source
113
114 This mixin expects to be mixed with a class that provides:
115 - mass: MusicAssistant instance
116 - logger: logging.Logger instance
117 - _source_sessions: dict of live sessions, keyed on player_id
118 - trigger_player_update(): method to signal a player state change
119 """
120
121 # Type hints for attributes provided by the class this mixin is used with
122 if TYPE_CHECKING:
123 mass: MusicAssistant
124 logger: logging.Logger
125 _source_sessions: dict[str, AudioSourceSession]
126
127 def trigger_player_update(self, player_id: str) -> None: ... # noqa: D102
128
129 def get_audio_source_session(self, player_id: str) -> AudioSourceSession | None:
130 """
131 Return the live AudioSource session on the given player, if any.
132
133 :param player_id: The player to inspect.
134 """
135 return self._source_sessions.get(player_id)
136
137 def get_player_audio_source(self, player_id: str) -> tuple[AudioSource, PluginProvider] | None:
138 """
139 Return the AudioSource playing on the given player and its owning PluginProvider.
140
141 Resolves the given player alone, so a group member playing its group's
142 source has to be asked for by the group's id.
143
144 Returns None when no source is playing on the player, or when the owning
145 plugin provider is no longer available.
146
147 :param player_id: The player whose source to resolve.
148 """
149 if (session := self._source_sessions.get(player_id)) is None:
150 return None
151 provider = self.mass.get_provider(session.provider_instance_id)
152 if not isinstance(provider, PluginProvider):
153 return None
154 # a provider can drop the feature at runtime (reload, config change), which
155 # would leave the control hooks raising NotImplementedError
156 if ProviderFeature.AUDIO_SOURCE not in provider.supported_features:
157 return None
158 return session.source, provider
159
160 def claim_audio_source_session(
161 self,
162 session: AudioSourceSession,
163 playback_session_id: str,
164 stream_session_id: str,
165 ) -> bool:
166 """
167 Register a stream request as the one serving a live source session.
168
169 Called once the owning plugin has accepted the stream request, which is
170 where a source moving between players commits: whichever other player
171 still held it is evicted on the selection's first stream request, so a
172 takeover that never gets one leaves it untouched on the player that has
173 it. Returns False when the session is no longer the live one on its
174 player, in which case the caller must not serve it.
175
176 :param session: The session the stream request resolved.
177 :param playback_session_id: The playback session the request set out to serve.
178 :param stream_session_id: Token identifying this stream request.
179 """
180 if (
181 self._source_sessions.get(session.player_id) is not session
182 or session.playback_session_id != playback_session_id
183 ):
184 return False
185 # a source plays on one player at a time, so it leaves whichever other player
186 # was holding it: two players both reporting it would let a command on the one
187 # that lost it drive the one that has it. Only the first request for this
188 # selection evicts: a reconnect on the player already streaming the source
189 # would otherwise take away a player it is being handed to, before that one
190 # has had its chance to start.
191 if session.stream_session_id is None:
192 for other_id, other in list(self._source_sessions.items()):
193 if (
194 other_id != session.player_id
195 and other.source_id == session.source_id
196 and other.provider_instance_id == session.provider_instance_id
197 ):
198 self.mass.streams.audio_processing.clear_source(
199 other_id, other.playback_session_id
200 )
201 del self._source_sessions[other_id]
202 self.trigger_player_update(other_id)
203 session.stream_session_id = stream_session_id
204 return True
205
206 def update_source_metadata(
207 self,
208 player_id: str,
209 source_id: str,
210 provider_instance_id: str,
211 stream_metadata: StreamMetadata,
212 ) -> None:
213 """
214 Push a live metadata update for the AudioSource playing on a player.
215
216 Used by plugin providers exposing an AudioSource (e.g. AirPlay receiver,
217 Spotify Connect) to surface live track-change info without restarting the
218 stream. Accepted from the moment the source is selected, so a provider can
219 report what it already knows before any stream exists.
220
221 The update is rejected silently unless the source playing on the player is
222 owned by ``provider_instance_id`` with ``item_id == source_id``.
223
224 :param player_id: The player whose session should receive the update.
225 :param source_id: The AudioSource.item_id emitting this metadata.
226 :param provider_instance_id: The provider instance id emitting this metadata.
227 :param stream_metadata: The new stream metadata to attach.
228 """
229 session = self._source_sessions.get(player_id)
230 if (
231 session is None
232 or session.source_id != source_id
233 or session.provider_instance_id != provider_instance_id
234 ):
235 self.logger.debug(
236 "Rejected source update for player %s from provider %s source %s "
237 "(playing: provider %s source %s)",
238 player_id,
239 provider_instance_id,
240 source_id,
241 session.provider_instance_id if session else None,
242 session.source_id if session else None,
243 )
244 return
245 session.stream_metadata = stream_metadata
246 session.stream_metadata_last_updated = time.time()
247 session.stream_metadata_reported = True
248 self.trigger_player_update(player_id)
249
250 def refresh_source(self, player_id: str, source: AudioSource) -> None:
251 """
252 Replace the AudioSource a session is publishing with a rebuilt one.
253
254 A plugin rebuilds its source whenever its capability flags change, and the
255 controls the player publishes come from the object the session holds, so it
256 has to be handed the new one for the change to reach a client. Rejected
257 silently unless it is the same source, from the same provider, as the one
258 playing.
259
260 :param player_id: The player whose session should publish the new object.
261 :param source: The rebuilt AudioSource.
262 """
263 session = self._source_sessions.get(player_id)
264 if (
265 session is None
266 or session.source_id != source.item_id
267 or session.provider_instance_id != source.provider
268 ):
269 return
270 session.source = source
271 self.trigger_player_update(player_id)
272
273 def update_source_options(
274 self,
275 player_id: str,
276 source_id: str,
277 provider_instance_id: str,
278 *,
279 shuffle_enabled: bool | None,
280 repeat_mode: RepeatMode | None,
281 ) -> None:
282 """
283 Record the ordering a live source reports for its own session.
284
285 A None value leaves that option as it was, as does ``RepeatMode.UNKNOWN``:
286 neither is the source saying anything. Rejected silently unless the source
287 playing on the player is owned by ``provider_instance_id`` with
288 ``item_id == source_id``.
289
290 :param player_id: The player whose session should receive the update.
291 :param source_id: The AudioSource.item_id emitting this update.
292 :param provider_instance_id: The provider instance id emitting this update.
293 :param shuffle_enabled: The session's shuffle state, or None to leave it.
294 :param repeat_mode: The session's repeat mode, or None to leave it.
295 """
296 session = self._source_sessions.get(player_id)
297 if (
298 session is None
299 or session.source_id != source_id
300 or session.provider_instance_id != provider_instance_id
301 ):
302 self.logger.debug(
303 "Rejected source options for player %s from provider %s source %s",
304 player_id,
305 provider_instance_id,
306 source_id,
307 )
308 return
309 changed = False
310 if shuffle_enabled is not None and session.shuffle_enabled != shuffle_enabled:
311 session.shuffle_enabled = shuffle_enabled
312 changed = True
313 if repeat_mode not in (None, RepeatMode.UNKNOWN) and session.repeat_mode != repeat_mode:
314 session.repeat_mode = repeat_mode
315 changed = True
316 if changed:
317 self.trigger_player_update(player_id)
318
319 def _start_audio_source_session(
320 self,
321 player_id: str,
322 source: AudioSource,
323 provider_instance_id: str,
324 ) -> AudioSourceSession:
325 """
326 Record that an AudioSource is now playing on the given player.
327
328 Re-selecting the source already playing keeps its session and re-stamps
329 the stream token, so a player that drops and reconnects keeps the metadata
330 and stream details it had. The source object itself is always taken from
331 this call: a plugin rebuilds it whenever its capability flags change, and
332 the session has to report the current ones. Selecting a different source
333 replaces the session: a player outputs one source at a time. A source
334 playing on another player stays there for now: that player is only
335 evicted when a stream request claims the new session, so a start that
336 never gets that far leaves the source where it was.
337
338 :param player_id: The player the source plays on.
339 :param source: The AudioSource that was selected.
340 :param provider_instance_id: Instance id of the plugin exposing it.
341 """
342 session = self._source_sessions.get(player_id)
343 if (
344 session is not None
345 and session.source_id == source.item_id
346 and session.provider_instance_id == provider_instance_id
347 ):
348 self.mass.streams.audio_processing.clear_source(
349 player_id,
350 session.playback_session_id,
351 preserve_details=True,
352 )
353 session.source = source
354 session.playback_session_id = uuid4().hex
355 session.stream_session_id = None
356 return session
357 if session is not None:
358 self.mass.streams.audio_processing.clear_source(player_id, session.playback_session_id)
359 session = AudioSourceSession(
360 player_id=player_id,
361 source=source,
362 provider_instance_id=provider_instance_id,
363 )
364 self._source_sessions[player_id] = session
365 return session
366
367 def _end_audio_source_session(self, player_id: str) -> AudioSourceSession | None:
368 """
369 Drop the AudioSource session on the given player and return it.
370
371 Not tied to a stream: a paused source keeps the player while its stream is
372 torn down, so this is only for the player being done with the source.
373
374 :param player_id: The player whose session ended.
375 :return: The session that was ended, or None if there was none.
376 """
377 session = self._source_sessions.get(player_id)
378 if session is not None:
379 self.mass.streams.audio_processing.clear_source(player_id, session.playback_session_id)
380 return self._source_sessions.pop(player_id, None)
381