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.
- pyloxone_audio/__init__.py +90 -0
- pyloxone_audio/announcement.py +101 -0
- pyloxone_audio/catalogue.py +1118 -0
- pyloxone_audio/clock.py +7 -0
- pyloxone_audio/metadata.py +98 -0
- pyloxone_audio/models.py +48 -0
- pyloxone_audio/native.py +1136 -0
- pyloxone_audio/py.typed +0 -0
- pyloxone_audio/radio.py +211 -0
- pyloxone_audio/selection.py +194 -0
- pyloxone_audio/sources.py +739 -0
- pyloxone_audio/spotify.py +88 -0
- pyloxone_audio-0.1.0.dist-info/METADATA +138 -0
- pyloxone_audio-0.1.0.dist-info/RECORD +18 -0
- pyloxone_audio-0.1.0.dist-info/WHEEL +5 -0
- pyloxone_audio-0.1.0.dist-info/licenses/LICENSE +201 -0
- pyloxone_audio-0.1.0.dist-info/licenses/NOTICE.md +15 -0
- pyloxone_audio-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|