/
/
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
19
20from music_assistant_models.enums import ProviderFeature
21
22from music_assistant.models.plugin import PluginProvider
23
24if TYPE_CHECKING:
25 from music_assistant_models.media_items import AudioSource
26 from music_assistant_models.streamdetails import StreamDetails, StreamMetadata
27
28 from music_assistant.mass import MusicAssistant
29
30
31@dataclass
32class AudioSourceSession:
33 """
34 A live external AudioSource playing on a player.
35
36 Carries what the source is, who owns it, and what it reports about itself,
37 so none of it has to be read out of a queue item.
38
39 ``streamdetails`` and ``stream_session_id`` are independent of the session's
40 own existence: a paused external source keeps the player while its stream is
41 torn down, so both fall back to None without the session ending.
42 """
43
44 player_id: str
45 source: AudioSource
46 provider_instance_id: str
47 started_at: float = field(default_factory=time.time)
48 streamdetails: StreamDetails | None = None
49 stream_metadata: StreamMetadata | None = None
50 stream_metadata_last_updated: float | None = None
51 # an adopted placeholder stays replaceable by a later one, a report does not
52 stream_metadata_reported: bool = False
53 # token of the stream request currently holding the source's claim
54 stream_session_id: str | None = None
55
56 @property
57 def source_id(self) -> str:
58 """
59 Return the AudioSource.item_id this session plays.
60
61 Provider-scoped rather than unique: every shipped plugin names its only
62 source "main". Use ``source_uri`` wherever the identifier has to be
63 unique server-wide, such as a player's active source.
64 """
65 return self.source.item_id
66
67 @property
68 def source_uri(self) -> str | None:
69 """Return the server-wide unique uri of the AudioSource this session plays."""
70 return self.source.uri
71
72 def attach_streamdetails(self, streamdetails: StreamDetails) -> None:
73 """
74 Record the stream details resolved for this session's source.
75
76 Adopts the metadata they carry unless the source has reported something
77 itself: while a source is still selected on a player, what it reported is
78 what the session reports, however long ago it said it. Until it says
79 anything, the placeholder every plugin sets in ``get_stream_details``
80 stands in â for vban_receiver and sendspin_source that is the only
81 metadata there is, and a later placeholder replaces an earlier one so
82 those two still follow a reconnect that changed what they describe.
83
84 :param streamdetails: The stream details resolved for this source.
85 """
86 self.streamdetails = streamdetails
87 if streamdetails.stream_metadata is not None and not self.stream_metadata_reported:
88 self.stream_metadata = streamdetails.stream_metadata
89 self.stream_metadata_last_updated = time.time()
90
91
92class AudioSourceMixin:
93 """
94 Mixin class providing live audio source sessions for PlayerController.
95
96 Handles:
97 - Tracking which external AudioSource is playing on which player
98 - Resolving that source (and its owning plugin) for command proxying
99 - Receiving the live metadata the owning plugin pushes about the source
100
101 This mixin expects to be mixed with a class that provides:
102 - mass: MusicAssistant instance
103 - logger: logging.Logger instance
104 - _source_sessions: dict of live sessions, keyed on player_id
105 - trigger_player_update(): method to signal a player state change
106 """
107
108 # Type hints for attributes provided by the class this mixin is used with
109 if TYPE_CHECKING:
110 mass: MusicAssistant
111 logger: logging.Logger
112 _source_sessions: dict[str, AudioSourceSession]
113
114 def trigger_player_update(self, player_id: str) -> None: ... # noqa: D102
115
116 def get_audio_source_session(self, player_id: str) -> AudioSourceSession | None:
117 """
118 Return the live AudioSource session on the given player, if any.
119
120 :param player_id: The player to inspect.
121 """
122 return self._source_sessions.get(player_id)
123
124 def get_player_audio_source(self, player_id: str) -> tuple[AudioSource, PluginProvider] | None:
125 """
126 Return the AudioSource playing on the given player and its owning PluginProvider.
127
128 Resolves the given player alone, so a group member playing its group's
129 source has to be asked for by the group's id.
130
131 Returns None when no source is playing on the player, or when the owning
132 plugin provider is no longer available.
133
134 :param player_id: The player whose source to resolve.
135 """
136 if (session := self._source_sessions.get(player_id)) is None:
137 return None
138 provider = self.mass.get_provider(session.provider_instance_id)
139 if not isinstance(provider, PluginProvider):
140 return None
141 # a provider can drop the feature at runtime (reload, config change), which
142 # would leave the control hooks raising NotImplementedError
143 if ProviderFeature.AUDIO_SOURCE not in provider.supported_features:
144 return None
145 return session.source, provider
146
147 def update_source_metadata(
148 self,
149 player_id: str,
150 source_id: str,
151 provider_instance_id: str,
152 stream_metadata: StreamMetadata,
153 ) -> None:
154 """
155 Push a live metadata update for the AudioSource playing on a player.
156
157 Used by plugin providers exposing an AudioSource (e.g. AirPlay receiver,
158 Spotify Connect) to surface live track-change info without restarting the
159 stream. Accepted from the moment the source is selected, so a provider can
160 report what it already knows before any stream exists.
161
162 The update is rejected silently unless the source playing on the player is
163 owned by ``provider_instance_id`` with ``item_id == source_id``.
164
165 :param player_id: The player whose session should receive the update.
166 :param source_id: The AudioSource.item_id emitting this metadata.
167 :param provider_instance_id: The provider instance id emitting this metadata.
168 :param stream_metadata: The new stream metadata to attach.
169 """
170 session = self._source_sessions.get(player_id)
171 if (
172 session is None
173 or session.source_id != source_id
174 or session.provider_instance_id != provider_instance_id
175 ):
176 self.logger.debug(
177 "Rejected source update for player %s from provider %s source %s "
178 "(playing: provider %s source %s)",
179 player_id,
180 provider_instance_id,
181 source_id,
182 session.provider_instance_id if session else None,
183 session.source_id if session else None,
184 )
185 return
186 session.stream_metadata = stream_metadata
187 session.stream_metadata_last_updated = time.time()
188 session.stream_metadata_reported = True
189 self.trigger_player_update(player_id)
190
191 def _start_audio_source_session(
192 self,
193 player_id: str,
194 source: AudioSource,
195 provider_instance_id: str,
196 stream_session_id: str | None = None,
197 ) -> AudioSourceSession:
198 """
199 Record that an AudioSource is now playing on the given player.
200
201 Re-selecting the source already playing keeps its session and re-stamps
202 the stream token, so a player that drops and reconnects keeps the metadata
203 and stream details it had. The source object itself is always taken from
204 this call: a plugin rebuilds it whenever its capability flags change, and
205 the session has to report the current ones. Selecting a different source
206 replaces the session: a player outputs one source at a time.
207
208 :param player_id: The player the source plays on.
209 :param source: The AudioSource that was selected.
210 :param provider_instance_id: Instance id of the plugin exposing it.
211 :param stream_session_id: Token of the stream claiming the source, when one
212 has been requested; pass it so the matching release is recognised.
213 """
214 session = self._source_sessions.get(player_id)
215 if (
216 session is not None
217 and session.source_id == source.item_id
218 and session.provider_instance_id == provider_instance_id
219 ):
220 session.source = source
221 session.stream_session_id = stream_session_id
222 return session
223 session = AudioSourceSession(
224 player_id=player_id,
225 source=source,
226 provider_instance_id=provider_instance_id,
227 stream_session_id=stream_session_id,
228 )
229 self._source_sessions[player_id] = session
230 return session
231
232 def _end_audio_source_session(
233 self, player_id: str, stream_session_id: str | None = None
234 ) -> AudioSourceSession | None:
235 """
236 Drop the AudioSource session on the given player and return it.
237
238 Pass the ``stream_session_id`` of the stream being torn down to end only
239 the session that stream owns. A reconnect (the player drops and reopens
240 the stream before the first request's teardown runs) leaves the previous
241 request finishing *after* its replacement has started, and an unguarded
242 end would let that late teardown drop the live session â the same hazard
243 ``PluginProvider.on_source_unselected`` requires plugins to guard against.
244 Omit it to end whatever is playing, for a teardown that is not scoped to
245 one stream (an explicit deselect, or the player going away).
246
247 :param player_id: The player whose session ended.
248 :param stream_session_id: Only end the session holding this stream token.
249 :return: The session that was ended, or None if none matched.
250 """
251 if stream_session_id is not None:
252 session = self._source_sessions.get(player_id)
253 if session is None or session.stream_session_id != stream_session_id:
254 return None
255 return self._source_sessions.pop(player_id, None)
256