/
/
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 both fall back to None without the session ending. The
44 ``playback_session_id`` identifies the current selection through pauses and
45 stream reconnects, and is refreshed when the source is explicitly reselected.
46 """
47
48 player_id: str
49 source: AudioSource
50 provider_instance_id: str
51 # identifies the current selection in its stream URLs
52 playback_session_id: str = field(default_factory=lambda: uuid4().hex)
53 started_at: float = field(default_factory=time.time)
54 streamdetails: StreamDetails | None = None
55 active_source_audio: ActiveSourceAudioDetails | None = None
56 stream_metadata: StreamMetadata | None = None
57 stream_metadata_last_updated: float | None = None
58 # an adopted placeholder stays replaceable by a later one, a report does not
59 stream_metadata_reported: bool = False
60 # the ordering the source reports for its own session; None = it has not said
61 shuffle_enabled: bool | None = None
62 repeat_mode: RepeatMode | None = None
63 # token of the stream request currently holding the source's claim
64 stream_session_id: str | None = None
65
66 @property
67 def source_id(self) -> str:
68 """
69 Return the AudioSource.item_id this session plays.
70
71 Provider-scoped rather than unique: every shipped plugin names its only
72 source "main". Use ``source_uri`` wherever the identifier has to be
73 unique server-wide, such as a player's active source.
74 """
75 return self.source.item_id
76
77 @property
78 def source_uri(self) -> str | None:
79 """Return the server-wide unique uri of the AudioSource this session plays."""
80 return self.source.uri
81
82 def attach_streamdetails(self, streamdetails: StreamDetails) -> None:
83 """
84 Record the stream details resolved for this session's source.
85
86 Adopts the metadata they carry unless the source has reported something
87 itself: while a source is still selected on a player, what it reported is
88 what the session reports, however long ago it said it. Until it says
89 anything, the placeholder every plugin sets in ``get_stream_details``
90 stands in — for vban_receiver and sendspin_source that is the only
91 metadata there is, and a later placeholder replaces an earlier one so
92 those two still follow a reconnect that changed what they describe.
93
94 :param streamdetails: The stream details resolved for this source.
95 """
96 self.streamdetails = streamdetails
97 if streamdetails.stream_metadata is not None and not self.stream_metadata_reported:
98 self.stream_metadata = streamdetails.stream_metadata
99 self.stream_metadata_last_updated = time.time()
100
101
102class AudioSourceMixin:
103 """
104 Mixin class providing live audio source sessions for PlayerController.
105
106 Handles:
107 - Tracking which external AudioSource is playing on which player
108 - Resolving that source (and its owning plugin) for command proxying
109 - Receiving the live metadata the owning plugin pushes about the source
110
111 This mixin expects to be mixed with a class that provides:
112 - mass: MusicAssistant instance
113 - logger: logging.Logger instance
114 - _source_sessions: dict of live sessions, keyed on player_id
115 - trigger_player_update(): method to signal a player state change
116 """
117
118 # Type hints for attributes provided by the class this mixin is used with
119 if TYPE_CHECKING:
120 mass: MusicAssistant
121 logger: logging.Logger
122 _source_sessions: dict[str, AudioSourceSession]
123
124 def trigger_player_update(self, player_id: str) -> None: ... # noqa: D102
125
126 def get_audio_source_session(self, player_id: str) -> AudioSourceSession | None:
127 """
128 Return the live AudioSource session on the given player, if any.
129
130 :param player_id: The player to inspect.
131 """
132 return self._source_sessions.get(player_id)
133
134 def get_player_audio_source(self, player_id: str) -> tuple[AudioSource, PluginProvider] | None:
135 """
136 Return the AudioSource playing on the given player and its owning PluginProvider.
137
138 Resolves the given player alone, so a group member playing its group's
139 source has to be asked for by the group's id.
140
141 Returns None when no source is playing on the player, or when the owning
142 plugin provider is no longer available.
143
144 :param player_id: The player whose source to resolve.
145 """
146 if (session := self._source_sessions.get(player_id)) is None:
147 return None
148 provider = self.mass.get_provider(session.provider_instance_id)
149 if not isinstance(provider, PluginProvider):
150 return None
151 # a provider can drop the feature at runtime (reload, config change), which
152 # would leave the control hooks raising NotImplementedError
153 if ProviderFeature.AUDIO_SOURCE not in provider.supported_features:
154 return None
155 return session.source, provider
156
157 def update_source_metadata(
158 self,
159 player_id: str,
160 source_id: str,
161 provider_instance_id: str,
162 stream_metadata: StreamMetadata,
163 ) -> None:
164 """
165 Push a live metadata update for the AudioSource playing on a player.
166
167 Used by plugin providers exposing an AudioSource (e.g. AirPlay receiver,
168 Spotify Connect) to surface live track-change info without restarting the
169 stream. Accepted from the moment the source is selected, so a provider can
170 report what it already knows before any stream exists.
171
172 The update is rejected silently unless the source playing on the player is
173 owned by ``provider_instance_id`` with ``item_id == source_id``.
174
175 :param player_id: The player whose session should receive the update.
176 :param source_id: The AudioSource.item_id emitting this metadata.
177 :param provider_instance_id: The provider instance id emitting this metadata.
178 :param stream_metadata: The new stream metadata to attach.
179 """
180 session = self._source_sessions.get(player_id)
181 if (
182 session is None
183 or session.source_id != source_id
184 or session.provider_instance_id != provider_instance_id
185 ):
186 self.logger.debug(
187 "Rejected source update for player %s from provider %s source %s "
188 "(playing: provider %s source %s)",
189 player_id,
190 provider_instance_id,
191 source_id,
192 session.provider_instance_id if session else None,
193 session.source_id if session else None,
194 )
195 return
196 session.stream_metadata = stream_metadata
197 session.stream_metadata_last_updated = time.time()
198 session.stream_metadata_reported = True
199 self.trigger_player_update(player_id)
200
201 def refresh_source(self, player_id: str, source: AudioSource) -> None:
202 """
203 Replace the AudioSource a session is publishing with a rebuilt one.
204
205 A plugin rebuilds its source whenever its capability flags change, and the
206 controls the player publishes come from the object the session holds, so it
207 has to be handed the new one for the change to reach a client. Rejected
208 silently unless it is the same source, from the same provider, as the one
209 playing.
210
211 :param player_id: The player whose session should publish the new object.
212 :param source: The rebuilt AudioSource.
213 """
214 session = self._source_sessions.get(player_id)
215 if (
216 session is None
217 or session.source_id != source.item_id
218 or session.provider_instance_id != source.provider
219 ):
220 return
221 session.source = source
222 self.trigger_player_update(player_id)
223
224 def update_source_options(
225 self,
226 player_id: str,
227 source_id: str,
228 provider_instance_id: str,
229 *,
230 shuffle_enabled: bool | None,
231 repeat_mode: RepeatMode | None,
232 ) -> None:
233 """
234 Record the ordering a live source reports for its own session.
235
236 A None value leaves that option as it was, as does ``RepeatMode.UNKNOWN``:
237 neither is the source saying anything. Rejected silently unless the source
238 playing on the player is owned by ``provider_instance_id`` with
239 ``item_id == source_id``.
240
241 :param player_id: The player whose session should receive the update.
242 :param source_id: The AudioSource.item_id emitting this update.
243 :param provider_instance_id: The provider instance id emitting this update.
244 :param shuffle_enabled: The session's shuffle state, or None to leave it.
245 :param repeat_mode: The session's repeat mode, or None to leave it.
246 """
247 session = self._source_sessions.get(player_id)
248 if (
249 session is None
250 or session.source_id != source_id
251 or session.provider_instance_id != provider_instance_id
252 ):
253 self.logger.debug(
254 "Rejected source options for player %s from provider %s source %s",
255 player_id,
256 provider_instance_id,
257 source_id,
258 )
259 return
260 changed = False
261 if shuffle_enabled is not None and session.shuffle_enabled != shuffle_enabled:
262 session.shuffle_enabled = shuffle_enabled
263 changed = True
264 if repeat_mode not in (None, RepeatMode.UNKNOWN) and session.repeat_mode != repeat_mode:
265 session.repeat_mode = repeat_mode
266 changed = True
267 if changed:
268 self.trigger_player_update(player_id)
269
270 def _start_audio_source_session(
271 self,
272 player_id: str,
273 source: AudioSource,
274 provider_instance_id: str,
275 ) -> AudioSourceSession:
276 """
277 Record that an AudioSource is now playing on the given player.
278
279 Re-selecting the source already playing keeps its session and re-stamps
280 the stream token, so a player that drops and reconnects keeps the metadata
281 and stream details it had. The source object itself is always taken from
282 this call: a plugin rebuilds it whenever its capability flags change, and
283 the session has to report the current ones. Selecting a different source
284 replaces the session: a player outputs one source at a time.
285
286 :param player_id: The player the source plays on.
287 :param source: The AudioSource that was selected.
288 :param provider_instance_id: Instance id of the plugin exposing it.
289 """
290 session = self._source_sessions.get(player_id)
291 if (
292 session is not None
293 and session.source_id == source.item_id
294 and session.provider_instance_id == provider_instance_id
295 ):
296 self.mass.streams.audio_processing.clear_source(
297 player_id,
298 session.playback_session_id,
299 preserve_details=True,
300 )
301 session.source = source
302 session.playback_session_id = uuid4().hex
303 session.stream_session_id = None
304 return session
305 if session is not None:
306 self.mass.streams.audio_processing.clear_source(player_id, session.playback_session_id)
307 # a source plays on one player at a time, so it leaves whichever other player
308 # was holding it: two players both reporting it would let a command on the one
309 # that lost it drive the one that has it
310 for other_id, other in list(self._source_sessions.items()):
311 if (
312 other_id != player_id
313 and other.source_id == source.item_id
314 and other.provider_instance_id == provider_instance_id
315 ):
316 self.mass.streams.audio_processing.clear_source(other_id, other.playback_session_id)
317 del self._source_sessions[other_id]
318 self.trigger_player_update(other_id)
319 session = AudioSourceSession(
320 player_id=player_id,
321 source=source,
322 provider_instance_id=provider_instance_id,
323 )
324 self._source_sessions[player_id] = session
325 return session
326
327 def _end_audio_source_session(self, player_id: str) -> AudioSourceSession | None:
328 """
329 Drop the AudioSource session on the given player and return it.
330
331 Not tied to a stream: a paused source keeps the player while its stream is
332 torn down, so this is only for the player being done with the source.
333
334 :param player_id: The player whose session ended.
335 :return: The session that was ended, or None if there was none.
336 """
337 session = self._source_sessions.get(player_id)
338 if session is not None:
339 self.mass.streams.audio_processing.clear_source(player_id, session.playback_session_id)
340 return self._source_sessions.pop(player_id, None)
341