PyLoxone-Audio 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,739 @@
1
+ """Bounded configured room favourites, separate from catalogue browsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from dataclasses import dataclass
8
+ import re
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from . import clock as dt_util
12
+
13
+ from .native import AudioCommandError
14
+
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Awaitable, Callable
18
+ from datetime import datetime
19
+ from .metadata import AudioMetadataCoordinator
20
+ from .catalogue import CatalogueTarget
21
+ from .native import AudioServerCommands, AudioZoneMetadata
22
+
23
+
24
+ _LOGGER = logging.getLogger(__name__)
25
+ _MAX_FAVOURITES = 256
26
+ _MAX_SERVICE_ACCOUNTS = 32
27
+ _MAX_NAME_LENGTH = 256
28
+ _MAX_PATH_LENGTH = 2048
29
+ _CONTROL_BOUNDARY = 32
30
+ _PAUSED_POSITION_TOLERANCE = 3.0
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SourceEnvironment:
35
+ """An isolated source-operation clock; defaults to aware UTC, never HA."""
36
+
37
+ clock: Callable[[], datetime] = dt_util.utcnow
38
+
39
+ async def async_observed_change(self, *args: Any, **kwargs: Any) -> AudioZoneMetadata | None:
40
+ """Run the concrete native settlement/feedback operation with this clock."""
41
+ return await async_observed_change(*args, clock=self.clock, **kwargs)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class AudioFavourite:
46
+ """A discovered favourite's stable identity and expected playback path."""
47
+
48
+ player_id: int
49
+ favourite_id: int
50
+ label: str
51
+ audio_path: str
52
+ is_context: bool = False
53
+
54
+
55
+ def parse_room_favourites(payload: object, player_id: int) -> tuple[AudioFavourite, ...]:
56
+ """Read one complete, bounded room-favourite page without rewriting paths."""
57
+ message = "Configured favourites require a complete bounded exact-player page"
58
+ if type(player_id) is not int or player_id <= 0 or not isinstance(payload, list) or len(payload) != 1:
59
+ raise ValueError(message)
60
+ row = payload[0]
61
+ if not isinstance(row, dict) or type(row.get("id")) is not int or row["id"] != player_id:
62
+ raise ValueError(message)
63
+ items, total = row.get("items"), row.get("totalitems")
64
+ if (
65
+ type(row.get("start")) is not int
66
+ or row["start"] != 0
67
+ or type(total) is not int
68
+ or not 0 <= total <= _MAX_FAVOURITES
69
+ or not isinstance(items, list)
70
+ or len(items) != total
71
+ ):
72
+ raise ValueError(message)
73
+ choices = []
74
+ seen = set()
75
+ for item in items:
76
+ if not isinstance(item, dict):
77
+ raise ValueError(message) # noqa: TRY004 - one discovery-validation contract
78
+ target = item.get("id")
79
+ if type(target) is not int or target <= 0 or target in seen:
80
+ raise ValueError(message)
81
+ seen.add(target)
82
+ kind = item.get("type")
83
+ prefixes = {
84
+ "tunein": ("tunein:",),
85
+ "loxoneradio": ("loxoneradio:",),
86
+ "custom_stream": ("tunein:",),
87
+ "linein": ("linein/", "linein:"),
88
+ "library_track": ("library:local:track:",),
89
+ "library_folder": ("library:local:folder:",),
90
+ "playlist": ("playlist:",),
91
+ **{
92
+ f"spotify_{name}": ("spotify:", "spotify@")
93
+ for name in (
94
+ "track",
95
+ "playlist",
96
+ "album",
97
+ "artist",
98
+ "collection",
99
+ "show",
100
+ "episode",
101
+ )
102
+ },
103
+ }
104
+ if kind not in prefixes:
105
+ continue
106
+ label, path = item.get("name"), item.get("audiopath")
107
+ if (
108
+ not isinstance(label, str)
109
+ or not label.strip()
110
+ or len(label) > _MAX_NAME_LENGTH
111
+ or any(ord(c) < _CONTROL_BOUNDARY for c in label)
112
+ or not isinstance(path, str)
113
+ or len(path) > _MAX_PATH_LENGTH
114
+ or not any(path.startswith(prefix) and path[len(prefix) :].strip() for prefix in prefixes[kind])
115
+ or any(ord(c) < _CONTROL_BOUNDARY for c in path)
116
+ ):
117
+ raise ValueError(message)
118
+ if kind.startswith("spotify_") and path.startswith("spotify:"):
119
+ # A favourite lacks the separately selected browser account. Do
120
+ # not advertise an account-ambiguous URI as an exact selection.
121
+ continue
122
+ context = kind in {
123
+ "library_folder",
124
+ "playlist",
125
+ "spotify_playlist",
126
+ "spotify_album",
127
+ "spotify_artist",
128
+ "spotify_collection",
129
+ "spotify_show",
130
+ }
131
+ choices.append(AudioFavourite(player_id, target, label, path, context))
132
+ counts = {label: sum(c.label == label for c in choices) for label in {c.label for c in choices}}
133
+ result = tuple(
134
+ AudioFavourite(
135
+ c.player_id,
136
+ c.favourite_id,
137
+ f"{c.label} (favourite {c.favourite_id})" if counts[c.label] > 1 else c.label,
138
+ c.audio_path,
139
+ c.is_context,
140
+ )
141
+ for c in choices
142
+ )
143
+ if len({c.label for c in result}) != len(result):
144
+ raise ValueError(message)
145
+ return result
146
+
147
+
148
+ def parse_spotify_accounts(payload: object) -> tuple[str, ...]:
149
+ """Retain healthy configured account IDs, not names or another service's IDs."""
150
+ message = "Spotify requires a bounded list of healthy configured accounts"
151
+ if not isinstance(payload, list) or len(payload) > _MAX_SERVICE_ACCOUNTS:
152
+ raise ValueError(message)
153
+ result = []
154
+ for row in payload:
155
+ if not isinstance(row, dict):
156
+ raise ValueError(message) # noqa: TRY004 - one discovery-validation contract
157
+ if row.get("cmd") != "spotify" or row.get("configerror") is not False:
158
+ continue
159
+ account = row.get("id")
160
+ if not isinstance(account, str) or not re.fullmatch(r"[A-Za-z0-9_.@+-]{1,128}", account):
161
+ raise ValueError(message)
162
+ if account not in result:
163
+ result.append(account)
164
+ return tuple(result)
165
+
166
+
167
+ def passive_history_eligible(
168
+ snapshot: AudioZoneMetadata | None, previous: AudioZoneMetadata | None,
169
+ *, settled: bool, initial_generation: bool,
170
+ ) -> bool:
171
+ """Qualify passive last-good observations independently of host scheduling."""
172
+ corroborated = bool(
173
+ snapshot is not None and previous is not None
174
+ and snapshot.native_mode == previous.native_mode == "play"
175
+ and snapshot.event_idle and previous.event_idle
176
+ and snapshot.event_active is previous.event_active is False
177
+ and snapshot.received_at is not None and previous.received_at is not None
178
+ and snapshot.received_at > previous.received_at
179
+ and all(getattr(snapshot, field) == getattr(previous, field)
180
+ for field in ("player_id", "source", "source_name", "audio_path", "parent_id"))
181
+ and (snapshot.source in ("TuneIn", "Loxone Radio", "Line-In") or (
182
+ snapshot.position is None or previous.position is None
183
+ or snapshot.position > previous.position
184
+ ))
185
+ )
186
+ return settled and (
187
+ initial_generation or corroborated or (snapshot is not None and snapshot.native_mode == "pause")
188
+ )
189
+
190
+
191
+ def native_selection_unchanged(original: AudioZoneMetadata | None, current: AudioZoneMetadata | None) -> bool:
192
+ """Reject identity changes and positive paused seeks throughout preparation."""
193
+ return bool(
194
+ original is not None
195
+ and current is not None
196
+ and all(
197
+ getattr(original, field) == getattr(current, field)
198
+ for field in (
199
+ "player_id",
200
+ "source",
201
+ "source_name",
202
+ "audio_path",
203
+ "parent_id",
204
+ "native_mode",
205
+ )
206
+ )
207
+ and not (
208
+ original.source == "Spotify"
209
+ and original.native_mode in ("pause", "stop")
210
+ and original.position is not None
211
+ and current.position is not None
212
+ and abs(original.position - current.position) > _PAUSED_POSITION_TOLERANCE
213
+ )
214
+ )
215
+
216
+
217
+ def source_matches(
218
+ snapshot: AudioZoneMetadata,
219
+ path: str,
220
+ *,
221
+ is_context: bool = False,
222
+ account_user: str | None = None,
223
+ ) -> bool:
224
+ """Match exact inputs/tracks or native parent context, including account scope."""
225
+ if account_user is not None and path.startswith(("spotify:", "spotify@")):
226
+ if snapshot.source != "Spotify" or snapshot.source_name != account_user:
227
+ return False
228
+ observed = snapshot.parent_id if is_context else snapshot.audio_path
229
+ if observed == path:
230
+ return True
231
+ if path.startswith("linein/"):
232
+ return observed == "linein:" + path.removeprefix("linein/")
233
+ match = re.fullmatch(r"spotify@([A-Za-z0-9_.@+-]{1,128}):(.+)", path)
234
+ return bool(match and account_user and snapshot.source_name == account_user and observed == "spotify:" + match[2])
235
+
236
+
237
+ async def _source_matcher(
238
+ commands: AudioServerCommands, path: str, *, is_context: bool = False,
239
+ expected_account: str | None = None, source_return: bool = False,
240
+ ) -> Callable[[AudioZoneMetadata], bool]:
241
+ account_user = None
242
+ match = re.fullmatch(r"spotify@([A-Za-z0-9_.@+-]{1,128}):(.+)", path)
243
+ if expected_account is not None and (match is None or match[1] != expected_account):
244
+ msg = "Spotify account changed; no source command was sent"
245
+ raise AudioCommandError(msg)
246
+ if match:
247
+ rows = await commands.async_service_accounts()
248
+ if match[1] not in parse_spotify_accounts(rows):
249
+ msg = "Spotify account changed; no source command was sent"
250
+ raise AudioCommandError(msg)
251
+ account_user = next(
252
+ row.get("user")
253
+ for row in rows
254
+ if row.get("cmd") == "spotify" and row.get("id") == match[1] and row.get("configerror") is False
255
+ )
256
+ if source_return and (
257
+ not isinstance(account_user, str) or not account_user.strip()
258
+ or sum(row.get("cmd") == "spotify" and row.get("user") == account_user for row in rows) != 1
259
+ ):
260
+ msg = "Native Spotify account is not positively matched"
261
+ raise AudioCommandError(msg)
262
+ if source_return:
263
+ native_path = "spotify:" + match[2] if match else path
264
+ provider = next((
265
+ (prefix, label) for prefix, label in (
266
+ ("spotify:", "Spotify"), ("library:local:", "Local Library"),
267
+ ("tunein:", "TuneIn"), ("loxoneradio:", "Loxone Radio"),
268
+ ) if native_path.startswith(prefix)
269
+ ), None)
270
+ if provider is not None:
271
+ prefix, label = provider
272
+ return lambda snapshot: bool(
273
+ snapshot.source == label and snapshot.audio_path and snapshot.audio_path.startswith(prefix)
274
+ and snapshot.audio_path.removeprefix(prefix).strip()
275
+ and (prefix != "spotify:" or snapshot.source_name == account_user)
276
+ )
277
+ return lambda snapshot: source_matches(snapshot, path, is_context=is_context, account_user=account_user)
278
+
279
+
280
+ async def async_select_catalogue( # noqa: PLR0913 - pin the pre-preparation native selection
281
+ commands: AudioServerCommands,
282
+ metadata: AudioMetadataCoordinator,
283
+ player_id: int,
284
+ target: CatalogueTarget,
285
+ *,
286
+ is_available: Callable[[], bool],
287
+ expected_native: AudioZoneMetadata | None = None,
288
+ source_return: bool = False,
289
+ on_confirmed: Callable[[AudioZoneMetadata], None] | None = None,
290
+ environment: SourceEnvironment | None = None,
291
+ ) -> None:
292
+ """Revalidate source ownership and share the existing no-blind-replay fence."""
293
+ from .catalogue import parse_line_inputs # noqa: PLC0415 - break catalogue/source import cycle
294
+
295
+ if target.kind not in ("item", "input") and not target.is_context:
296
+ msg = "Only a discovered playable source can be selected"
297
+ raise ValueError(msg)
298
+ if target.service == "linein" and not any(
299
+ item.folder == target.folder and item.audio_path == target.audio_path
300
+ for item in parse_line_inputs(await commands.async_source_inputs())
301
+ ):
302
+ msg = "Input changed or became unavailable; no command was sent"
303
+ raise AudioCommandError(msg)
304
+ observed_path = target.audio_path
305
+ if target.service == "spotify" and observed_path.startswith("spotify:"):
306
+ observed_path = "spotify@" + target.account + ":" + observed_path.removeprefix("spotify:")
307
+ # Validate the selected account and resolve its observed user together from
308
+ # one discovery snapshot, rather than issuing the same native read twice.
309
+ matches = await _source_matcher(
310
+ commands, observed_path, is_context=target.is_context,
311
+ expected_account=target.account if target.service == "spotify" else None, source_return=source_return,
312
+ )
313
+ content_matches = matches
314
+ if not source_return and target.parent_path is not None:
315
+ matches = lambda snapshot: content_matches(snapshot) and snapshot.parent_id == target.parent_path
316
+
317
+ async def send(guard: Callable[[], bool], sent: Callable[[], None]) -> None:
318
+ parent = (
319
+ {"parent_path": target.parent_path, "parent_index": target.parent_index}
320
+ if target.parent_path is not None else {}
321
+ )
322
+ await commands.async_play_catalogue(
323
+ player_id, target.service, target.account, target.folder, is_current=guard, on_send=sent, **parent,
324
+ )
325
+
326
+ observed = await (environment or SourceEnvironment()).async_observed_change(
327
+ commands, metadata, player_id, send, matches,
328
+ is_available=is_available, expected_native=expected_native,
329
+ on_confirmed=None if source_return else on_confirmed,
330
+ )
331
+ if source_return and on_confirmed is not None and is_available():
332
+ on_confirmed(observed or metadata.get(player_id))
333
+ saved = target.return_session
334
+ if not source_return or saved is None or observed is None:
335
+ return
336
+
337
+ def can_restore() -> bool:
338
+ snapshot = metadata.get(player_id)
339
+ return bool(
340
+ is_available() and snapshot is not None
341
+ and snapshot.player_id == player_id
342
+ and snapshot.event_idle and snapshot.event_active is False
343
+ and snapshot.native_mode == "play" and matches(snapshot)
344
+ and snapshot.audio_path == saved.audio_path
345
+ and snapshot.source_name == saved.source_name
346
+ and snapshot.duration is not None and saved.position is not None
347
+ and 0 < saved.position < snapshot.duration - 1
348
+ and snapshot.playback_restrictions is not None and snapshot.playback_restrictions & 32
349
+ and player_id not in commands.announcement_fences
350
+ and player_id not in commands.source_fences
351
+ )
352
+
353
+ if can_restore():
354
+ # An ordinary bounded native request, with a last current-target check
355
+ # after authentication. Its echo is not observed playback progress.
356
+ # Optional failure cannot undo already confirmed source playback.
357
+
358
+ try:
359
+ await commands.async_seek(
360
+ player_id, saved.position, is_current=can_restore,
361
+ )
362
+ except (AudioCommandError, ValueError, TimeoutError):
363
+ _LOGGER.warning("Optional audio position restore failed; confirmed source playback is retained")
364
+ if can_restore() and on_confirmed is not None:
365
+ on_confirmed(metadata.get(player_id))
366
+
367
+
368
+ async def async_select_favourite(
369
+ commands: AudioServerCommands,
370
+ metadata: AudioMetadataCoordinator,
371
+ favourite: AudioFavourite,
372
+ *,
373
+ is_available: Callable[[], bool],
374
+ expected_native: AudioZoneMetadata | None = None,
375
+ source_return: bool = False,
376
+ on_confirmed: Callable[[AudioZoneMetadata], None] | None = None,
377
+ environment: SourceEnvironment | None = None,
378
+ ) -> None:
379
+ """Revalidate an advertised choice, send once, and await exact source evidence."""
380
+ fresh = parse_room_favourites(await commands.async_room_favourites(favourite.player_id), favourite.player_id)
381
+ if favourite not in fresh:
382
+ message = "Configured favourite changed; refresh the source choices before selecting"
383
+ raise AudioCommandError(message)
384
+ if favourite.audio_path.startswith(("linein/", "linein:")):
385
+ from .catalogue import parse_line_inputs # noqa: PLC0415 - cycle boundary
386
+
387
+ expected = favourite.audio_path.replace("linein/", "linein:", 1)
388
+ if not any(item.audio_path == expected for item in parse_line_inputs(await commands.async_source_inputs())):
389
+ msg = "Favourite input became unavailable; no source command was sent"
390
+ raise AudioCommandError(msg)
391
+ matches = await _source_matcher(
392
+ commands, favourite.audio_path, is_context=favourite.is_context, source_return=source_return,
393
+ )
394
+
395
+ async def send(guard: Callable[[], bool], sent: Callable[[], None]) -> None:
396
+ await commands.async_play_room_favourite(
397
+ favourite.player_id, favourite.favourite_id, is_current=guard, on_send=sent
398
+ )
399
+
400
+ observed = await (environment or SourceEnvironment()).async_observed_change(
401
+ commands,
402
+ metadata,
403
+ favourite.player_id,
404
+ send,
405
+ matches,
406
+ is_available=is_available,
407
+ expected_native=expected_native,
408
+ on_confirmed=None if source_return else on_confirmed,
409
+ )
410
+ if source_return and on_confirmed is not None and is_available():
411
+ on_confirmed(observed or metadata.get(favourite.player_id))
412
+
413
+
414
+ async def async_select_radio_station( # noqa: PLR0913 - exact source and shared observation dependencies
415
+ commands: AudioServerCommands,
416
+ metadata: AudioMetadataCoordinator,
417
+ player_id: int,
418
+ service: str,
419
+ audio_path: str,
420
+ *,
421
+ is_available: Callable[[], bool],
422
+ expected_native: AudioZoneMetadata | None = None,
423
+ source_return: bool = False,
424
+ on_confirmed: Callable[[AudioZoneMetadata], None] | None = None,
425
+ environment: SourceEnvironment | None = None,
426
+ ) -> None:
427
+ """Share the existing source fence and require fresh exact-station feedback."""
428
+
429
+ async def send(guard: Callable[[], bool], sent: Callable[[], None]) -> None:
430
+ await commands.async_play_radio_station(player_id, service, audio_path, is_current=guard, on_send=sent)
431
+
432
+ matches = await _source_matcher(commands, audio_path, source_return=source_return)
433
+ observed = await (environment or SourceEnvironment()).async_observed_change(
434
+ commands,
435
+ metadata,
436
+ player_id,
437
+ send,
438
+ matches,
439
+ is_available=is_available,
440
+ expected_native=expected_native,
441
+ on_confirmed=None if source_return else on_confirmed,
442
+ )
443
+ if source_return and on_confirmed is not None and is_available():
444
+ on_confirmed(observed or metadata.get(player_id))
445
+
446
+
447
+ async def async_seek_track(
448
+ commands: AudioServerCommands,
449
+ metadata: AudioMetadataCoordinator,
450
+ player_id: int,
451
+ position: float,
452
+ *,
453
+ is_available: Callable[[], bool],
454
+ environment: SourceEnvironment | None = None,
455
+ ) -> AudioZoneMetadata:
456
+ """Require matching wire progress as well as the native position echo."""
457
+ expected = position
458
+
459
+ async def send(guard: Callable[[], bool], sent: Callable[[], None]) -> None:
460
+ nonlocal expected
461
+ echoed = await commands.async_seek(player_id, position, is_current=guard, on_send=sent)
462
+ if isinstance(echoed, bool) or not isinstance(echoed, (int, float)) or abs(echoed - position) > 1:
463
+ msg = "Invalid seek echo; no replay was sent"
464
+ raise AudioCommandError(msg)
465
+ expected = echoed
466
+
467
+ def matches(snapshot: AudioZoneMetadata) -> bool:
468
+ return bool(is_available() and snapshot.position is not None and abs(snapshot.position - expected) <= 1)
469
+
470
+ observed = await (environment or SourceEnvironment()).async_observed_change(
471
+ commands,
472
+ metadata,
473
+ player_id,
474
+ send,
475
+ matches,
476
+ is_available=is_available,
477
+ allow_noop=False,
478
+ retain_progress=True,
479
+
480
+ timeout=10,
481
+ )
482
+ snapshot = metadata.get(player_id)
483
+ if (
484
+ observed is None
485
+ or snapshot is None
486
+ or snapshot.position is None
487
+ or not is_available()
488
+ or snapshot.native_mode != "play"
489
+ ):
490
+ msg = "Seek progress was not observed"
491
+ raise AudioCommandError(msg)
492
+ return observed
493
+
494
+
495
+ async def async_native_catalogue_noop(
496
+ commands: AudioServerCommands,
497
+ metadata: AudioMetadataCoordinator,
498
+ player_id: int,
499
+ target: CatalogueTarget,
500
+ *,
501
+ is_available: Callable[[], bool],
502
+ resume_paused: bool = False,
503
+ environment: SourceEnvironment | None = None,
504
+ ) -> bool:
505
+ """Recognize an active source account, not its obsolete historical context."""
506
+ original = metadata.get(player_id)
507
+ if (
508
+ original is None or original.source != "Spotify"
509
+ or original.native_mode not in (("play", "pause") if resume_paused else ("play",))
510
+ or not original.audio_path or not original.audio_path.startswith(("spotify:track:", "spotify:episode:"))
511
+ ):
512
+ return False
513
+ rows = await commands.async_service_accounts()
514
+ if target.account not in parse_spotify_accounts(rows):
515
+ msg = "Spotify account changed; no source command was sent"
516
+ raise AudioCommandError(msg)
517
+ users = [
518
+ row.get("user")
519
+ for row in rows
520
+ if row.get("cmd") == "spotify" and row.get("id") == target.account and row.get("configerror") is False
521
+ ]
522
+ user = users[0] if len(users) == 1 else None
523
+ matched = (
524
+ isinstance(user, str)
525
+ and bool(user)
526
+ and not any(
527
+ row.get("cmd") == "spotify" and row.get("id") != target.account and row.get("user") == user for row in rows
528
+ )
529
+ )
530
+ current = metadata.get(player_id)
531
+ if not is_available() or not native_selection_unchanged(original, current):
532
+ msg = "Native no-op was superseded; no command was sent"
533
+ raise AudioCommandError(msg)
534
+ if matched and current.source_name != user:
535
+ # Optional provider loss cannot convert another native account's
536
+ # current playback into permission for a remembered-item fallback.
537
+ raise AudioCommandError("Native Spotify account does not match the selected source")
538
+ qualified = bool(
539
+ matched
540
+ and current.player_id == player_id
541
+ and current.event_idle
542
+ and current.event_active is False
543
+ and current.source_name == user
544
+ )
545
+ if qualified and current.native_mode == "pause":
546
+ await (environment or SourceEnvironment()).async_observed_change(
547
+ commands, metadata, player_id,
548
+ lambda available, sent: commands.async_spotify_handoff(
549
+ player_id, target.account, is_current=available, on_send=sent,
550
+ ),
551
+ lambda row: (
552
+ row.native_mode == "play" and row.player_id == player_id
553
+ and row.event_idle and row.event_active is False
554
+ and row.source == "Spotify" and row.source_name == user
555
+ and bool(row.audio_path and row.audio_path.startswith(("spotify:track:", "spotify:episode:")))
556
+ ),
557
+ is_available=is_available, allow_noop=False, expected_native=original,
558
+ )
559
+ return qualified
560
+
561
+
562
+ async def async_observed_change( # noqa: C901, PLR0913, PLR0915 - explicit send/observation/fence ownership
563
+ commands: AudioServerCommands,
564
+ metadata: AudioMetadataCoordinator,
565
+ player_id: int,
566
+ send: Callable[[Callable[[], bool], Callable[[], None]], Awaitable[None]],
567
+ matches: Callable[[AudioZoneMetadata], bool],
568
+ *,
569
+ is_available: Callable[[], bool],
570
+ allow_noop: bool = True,
571
+ retain_progress: bool = False,
572
+ expected_native: AudioZoneMetadata | None = None,
573
+ on_confirmed: Callable[[AudioZoneMetadata], None] | None = None,
574
+
575
+ timeout: float = 15, # noqa: ASYNC109 - bounded native feedback deadline, not a public HA action
576
+ clock: Callable[[], datetime] = dt_util.utcnow,
577
+ ) -> AudioZoneMetadata | None:
578
+ """Retain uncertain writes until exact fresh target feedback or client close."""
579
+ if player_id in (fences := commands.source_fences) or player_id in commands.announcement_fences:
580
+ message = "Another player action is active or uncertain; no content was sent"
581
+ raise AudioCommandError(message)
582
+
583
+ def selection_current() -> bool:
584
+ snapshot = metadata.get(player_id)
585
+ return bool(
586
+ snapshot is not None
587
+ and snapshot.player_id == player_id
588
+ and (expected_native is None or native_selection_unchanged(expected_native, snapshot))
589
+ )
590
+
591
+ if not selection_current() or not is_available():
592
+ msg = "Native source selection was superseded before dispatch"
593
+ raise AudioCommandError(msg)
594
+
595
+ def target_playing() -> bool:
596
+ return bool(
597
+ (snapshot := metadata.get(player_id))
598
+ and snapshot.event_active is False
599
+ and snapshot.event_idle
600
+ and snapshot.native_mode == "play"
601
+ and matches(snapshot)
602
+ )
603
+
604
+ if allow_noop and is_available() and target_playing():
605
+
606
+ if on_confirmed is not None:
607
+ on_confirmed(metadata.get(player_id))
608
+ return None
609
+ finished = asyncio.get_running_loop().create_future()
610
+ finished.add_done_callback(lambda future: None if future.cancelled() else future.exception())
611
+ dispatched = completed = send_settled = False
612
+ waiter_active = True
613
+ settlement_sample = object()
614
+ dispatched_at = None
615
+ observed = None
616
+ observation = asyncio.get_running_loop().create_future()
617
+ observations = getattr(commands, "source_observations", None)
618
+ if not isinstance(observations, dict):
619
+ observations = commands.source_observations = {}
620
+ observations[player_id] = observation
621
+
622
+ def release() -> None:
623
+ remove()
624
+ if observations.get(player_id) is observation:
625
+ observations.pop(player_id)
626
+ if not observation.done():
627
+ observation.cancel()
628
+ if fences.get(player_id) is release:
629
+ fences.pop(player_id)
630
+ if not finished.done():
631
+ finished.cancel()
632
+
633
+ def updated() -> None:
634
+ nonlocal completed, observed
635
+ snapshot = metadata.get(player_id)
636
+ valid = bool(
637
+ snapshot
638
+ and snapshot.event_active is False
639
+ and snapshot.event_idle
640
+ and snapshot.native_mode == "play"
641
+ # A terminal action no longer owns an entity generation. Keep its
642
+ # exact-target settlement observer, not its obsolete dispatch guard:
643
+ # a refused later request must not make this fence permanent.
644
+ and (not waiter_active or is_available())
645
+ and player_id not in commands.announcement_fences
646
+ and dispatched_at is not None
647
+ and snapshot.received_at is not None
648
+ and dispatched_at <= snapshot.received_at <= clock()
649
+ )
650
+ if (
651
+ dispatched
652
+ and snapshot
653
+ and snapshot.received_at is not None
654
+ and snapshot.received_at is not settlement_sample
655
+ and dispatched_at is not None
656
+ and dispatched_at <= snapshot.received_at <= clock()
657
+ ):
658
+ if valid and matches(snapshot):
659
+ if not (retain_progress and observed is not None and snapshot.position == 0):
660
+ observed = snapshot
661
+ # Feedback can precede a rejection or transport failure.
662
+ # Publish successful history only after the send settles.
663
+ if send_settled and waiter_active and on_confirmed is not None:
664
+ on_confirmed(snapshot)
665
+ if not observation.done():
666
+ observation.set_result(None)
667
+ elif retain_progress and valid and observed is not None:
668
+ # Once qualified, retain genuine positive progress, not a later
669
+ # passive zero placeholder (including after a zero-target seek).
670
+ if snapshot.position is not None and snapshot.position > 0:
671
+ observed = snapshot
672
+ elif not (retain_progress and valid):
673
+ observed = None
674
+ if send_settled and observed and valid:
675
+ completed = True
676
+ if not finished.done():
677
+ finished.set_result(None)
678
+ # Seek progress can advance before the awakened waiter resumes.
679
+ # Keep observing until it consumes the qualified clock snapshot.
680
+ if not retain_progress or not waiter_active:
681
+ release()
682
+ elif (snapshot is None or snapshot.event_active is not False) and not finished.done():
683
+ finished.set_exception(AudioCommandError("Source event stream lost or preempted; no replay was sent"))
684
+
685
+ def is_current() -> bool:
686
+ return bool(
687
+ not finished.done()
688
+ and is_available()
689
+ and (dispatched or selection_current())
690
+ and (snapshot := metadata.get(player_id)) is not None
691
+ and snapshot.event_active is False
692
+ and snapshot.event_idle
693
+ and player_id not in commands.announcement_fences
694
+ and (not allow_noop or not target_playing())
695
+ )
696
+
697
+ def on_send() -> None:
698
+ nonlocal dispatched, settlement_sample, dispatched_at
699
+ snapshot = metadata.get(player_id)
700
+ settlement_sample = snapshot.received_at if snapshot else None
701
+ dispatched_at = clock()
702
+ dispatched = True
703
+
704
+
705
+ remove = metadata.async_add_listener(player_id, updated)
706
+
707
+ fences[player_id] = release
708
+ try:
709
+ if not is_current():
710
+ message = "Source state is unavailable or active; no content was sent"
711
+ raise AudioCommandError(message)
712
+ await send(is_current, on_send)
713
+ # Native source/progress events can precede the command echo. Require
714
+ # fresh post-SEND evidence plus successful settlement, in either order.
715
+ send_settled = True
716
+
717
+ if not finished.done():
718
+ updated()
719
+ async with asyncio.timeout(timeout):
720
+ await asyncio.shield(finished)
721
+
722
+ return observed
723
+ finally:
724
+ # Failed/cancelled writes cannot consume observations that arrived while
725
+ # the write was unresolved. Only later feedback or client close can release.
726
+ waiter_active = False
727
+ send_settled = True
728
+ if not dispatched or completed:
729
+ release()
730
+ else:
731
+
732
+ observed = None
733
+ # Receiver timer callbacks retain the exact wire sample timestamp
734
+ # object; they are not new feedback after uncertain settlement.
735
+ settlement_sample = snapshot.received_at if (snapshot := metadata.get(player_id)) else None
736
+
737
+
738
+ # Historical private import retained for source-compatible offline consumers.
739
+ _async_observed_change = async_observed_change