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