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