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