/
/
/
1"""Group Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6from copy import deepcopy
7from time import time
8from typing import TYPE_CHECKING, cast
9
10from aiohttp import HttpVersion11, web
11from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
12from music_assistant_models.constants import PLAYER_CONTROL_FAKE, PLAYER_CONTROL_NONE
13from music_assistant_models.enums import (
14 ConfigEntryType,
15 ContentType,
16 MediaType,
17 PlaybackState,
18 PlayerFeature,
19 PlayerType,
20)
21from music_assistant_models.errors import UnsupportedFeaturedException
22from music_assistant_models.media_items import AudioFormat
23from propcache import under_cached_property as cached_property
24
25from music_assistant.constants import (
26 CONF_DYNAMIC_GROUP_MEMBERS,
27 CONF_ENTRY_HTTP_PROFILE_DEFAULT_1,
28 CONF_GROUP_MEMBERS,
29 CONF_HTTP_PROFILE,
30 CONF_POWER_CONTROL,
31 DEFAULT_STREAM_HEADERS,
32 DLNA_CONTENT_FEATURES_REALTIME,
33)
34from music_assistant.controllers.streams.audio_processing import get_media_session_id
35from music_assistant.helpers.audio import get_mime_type
36from music_assistant.helpers.util import TaskManager
37from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
38
39from .constants import (
40 CONF_ENTRY_UGP_OUTPUT_FORMAT,
41 CONF_UGP_OUTPUT_FORMAT,
42 CONFIG_ENTRY_UGP_NOTE,
43 EXTRA_FEATURES_FROM_MEMBERS,
44 IDLE_GRACE_SECONDS,
45 UGP_OUTPUT_MP3,
46 resolve_ugp_output_format,
47)
48from .ugp_stream import UGPStream
49
50if TYPE_CHECKING:
51 from .provider import UniversalGroupProvider
52
53# The features the group carries on its own. Everything else is resolved per read in
54# the supported_features property: POWER when the user assigns 'Fake power control',
55# SET_MEMBERS for dynamic groups, and EXTRA_FEATURES_FROM_MEMBERS from the members.
56# PlayerFeature.POWER is intentionally not a base feature: the lifecycle (form on
57# play, dissolve on stop, debounced idle deform) governs whether the group captures
58# its members.
59BASE_FEATURES = {
60 PlayerFeature.PLAY_MEDIA,
61 PlayerFeature.MULTI_DEVICE_DSP,
62}
63
64
65class UniversalGroupPlayer(Player):
66 """Universal Group Player implementation."""
67
68 _attr_type: PlayerType = PlayerType.GROUP
69
70 def __init__(
71 self,
72 provider: UniversalGroupProvider,
73 player_id: str,
74 ) -> None:
75 """Initialize UniversalGroupPlayer instance."""
76 super().__init__(provider, player_id)
77 self.stream: UGPStream | None = None
78 self._attr_name = self.config.name or f"Universal Group {player_id}"
79 self._attr_available = True
80 # See SyncGroupPlayer: groups have no opinion on power by default; the
81 # session lifecycle is what governs activity. Fake power control is the
82 # opt-in mechanism for explicit on/off semantics.
83 self._attr_powered = None
84 self._attr_device_info = DeviceInfo(model="Universal Group", manufacturer=provider.name)
85 self._attr_needs_poll = True
86 self._attr_poll_interval = 30
87 # task that releases members after the idle grace window expires
88 self._idle_grace_task: asyncio.Task[None] | None = None
89 # register dynamic routes for the ugp stream (FLAC + MP3 cover the configured
90 # output formats; the actual codec served is decided by the UGP's own config,
91 # not by the request URL)
92 self._on_unload_callbacks.append(
93 self.mass.streams.register_dynamic_route(
94 f"/ugp/{self.player_id}.flac", self._serve_ugp_stream
95 )
96 )
97 self._on_unload_callbacks.append(
98 self.mass.streams.register_dynamic_route(
99 f"/ugp/{self.player_id}.mp3", self._serve_ugp_stream
100 )
101 )
102 self._set_attributes()
103
104 @property
105 def supported_features(self) -> set[PlayerFeature]:
106 """Return the supported features of the player."""
107 features = {*BASE_FEATURES}
108 # The raw config value is read here to avoid recursion via the power_control
109 # property (which itself may inspect supported features).
110 raw_power_conf = self.mass.config.get_raw_player_config_value(
111 self.player_id, CONF_POWER_CONTROL
112 )
113 if raw_power_conf == PLAYER_CONTROL_FAKE:
114 features.add(PlayerFeature.POWER)
115 if self.is_dynamic:
116 features.add(PlayerFeature.SET_MEMBERS)
117 # derive the fanned-out features from all (configured) members, so volume and
118 # mute are advertised whether or not the group currently has a live session.
119 for member_id in self._attr_group_members:
120 member_player = self.mass.players.get_player(member_id)
121 if member_player and member_player.state.available:
122 for feature in EXTRA_FEATURES_FROM_MEMBERS:
123 if feature in member_player.state.supported_features:
124 features.add(feature)
125 return features
126
127 @property
128 def requires_flow_mode(self) -> bool:
129 """Return if the player requires flow mode."""
130 return True
131
132 @property
133 def synced_to(self) -> str | None:
134 """Return the id of the player this player is synced to (sync leader)."""
135 # groups can't be synced
136 return None
137
138 @property
139 def is_active_session(self) -> bool:
140 """
141 Return whether this group currently has captured members.
142
143 The session is considered active while the multicast stream is live or
144 while the idle grace timer is still pending. ``__final_active_group``
145 reads this to decide whether the configured members should be marked
146 as ``active_group`` for this group.
147 """
148 if self.stream is not None and not self.stream.done:
149 return True
150 return self._idle_grace_task is not None
151
152 @property
153 def can_group_with(self) -> set[str]:
154 """Return the id's of players this player can group with."""
155 if not self.is_dynamic:
156 # in case of static members,
157 # we can only group with the players defined in the config, so we return those directly
158 return set(self._attr_static_group_members)
159 # allow grouping with all providers, except the ugp provider itself
160 return {
161 x.instance_id
162 for x in self.mass.players.providers
163 if x.instance_id != self.provider.instance_id
164 }
165
166 @cached_property
167 def supported_sample_rates(self) -> list[tuple[int, int]] | None:
168 """Return the (sample_rate, bit_depth) pair the UGP serves to its members."""
169 # UGP delivers the same encoded stream to every member, so its only natively
170 # supported rate is whatever the configured output format produces. Returning a
171 # single-rate list keeps the upstream MA flow stream pinned and prevents
172 # smart/bit-perfect modes from triggering needless restarts.
173 output_format, _ = resolve_ugp_output_format(
174 cast("str", self.config.get_value(CONF_UGP_OUTPUT_FORMAT, UGP_OUTPUT_MP3))
175 )
176 return [(output_format.sample_rate, output_format.bit_depth)]
177
178 async def on_config_updated(self) -> None:
179 """Handle logic when the PlayerConfig is first loaded or updated."""
180 static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
181 self._attr_static_group_members = static_members.copy()
182 if not self.is_active_session:
183 # only realign members to the configured static set when the group
184 # is dormant â otherwise we would lose any dynamic adds mid-session.
185 self._attr_group_members = static_members.copy()
186
187 @cached_property
188 def is_dynamic(self) -> bool:
189 """Return if the player is a dynamic group player."""
190 return bool(self.config.get_value(CONF_DYNAMIC_GROUP_MEMBERS, False))
191
192 async def get_config_entries(self) -> list[ConfigEntry]:
193 """Return all (provider/player specific) Config Entries for the given player (if any)."""
194 return [
195 # add universal group specific entries
196 CONFIG_ENTRY_UGP_NOTE,
197 ConfigEntry(
198 key=CONF_GROUP_MEMBERS,
199 type=ConfigEntryType.STRING,
200 multi_value=True,
201 default_value=[],
202 required=False, # needed for dynamic members (which allows empty members list)
203 options=[
204 ConfigValueOption(x.player_id, title=x.display_name)
205 for x in self.mass.players.all_players(True, False)
206 if x.type != PlayerType.GROUP
207 ],
208 ),
209 ConfigEntry(
210 key=CONF_DYNAMIC_GROUP_MEMBERS,
211 type=ConfigEntryType.BOOLEAN,
212 default_value=False,
213 required=False,
214 ),
215 CONF_ENTRY_UGP_OUTPUT_FORMAT,
216 CONF_ENTRY_HTTP_PROFILE_DEFAULT_1,
217 ]
218
219 async def stop(self) -> None:
220 """
221 Handle STOP command.
222
223 An explicit stop releases the captured members immediately so they
224 return to individual control. The idle grace timer is only used for
225 natural end-of-queue transitions (see :meth:`_set_attributes`). Users
226 who want the group to stay 'active' across stops can assign Fake
227 power control and use that to pin the group.
228 """
229 # an explicit stop overrides any pending idle-grace release
230 self._cancel_idle_grace_timer()
231 async with TaskManager(self.mass) as tg:
232 for member in self.mass.players.iter_group_members(self, active_only=True):
233 # Use internal handler to get protocol selection and avoid redirect
234 tg.create_task(self.mass.players._handle_cmd_stop(member.player_id))
235 # abort the stream session â this drops is_active_session to False so
236 # the (former) members will see active_group=None on their next state
237 # update and accept direct playback commands again.
238 if self.stream and not self.stream.done:
239 await self.stream.stop()
240 self.stream = None
241 # snap group_members back to the configured static set so we don't
242 # keep stale dynamic adds around once the session has ended.
243 if self._attr_powered is not True:
244 self._attr_group_members = self._attr_static_group_members.copy()
245 self._set_attributes()
246
247 async def power(self, powered: bool) -> None:
248 """
249 Handle POWER command to group player.
250
251 Only called when the user has assigned a power control (native or fake)
252 to the group. Powering ON prepares the members so the group is
253 considered 'active' immediately (matching the legacy behaviour for
254 users who opt in). Powering OFF stops any playback and releases the
255 captured members.
256
257 :param powered: True to power on (capture members), False to power off (release).
258 """
259 # any pending idle-grace release is moot â we're on an explicit transition
260 self._cancel_idle_grace_timer()
261
262 # always stop at power off
263 if not powered and self._attr_playback_state in (
264 PlaybackState.PLAYING,
265 PlaybackState.PAUSED,
266 ):
267 await self.stop()
268
269 prev_power = self._attr_powered
270 self._attr_powered = powered
271
272 if powered:
273 await self._capture_members()
274 elif prev_power:
275 # handle TURN_OFF of the group player by turning off all members
276 for member in self.mass.players.iter_group_members(
277 self, only_powered=True, active_only=True
278 ):
279 if member.powered and member.power_control != PLAYER_CONTROL_NONE:
280 await self.mass.players.cmd_power(member.player_id, False)
281
282 if not powered:
283 # reset the original group members when powered off
284 self._attr_group_members = self._attr_static_group_members.copy()
285 self.update_state()
286
287 async def play_media(self, media: PlayerMedia) -> None:
288 """Handle PLAY MEDIA on given player."""
289 # form on play: cancel any pending idle-grace release, then capture the
290 # configured members and free them of any conflicting prior allegiance.
291 self._cancel_idle_grace_timer()
292 await self._capture_members()
293
294 if self.stream and not self.stream.done:
295 # stop any existing stream first
296 await self.stream.stop()
297
298 # resolve the static output format the UGP serves to all members
299 output_format, fmt_str = resolve_ugp_output_format(
300 cast("str", self.config.get_value(CONF_UGP_OUTPUT_FORMAT, UGP_OUTPUT_MP3))
301 )
302 # internal PCM pivot for the multiplexer: F32 at the configured output rate
303 # so the per-member encoder doesn't have to resample
304 pivot_format = AudioFormat(
305 content_type=ContentType.PCM_F32LE,
306 sample_rate=output_format.sample_rate,
307 bit_depth=32,
308 channels=2,
309 )
310 audio_source = self.mass.streams.get_stream(media, pivot_format, self.player_id)
311 self.stream = UGPStream(
312 audio_source=audio_source,
313 audio_format=pivot_format,
314 base_pcm_format=pivot_format,
315 queue_id=media.source_id,
316 session_id=get_media_session_id(media),
317 )
318 base_url = f"{self.mass.streams.base_url}/ugp/{self.player_id}.{fmt_str}"
319
320 # set the state optimistically
321 self._attr_current_media = deepcopy(media)
322 self._attr_elapsed_time = 0
323 self._attr_elapsed_time_last_updated = time() - 1
324 self._attr_playback_state = PlaybackState.PLAYING
325 self.update_state()
326
327 # forward to downstream play_media commands
328 async with TaskManager(self.mass) as tg:
329 for member in self.mass.players.iter_group_members(self, only_powered=True):
330 # Use internal handler to get protocol selection and avoid redirect
331 tg.create_task(
332 self.mass.players._handle_play_media(
333 member.player_id,
334 PlayerMedia(
335 uri=f"{base_url}?player_id={member.player_id}",
336 media_type=MediaType.FLOW_STREAM,
337 title=self.display_name,
338 source_id=self.player_id,
339 custom_data={
340 "ugp_player_id": self.player_id,
341 "session_id": self.stream.session_id,
342 },
343 ),
344 )
345 )
346
347 async def set_members(
348 self,
349 player_ids_to_add: list[str] | None = None,
350 player_ids_to_remove: list[str] | None = None,
351 ) -> None:
352 """Handle SET_MEMBERS command on the player."""
353 if not self.is_dynamic:
354 raise UnsupportedFeaturedException(
355 f"Group {self.display_name} does not allow dynamically adding/removing members!",
356 translation_key="group_not_dynamic",
357 translation_owner=self.translation_owner,
358 translation_args=[self.display_name],
359 )
360 # handle additions
361 for player_id in player_ids_to_add or []:
362 if player_id in self._attr_group_members:
363 continue
364 if player_id == self.player_id:
365 raise UnsupportedFeaturedException(
366 f"Cannot add {self.display_name} to itself as a member!",
367 translation_key="cannot_add_group_to_itself",
368 translation_owner=self.translation_owner,
369 translation_args=[self.display_name],
370 )
371 child_player = self.mass.players.get_player(player_id, True)
372 assert child_player # for type checking
373 if child_player.synced_to:
374 # This is player is part of a syncgroup - ungroup it first
375 await child_player.ungroup()
376 self._attr_group_members.append(player_id)
377 # let the newly added member join the stream if it's still live â
378 # the `self.powered` gate that used to guard this is gone with the
379 # session-lifecycle refactor (groups now have `_attr_powered=None`
380 # unless the user assigned Fake control).
381 if self.stream and not self.stream.done:
382 _, fmt_str = resolve_ugp_output_format(
383 cast("str", self.config.get_value(CONF_UGP_OUTPUT_FORMAT, UGP_OUTPUT_MP3))
384 )
385 base_url = f"{self.mass.streams.base_url}/ugp/{self.player_id}.{fmt_str}"
386 # Use internal handler to get protocol selection and avoid redirect
387 await self.mass.players._handle_play_media(
388 player_id,
389 PlayerMedia(
390 uri=f"{base_url}?player_id={player_id}",
391 media_type=MediaType.FLOW_STREAM,
392 title=self.display_name,
393 source_id=self.player_id,
394 custom_data={
395 "ugp_player_id": self.player_id,
396 "session_id": self.stream.session_id,
397 },
398 ),
399 )
400 # handle removals
401 for player_id in player_ids_to_remove or []:
402 if player_id not in self._attr_group_members:
403 continue
404 if player_id == self.player_id:
405 raise UnsupportedFeaturedException(
406 f"Cannot remove {self.display_name} from itself as a member!",
407 translation_key=(
408 "provider.universal_group.errors.cannot_remove_group_from_itself"
409 ),
410 translation_args=[self.display_name],
411 )
412 self._attr_group_members.remove(player_id)
413 child_player = self.mass.players.get_player(player_id, True)
414 assert child_player is not None # for type checking
415 if child_player.playback_state in (
416 PlaybackState.PLAYING,
417 PlaybackState.PAUSED,
418 ):
419 # if the child player is playing the group stream, stop it
420 # Use internal handler to get protocol selection and avoid redirect
421 await self.mass.players._handle_cmd_stop(player_id)
422 self.update_state()
423
424 async def poll(self) -> None:
425 """Poll player for state updates."""
426 self._set_attributes()
427
428 async def on_unload(self) -> None:
429 """Handle logic when the player is unloaded from the Player controller."""
430 self._cancel_idle_grace_timer()
431 await super().on_unload()
432 if self.is_active_session or self._attr_powered is True:
433 # tear down any in-flight session before unloading
434 await self.stop()
435 self._attr_powered = False
436
437 async def _capture_members(self) -> None:
438 """
439 Resolve collisions and prepare the configured members for grouping.
440
441 Rebuilds the effective member list from the configured static set,
442 powers on each member that has a power control, releases members
443 that are currently captured by another group / sync session, and
444 leaves the group ready for playback. Idempotent: safe to call on an
445 already-prepared group.
446 """
447 # rebuild the effective member list from the configured static set
448 self._attr_group_members = []
449 for static_group_member in self._attr_static_group_members:
450 if (
451 (member_player := self.mass.players.get_player(static_group_member))
452 and member_player.available
453 and member_player.enabled
454 ):
455 self._attr_group_members.append(static_group_member)
456 # ensure each member is free of any prior group/sync allegiance and ready to play
457 for member in self.mass.players.iter_group_members(
458 self, only_powered=False, active_only=False
459 ):
460 if (
461 member.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
462 and member.active_source != self.active_source
463 ):
464 # Use internal handler to get protocol selection and avoid redirect
465 await self.mass.players._handle_cmd_stop(member.player_id)
466 if (
467 member.state.active_group is not None
468 and member.state.active_group != self.player_id
469 ):
470 # collision: child is currently captured by a different group
471 if other_group := self.mass.players.get_player(member.state.active_group):
472 if (
473 other_group.supports_feature(PlayerFeature.SET_MEMBERS)
474 and member.player_id not in other_group.static_group_members
475 ):
476 async with self.mass.players.wait_for_player_update(
477 member.player_id, timeout=5
478 ):
479 await other_group.set_members(player_ids_to_remove=[member.player_id])
480 # the other group can't release this member dynamically â stop
481 # it entirely so the member is freed. Route power-off through
482 # the controller so a FAKE-power group also gets its extra_data
483 # updated; calling other_group.power() directly would only set
484 # _attr_powered and leave the cached fake state out of sync.
485 elif other_group.state.power_control != PLAYER_CONTROL_NONE:
486 async with self.mass.players.wait_for_player_update(
487 member.player_id, timeout=5
488 ):
489 await self.mass.players._handle_cmd_power(other_group.player_id, False)
490 else:
491 async with self.mass.players.wait_for_player_update(
492 member.player_id, timeout=5
493 ):
494 await other_group.stop()
495 if member.synced_to:
496 # member is part of a syncgroup â release it first
497 await member.ungroup()
498 if not member.powered and member.power_control != PLAYER_CONTROL_NONE:
499 await self.mass.players.cmd_power(member.player_id, True)
500
501 def _set_attributes(self) -> None:
502 """Set attributes of the group player."""
503 prev_state = self._attr_playback_state
504 # grab current media and state from one of the active players
505 # use state properties (not raw attributes) to account for protocol player propagation
506 for child_player in self.mass.players.iter_group_members(self, active_only=True):
507 self._attr_playback_state = child_player.state.playback_state
508 if child_player.state.elapsed_time:
509 self._attr_elapsed_time = child_player.state.elapsed_time
510 self._attr_elapsed_time_last_updated = child_player.state.elapsed_time_last_updated
511 break
512 else:
513 self._attr_playback_state = PlaybackState.IDLE
514 # idle grace handling: schedule a debounced release when playback
515 # naturally transitions to IDLE (e.g. queue ended). Skipped if the
516 # user has pinned the group with Fake power control.
517 if (
518 self._attr_playback_state == PlaybackState.IDLE
519 and prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
520 and self._attr_powered is not True
521 and self.stream is not None
522 and not self.stream.done
523 ):
524 self._schedule_idle_grace_timer()
525 elif self._attr_playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
526 self._cancel_idle_grace_timer()
527 self.update_state()
528
529 def _schedule_idle_grace_timer(self) -> None:
530 """Schedule a debounced session release after the stream becomes idle."""
531 self._cancel_idle_grace_timer()
532 self.logger.debug(
533 "Scheduling idle-grace release for universal group %s in %ss",
534 self.display_name,
535 IDLE_GRACE_SECONDS,
536 )
537 self._idle_grace_task = self.mass.create_task(self._idle_grace_runner())
538
539 def _cancel_idle_grace_timer(self) -> None:
540 """Cancel any pending idle-grace release task."""
541 if self._idle_grace_task is not None:
542 if not self._idle_grace_task.done():
543 self._idle_grace_task.cancel()
544 self._idle_grace_task = None
545
546 async def _idle_grace_runner(self) -> None:
547 """Wait the grace window, then release members if still idle."""
548 try:
549 await asyncio.sleep(IDLE_GRACE_SECONDS)
550 except asyncio.CancelledError:
551 return
552 # re-check state at fire time â a new play may have arrived, the user
553 # may have powered the group on, or another path may have torn down
554 # the session already.
555 self._idle_grace_task = None
556 if self._attr_powered is True:
557 return
558 if self._attr_playback_state != PlaybackState.IDLE:
559 return
560 self.logger.info(
561 "Idle-grace expired for universal group %s, releasing members",
562 self.display_name,
563 )
564 if self.stream and not self.stream.done:
565 await self.stream.stop()
566 self.stream = None
567 # snap group_members back to the configured static set; this drops
568 # is_active_session to False so children see active_group=None.
569 self._attr_group_members = self._attr_static_group_members.copy()
570 self.update_state()
571
572 async def _serve_ugp_stream(self, request: web.Request) -> web.StreamResponse:
573 """Serve the UGP (multi-client) flow stream audio to a player."""
574 ugp_player_id = request.path.rsplit(".")[0].rsplit("/")[-1]
575 # child_player_id is optional and only used for per-member DSP â never to
576 # decide the output codec/rate. The output format is dictated by the UGP
577 # player's own CONF_UGP_OUTPUT_FORMAT so every member receives an identical
578 # encoded stream.
579 child_player_id = request.query.get("player_id")
580
581 if not (ugp_player := self.mass.players.get_player(ugp_player_id)):
582 raise web.HTTPNotFound(reason=f"Unknown UGP player: {ugp_player_id}")
583 if not self.stream or self.stream.done:
584 raise web.HTTPNotFound(body=f"There is no active UGP stream for {ugp_player_id}!")
585
586 output_format, output_format_str = resolve_ugp_output_format(
587 cast("str", self.config.get_value(CONF_UGP_OUTPUT_FORMAT, UGP_OUTPUT_MP3))
588 )
589 headers = {
590 **DEFAULT_STREAM_HEADERS,
591 "contentFeatures.dlna.org": DLNA_CONTENT_FEATURES_REALTIME,
592 "Content-Type": get_mime_type(output_format_str),
593 }
594 resp = web.StreamResponse(status=200, reason="OK", headers=headers)
595 http_profile = self.get_config_value(CONF_HTTP_PROFILE, "chunked")
596 # prefer the configuration of the player that actually renders the audio
597 # (the member's active protocol player when it outputs via a protocol);
598 # child player_id may be stale/invalid, then fall back to the group profile
599 if child_player_id and (child_player := self.mass.players.get_player(child_player_id)):
600 http_profile = child_player.get_output_config_value(CONF_HTTP_PROFILE, http_profile)
601 if http_profile == "chunked" and request.version < HttpVersion11:
602 # chunked encoding is not allowed on HTTP/1.0; fall back to
603 # connection-close streaming to avoid raising in resp.prepare()
604 self.logger.debug(
605 "Disabling chunked encoding for UGP stream to HTTP/1.0 client %s",
606 child_player_id or request.remote,
607 )
608 http_profile = "no_content_length"
609 if http_profile == "forced_content_length":
610 # some clients (notably older Chromecast firmware) refuse to play unless
611 # they see a Content-Length header up front
612 resp.content_length = 4294967296
613 elif http_profile == "chunked":
614 resp.enable_chunked_encoding()
615 await resp.prepare(request)
616
617 # return early if this is not a GET request
618 if request.method != "GET":
619 return resp
620
621 self.logger.debug(
622 "Start serving UGP flow audio stream for UGP-player %s to %s",
623 ugp_player.display_name,
624 child_player_id or request.remote,
625 )
626
627 # Generate filter params for the player specific DSP settings
628 output_plan = None
629 if child_player_id:
630 output_plan = self.mass.streams.audio.get_player_output_plan(
631 child_player_id,
632 self.stream.input_format,
633 output_format,
634 queue_id=self.stream.queue_id,
635 session_id=self.stream.session_id,
636 )
637
638 async for chunk in self.stream.get_stream(
639 output_format,
640 filter_params=output_plan.filter_params if output_plan else None,
641 ):
642 try:
643 await resp.write(chunk)
644 except ConnectionError, ConnectionResetError:
645 break
646
647 return resp
648