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,1118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Native sources and indexed search on existing opted-in AudioZoneV2 players.
|
|
3
|
+
|
|
4
|
+
The legacy radio content type/opaque IDs remain accepted. Nothing here starts
|
|
5
|
+
playback, scans storage, creates favourites or performs Spotify session import.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, replace
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from aiohttp import ClientError
|
|
18
|
+
from .models import MediaNode as BrowseMedia, SearchResult as SearchMedia, MediaKind as MediaClass
|
|
19
|
+
|
|
20
|
+
from .radio import _FAVOURITES, _ROOT, RadioCatalogue, RadioTarget, _text
|
|
21
|
+
from .sources import parse_spotify_accounts, source_matches
|
|
22
|
+
|
|
23
|
+
from .native import AudioCommandError
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .native import AudioServerCommands, AudioZoneMetadata
|
|
27
|
+
|
|
28
|
+
_RADIO = "pyloxone-audio:radio"
|
|
29
|
+
_INPUTS = "pyloxone-audio:inputs"
|
|
30
|
+
_LINE_IN_ARTWORK = "pyloxone-artwork:line-in"
|
|
31
|
+
_LIBRARY_ARTWORK = "pyloxone-artwork:library"
|
|
32
|
+
BROWSE_ARTWORK = {
|
|
33
|
+
_LINE_IN_ARTWORK: (
|
|
34
|
+
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160">'
|
|
35
|
+
b'<rect width="160" height="160" rx="20" fill="#183341"/>'
|
|
36
|
+
b'<g transform="rotate(40 80 80)" fill="#d8f4ff"><rect x="70" y="17" width="20" height="65" rx="6"/>'
|
|
37
|
+
b'<path d="M66 85h28v25a14 14 0 0 1-28 0z"/><path d="M76 115h8v30h-8z"/></g>'
|
|
38
|
+
b'<path d="m99 34 13 11m-22 0 13 11" stroke="#183341" stroke-width="5"/></svg>'
|
|
39
|
+
),
|
|
40
|
+
_LIBRARY_ARTWORK: (
|
|
41
|
+
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160">'
|
|
42
|
+
b'<rect width="160" height="160" rx="20" fill="#183b34"/>'
|
|
43
|
+
b'<path d="M24 45h43l13 14h56v65H24z" fill="#90d5ba"/>'
|
|
44
|
+
b'<path d="M91 72v33a9 9 0 1 1-6-9V78l27-6v26a9 9 0 1 1-6-9V68z" fill="#183b34"/></svg>'
|
|
45
|
+
),
|
|
46
|
+
}
|
|
47
|
+
_MAX_INPUTS = 128
|
|
48
|
+
_NATIVE_LINE_IN = 6
|
|
49
|
+
_NATIVE_PLAYLIST = 11
|
|
50
|
+
_MAX_NATIVE_TOTAL = 1_000_000
|
|
51
|
+
_PAGE_SIZE = 50
|
|
52
|
+
_MAX_RADIO_ITEMS = 256
|
|
53
|
+
_MAX_PROVIDERS = 16
|
|
54
|
+
_MAX_CAPABILITIES = 32
|
|
55
|
+
_MAX_SEARCH_ITEMS = 1000
|
|
56
|
+
_MAX_COVER_URL_LENGTH = 4096
|
|
57
|
+
_CONTROL_CHAR_BOUNDARY = 32
|
|
58
|
+
_MAX_RECENT_ITEMS = 256
|
|
59
|
+
_LOGGER = logging.getLogger(__name__)
|
|
60
|
+
_CATEGORIES = {
|
|
61
|
+
"local": ("track", "album", "artist", "playlist", "folder"),
|
|
62
|
+
"spotify": ("track", "album", "artist", "playlist", "episode", "show"),
|
|
63
|
+
"tunein": ("station", "custom"),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class CatalogueTarget(RadioTarget):
|
|
69
|
+
"""Account and context ownership accompany each opaque discovered item."""
|
|
70
|
+
|
|
71
|
+
account: str = "nouser"
|
|
72
|
+
is_context: bool = False
|
|
73
|
+
thumbnail: str | None = None
|
|
74
|
+
playlist_group: str | None = None
|
|
75
|
+
group_items: tuple[RadioTarget, ...] = ()
|
|
76
|
+
parent_path: str | None = None
|
|
77
|
+
parent_index: int | None = None
|
|
78
|
+
return_session: AudioZoneMetadata | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class ConfiguredSource:
|
|
83
|
+
"""Provider/account/input presence, never an implicit content selection."""
|
|
84
|
+
|
|
85
|
+
service: str
|
|
86
|
+
account: str = ""
|
|
87
|
+
input_id: str = ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _cover_url(row: dict) -> str | None:
|
|
91
|
+
"""Keep supplied HTTP artwork, never active/data/credential-bearing URLs."""
|
|
92
|
+
for field in ("thumbnail", "coverurl"):
|
|
93
|
+
value = row.get(field)
|
|
94
|
+
if (
|
|
95
|
+
not isinstance(value, str)
|
|
96
|
+
or not value
|
|
97
|
+
or len(value) > _MAX_COVER_URL_LENGTH
|
|
98
|
+
or any(ord(c) < _CONTROL_CHAR_BOUNDARY for c in value)
|
|
99
|
+
):
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
parsed = urlsplit(value)
|
|
103
|
+
if (
|
|
104
|
+
(
|
|
105
|
+
(parsed.scheme in ("http", "https") and parsed.hostname)
|
|
106
|
+
or (not parsed.scheme and value.startswith("/"))
|
|
107
|
+
)
|
|
108
|
+
and parsed.username is None
|
|
109
|
+
and parsed.password is None
|
|
110
|
+
):
|
|
111
|
+
return value
|
|
112
|
+
except ValueError:
|
|
113
|
+
continue
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_line_inputs(payload: object) -> tuple[CatalogueTarget, ...]:
|
|
118
|
+
"""Require a complete exact native input ID, never a bare linein command."""
|
|
119
|
+
if not isinstance(payload, list) or len(payload) > _MAX_INPUTS:
|
|
120
|
+
msg = "Native input discovery is invalid or exceeds its bound"
|
|
121
|
+
raise ValueError(msg)
|
|
122
|
+
result, seen = [], set()
|
|
123
|
+
for row in payload:
|
|
124
|
+
if not isinstance(row, dict) or row.get("type") != _NATIVE_LINE_IN:
|
|
125
|
+
msg = "Native input discovery contains an invalid input"
|
|
126
|
+
raise ValueError(msg)
|
|
127
|
+
identifier = _text(row.get("id"), 256)
|
|
128
|
+
if identifier in seen or row.get("cmd") != "linein/" + identifier:
|
|
129
|
+
msg = "Native input identity is ambiguous"
|
|
130
|
+
raise ValueError(msg)
|
|
131
|
+
seen.add(identifier)
|
|
132
|
+
if row.get("valid", True) is False or row.get("power", "on") != "on":
|
|
133
|
+
continue
|
|
134
|
+
if row.get("valid", True) is not True:
|
|
135
|
+
msg = "Native input validity is unknown"
|
|
136
|
+
raise ValueError(msg)
|
|
137
|
+
result.append(
|
|
138
|
+
CatalogueTarget(
|
|
139
|
+
"input", _text(row.get("name"), 256), "linein", identifier, audio_path="linein:" + identifier
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return tuple(result)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class AudioCatalogue(RadioCatalogue):
|
|
146
|
+
"""Reuse bounded per-room selections and existing radio discovery."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, commands: AudioServerCommands, player_id: int) -> None:
|
|
149
|
+
"""Retain the same client, opt-in boundary and room-local ID cache."""
|
|
150
|
+
super().__init__(commands, player_id)
|
|
151
|
+
self.source_choices: dict[str, RadioTarget] = {}
|
|
152
|
+
self.source_descriptors: dict[str, ConfiguredSource] = {}
|
|
153
|
+
self._source_inventory_revision = 0
|
|
154
|
+
self.source_inventory_complete = False
|
|
155
|
+
self._source_refresh_generation = 0
|
|
156
|
+
self._source_content_revision = 0
|
|
157
|
+
self._source_choice_versions: dict[str, int] = {}
|
|
158
|
+
self._source_accounts: list[dict] = []
|
|
159
|
+
self._source_inputs: tuple[CatalogueTarget, ...] = ()
|
|
160
|
+
self.source_sessions: dict[ConfiguredSource, AudioZoneMetadata] = {}
|
|
161
|
+
self._unavailable_source_sessions: set[ConfiguredSource] = set()
|
|
162
|
+
self._session_labels: dict[str, ConfiguredSource] = {}
|
|
163
|
+
|
|
164
|
+
def source_session_known(self, source: str) -> bool:
|
|
165
|
+
descriptor = self.source_descriptors.get(source)
|
|
166
|
+
return descriptor in self.source_sessions or descriptor in self._unavailable_source_sessions
|
|
167
|
+
|
|
168
|
+
def source_session(self, source: str) -> AudioZoneMetadata | None:
|
|
169
|
+
"""Resolve captured sessions by configured identity, never display label."""
|
|
170
|
+
descriptor = self.source_descriptors.get(source)
|
|
171
|
+
return self.source_sessions.get(descriptor) if descriptor is not None else None
|
|
172
|
+
|
|
173
|
+
def _source_label( # noqa: PLR0911 - explicit provider namespaces
|
|
174
|
+
self, target: RadioTarget, *, accounts: list[dict] | None = None,
|
|
175
|
+
inputs: tuple[CatalogueTarget, ...] | None = None,
|
|
176
|
+
) -> str | None:
|
|
177
|
+
accounts = self._source_accounts if accounts is None else accounts
|
|
178
|
+
inputs = self._source_inputs if inputs is None else inputs
|
|
179
|
+
if target.audio_path.startswith("tunein:"):
|
|
180
|
+
return "TuneIn"
|
|
181
|
+
if target.audio_path.startswith("loxoneradio:"):
|
|
182
|
+
return "Loxone Radio"
|
|
183
|
+
if target.service in ("library", "playlist"):
|
|
184
|
+
return "Local Library"
|
|
185
|
+
if target.service == "linein":
|
|
186
|
+
if not any(item.folder == target.folder for item in inputs):
|
|
187
|
+
return None
|
|
188
|
+
return "Line-In" if len(inputs) == 1 else "Line-In · " + target.label + " · " + target.folder
|
|
189
|
+
if isinstance(target, CatalogueTarget) and target.service == "spotify":
|
|
190
|
+
if target.account not in [account["id"] for account in accounts]:
|
|
191
|
+
return None
|
|
192
|
+
if len(accounts) == 1:
|
|
193
|
+
return "Spotify"
|
|
194
|
+
account = next((a for a in accounts if a["id"] == target.account), None)
|
|
195
|
+
if account:
|
|
196
|
+
return "Spotify · " + _text(account.get("name") or account["id"], 256) + " · " + account["id"]
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
def _recent_target( # noqa: PLR0911 - reject unknown or unscoped native paths
|
|
200
|
+
self, row: dict, *, accounts: list[dict] | None = None,
|
|
201
|
+
inputs: tuple[CatalogueTarget, ...] | None = None,
|
|
202
|
+
) -> RadioTarget | None:
|
|
203
|
+
accounts = self._source_accounts if accounts is None else accounts
|
|
204
|
+
inputs = self._source_inputs if inputs is None else inputs
|
|
205
|
+
path = row.get("audiopath")
|
|
206
|
+
if not isinstance(path, str) or not path or row.get("title", row.get("name")) == ".private":
|
|
207
|
+
return None
|
|
208
|
+
path = _text(path)
|
|
209
|
+
label = _text(row.get("title") or row.get("name") or "Last selection", 256)
|
|
210
|
+
if path.startswith(("linein:", "linein/")):
|
|
211
|
+
return next(
|
|
212
|
+
(item for item in inputs if item.audio_path == path.replace("linein/", "linein:", 1)), None
|
|
213
|
+
)
|
|
214
|
+
if path.startswith(("tunein:", "loxoneradio:")):
|
|
215
|
+
if not path.partition(":")[2].strip():
|
|
216
|
+
return None
|
|
217
|
+
service = (
|
|
218
|
+
"loxoneradio"
|
|
219
|
+
if path.startswith("loxoneradio:")
|
|
220
|
+
else "custom"
|
|
221
|
+
if path.startswith("tunein:custom:")
|
|
222
|
+
else "world"
|
|
223
|
+
)
|
|
224
|
+
return RadioTarget("station", label, service, audio_path=path)
|
|
225
|
+
if match := re.fullmatch(r"spotify@([A-Za-z0-9_.@+-]{1,128}):.+", path):
|
|
226
|
+
if match[1] not in [a["id"] for a in accounts]:
|
|
227
|
+
return None
|
|
228
|
+
return self._parse_source_item({**row, "id": path, "name": label}, "spotify", match[1])
|
|
229
|
+
if path.startswith("library:local:"):
|
|
230
|
+
return self._parse_source_item({**row, "id": path, "name": label}, "library")
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def _source_root_available(self, payload: object, service: str, account: str) -> bool:
|
|
234
|
+
page = self._page(payload, 0, library_root=service == "library")
|
|
235
|
+
rows = [row for row in page["items"] if not isinstance(row, dict) or row.get("name") != ".private"]
|
|
236
|
+
items = [self._parse_source_item(row, service, account) for row in rows]
|
|
237
|
+
if rows and not any(items):
|
|
238
|
+
message = "Source root has no supported visible content"
|
|
239
|
+
raise ValueError(message)
|
|
240
|
+
if service == "loxoneradio" and any(
|
|
241
|
+
item is not None and item.audio_path and not item.audio_path.startswith("loxoneradio:")
|
|
242
|
+
for item in items
|
|
243
|
+
):
|
|
244
|
+
message = "Loxone Radio root contains another provider"
|
|
245
|
+
raise ValueError(message)
|
|
246
|
+
return any(items)
|
|
247
|
+
|
|
248
|
+
async def _async_source_inventory(
|
|
249
|
+
self,
|
|
250
|
+
) -> tuple[list[dict], tuple[CatalogueTarget, ...], dict[str, ConfiguredSource], bool]:
|
|
251
|
+
"""Collect one local inventory without publishing partial shared state."""
|
|
252
|
+
accounts = await self._accounts()
|
|
253
|
+
inputs = parse_line_inputs(await self.commands.async_source_inputs())
|
|
254
|
+
descriptors = {}
|
|
255
|
+
for account in accounts:
|
|
256
|
+
target = CatalogueTarget("folder", "", "spotify", account=account["id"])
|
|
257
|
+
descriptors[self._source_label(target, accounts=accounts, inputs=inputs)] = ConfiguredSource(
|
|
258
|
+
"spotify", account["id"]
|
|
259
|
+
)
|
|
260
|
+
for target in inputs:
|
|
261
|
+
descriptors[self._source_label(target, accounts=accounts, inputs=inputs)] = ConfiguredSource(
|
|
262
|
+
"linein", input_id=target.folder
|
|
263
|
+
)
|
|
264
|
+
complete = True
|
|
265
|
+
# Search categories describe searchability, not configured presence.
|
|
266
|
+
# Reuse the bounded native radio-provider parser and catalogue reads.
|
|
267
|
+
if getattr(self.commands, "async_radio_services", None) is not None:
|
|
268
|
+
try:
|
|
269
|
+
radio = await super().async_browse()
|
|
270
|
+
except (AudioCommandError, ValueError, TimeoutError):
|
|
271
|
+
complete = False
|
|
272
|
+
_LOGGER.debug("Configured source discovery: radio inventory unavailable")
|
|
273
|
+
else:
|
|
274
|
+
if any(
|
|
275
|
+
self.resolve(item.media_content_id).service in ("local", "music", "world", "custom")
|
|
276
|
+
for item in radio.children[1:]
|
|
277
|
+
):
|
|
278
|
+
descriptors["TuneIn"] = ConfiguredSource("tunein")
|
|
279
|
+
# A non-empty, validated root proves available content, not a selected
|
|
280
|
+
# default. Successful empty roots are distinct from failed discovery.
|
|
281
|
+
folder = getattr(self.commands, "async_media_folder", None)
|
|
282
|
+
if folder is not None:
|
|
283
|
+
for service, account, root, label in (
|
|
284
|
+
("library", "nouser", "0", "Local Library"),
|
|
285
|
+
("loxoneradio", "noUser", "start", "Loxone Radio"),
|
|
286
|
+
):
|
|
287
|
+
try:
|
|
288
|
+
available = self._source_root_available(await folder(service, account, root, 0), service, account)
|
|
289
|
+
except (AudioCommandError, ValueError, TimeoutError):
|
|
290
|
+
complete = False
|
|
291
|
+
_LOGGER.debug("Configured source discovery: %s catalogue unavailable", label)
|
|
292
|
+
else:
|
|
293
|
+
if available:
|
|
294
|
+
descriptors[label] = ConfiguredSource(service)
|
|
295
|
+
return accounts, inputs, descriptors, complete
|
|
296
|
+
|
|
297
|
+
async def async_refresh_sources(self) -> None:
|
|
298
|
+
"""Discover presence independently; history supplies optional content only."""
|
|
299
|
+
self._source_refresh_generation += 1
|
|
300
|
+
generation = self._source_refresh_generation
|
|
301
|
+
content_revision = self._source_content_revision
|
|
302
|
+
|
|
303
|
+
accounts, inputs, descriptors, complete = await self._async_source_inventory()
|
|
304
|
+
if generation != self._source_refresh_generation:
|
|
305
|
+
message = "Source inventory refresh was superseded"
|
|
306
|
+
raise AudioCommandError(message)
|
|
307
|
+
# A display label rebound to a replacement account is not a fresh
|
|
308
|
+
# default selection. Require a new qualified sample for that identity.
|
|
309
|
+
known = set(self.source_sessions) | self._unavailable_source_sessions
|
|
310
|
+
self._session_labels.update({
|
|
311
|
+
label: identity for label, identity in self.source_descriptors.items() if identity in known
|
|
312
|
+
})
|
|
313
|
+
# Keep the last known association across an empty/removed inventory.
|
|
314
|
+
for label, old_identity in self._session_labels.items():
|
|
315
|
+
new_identity = descriptors.get(label)
|
|
316
|
+
if old_identity in known and new_identity is not None and new_identity != old_identity:
|
|
317
|
+
self._unavailable_source_sessions.add(new_identity)
|
|
318
|
+
self._source_accounts, self._source_inputs = accounts, inputs
|
|
319
|
+
self.source_inventory_complete = complete
|
|
320
|
+
self.source_descriptors = dict(sorted(
|
|
321
|
+
descriptors.items(), key=lambda item: (item[1].service == "linein", item[0])
|
|
322
|
+
))
|
|
323
|
+
self.source_choices = {
|
|
324
|
+
label:target for label,target in self.source_choices.items()
|
|
325
|
+
if label in descriptors and self._source_label(target, accounts=accounts, inputs=inputs) == label
|
|
326
|
+
}
|
|
327
|
+
self._source_inventory_revision += 1
|
|
328
|
+
|
|
329
|
+
recent = await self.commands.async_recent_sources(self.player_id)
|
|
330
|
+
|
|
331
|
+
if generation != self._source_refresh_generation:
|
|
332
|
+
message = "Source history refresh was superseded"
|
|
333
|
+
raise AudioCommandError(message)
|
|
334
|
+
if (
|
|
335
|
+
not isinstance(recent, dict)
|
|
336
|
+
or not isinstance(recent.get("items"), list)
|
|
337
|
+
or len(recent["items"]) > _MAX_RECENT_ITEMS
|
|
338
|
+
or any(not isinstance(row, dict) for row in recent["items"])
|
|
339
|
+
):
|
|
340
|
+
message = "Native recent sources are unavailable or exceed their bound"
|
|
341
|
+
raise ValueError(message)
|
|
342
|
+
choices = {}
|
|
343
|
+
for row in recent["items"]:
|
|
344
|
+
target = self._recent_target(row, accounts=accounts, inputs=inputs)
|
|
345
|
+
if (
|
|
346
|
+
target is not None
|
|
347
|
+
and (label := self._source_label(target, accounts=accounts, inputs=inputs)) is not None
|
|
348
|
+
):
|
|
349
|
+
choices.setdefault(label, target)
|
|
350
|
+
for target in inputs:
|
|
351
|
+
choices[self._source_label(target, accounts=accounts, inputs=inputs)] = target
|
|
352
|
+
choices.update({
|
|
353
|
+
label: target for label,target in self.source_choices.items()
|
|
354
|
+
if label in descriptors and label in self._source_choice_versions
|
|
355
|
+
})
|
|
356
|
+
self.source_choices = dict(sorted(choices.items()))
|
|
357
|
+
|
|
358
|
+
def remember_source(
|
|
359
|
+
self, snapshot: AudioZoneMetadata | None, *, intent: RadioTarget | None = None, accept: bool = True,
|
|
360
|
+
position_confirmed: bool = False,
|
|
361
|
+
) -> str | None:
|
|
362
|
+
"""Keep last successful playback separately from the current intent."""
|
|
363
|
+
if not accept or snapshot is None or snapshot.source not in (
|
|
364
|
+
"Local Library", "Spotify", "TuneIn", "Loxone Radio", "Line-In",
|
|
365
|
+
):
|
|
366
|
+
return None
|
|
367
|
+
if snapshot.source in ("Local Library", "Spotify"):
|
|
368
|
+
try:
|
|
369
|
+
path = _text(snapshot.audio_path)
|
|
370
|
+
except ValueError:
|
|
371
|
+
return None
|
|
372
|
+
prefixes = (
|
|
373
|
+
("spotify:track:", "spotify:episode:")
|
|
374
|
+
if snapshot.source == "Spotify" else ("library:local:track:",)
|
|
375
|
+
)
|
|
376
|
+
if not any(path.startswith(prefix) and path[len(prefix):].strip() for prefix in prefixes):
|
|
377
|
+
return None
|
|
378
|
+
if intent is not None:
|
|
379
|
+
favourite = intent.favourite
|
|
380
|
+
path = favourite.audio_path if favourite is not None else intent.audio_path
|
|
381
|
+
is_context = favourite.is_context if favourite is not None else getattr(intent, "is_context", False)
|
|
382
|
+
# Reuse the same validated path/account parser as other native
|
|
383
|
+
# selections; a favourite carries its account in its exact path.
|
|
384
|
+
qualified = (
|
|
385
|
+
self._recent_target({"audiopath": path, "type": 7 if is_context else 2})
|
|
386
|
+
if favourite is not None and path.startswith("spotify@") else intent
|
|
387
|
+
)
|
|
388
|
+
if qualified is None:
|
|
389
|
+
return None
|
|
390
|
+
users = {row.get("user") for row in self._source_accounts if row.get("id") == getattr(qualified, "account", None)}
|
|
391
|
+
user = next(iter(users)) if len(users) == 1 else None
|
|
392
|
+
if not path or not source_matches(snapshot, path, is_context=is_context, account_user=user):
|
|
393
|
+
return None
|
|
394
|
+
if getattr(intent, "parent_path", None) is not None and snapshot.parent_id != intent.parent_path:
|
|
395
|
+
return None
|
|
396
|
+
if (
|
|
397
|
+
snapshot.player_id == self.player_id and snapshot.event_idle
|
|
398
|
+
and snapshot.event_active is False and snapshot.source in ("Local Library", "Spotify")
|
|
399
|
+
and snapshot.audio_path is not None
|
|
400
|
+
and snapshot.audio_path.startswith(
|
|
401
|
+
("library:local:track:", "spotify:track:", "spotify:episode:")
|
|
402
|
+
)
|
|
403
|
+
):
|
|
404
|
+
identity = None
|
|
405
|
+
if snapshot.source == "Local Library":
|
|
406
|
+
identity = ConfiguredSource("library")
|
|
407
|
+
else:
|
|
408
|
+
accounts = [a for a in self._source_accounts if a.get("user") == snapshot.source_name]
|
|
409
|
+
if len(accounts) == 1:
|
|
410
|
+
identity = ConfiguredSource("spotify", accounts[0]["id"])
|
|
411
|
+
if identity is not None:
|
|
412
|
+
previous = self.source_sessions.get(identity)
|
|
413
|
+
newer = (
|
|
414
|
+
previous is None or previous.received_at is None
|
|
415
|
+
or (snapshot.received_at is not None and snapshot.received_at > previous.received_at)
|
|
416
|
+
)
|
|
417
|
+
if not newer:
|
|
418
|
+
return None
|
|
419
|
+
same_item = (
|
|
420
|
+
previous is not None
|
|
421
|
+
and snapshot.audio_path == previous.audio_path
|
|
422
|
+
and snapshot.source_name == previous.source_name
|
|
423
|
+
and snapshot.parent_id == previous.parent_id
|
|
424
|
+
)
|
|
425
|
+
paused_enrichment = snapshot.native_mode == "pause" and same_item
|
|
426
|
+
if newer and (snapshot.native_mode == "play" or paused_enrichment):
|
|
427
|
+
if same_item and (snapshot.position is None or (snapshot.position == 0 and not position_confirmed)):
|
|
428
|
+
# Missing/zero passive offsets cannot erase a confirmed
|
|
429
|
+
# clock, even across mixed placeholder sequences. The
|
|
430
|
+
# actual observed explicit seek may confirm genuine zero.
|
|
431
|
+
snapshot = replace(
|
|
432
|
+
snapshot, position=previous.position,
|
|
433
|
+
position_updated_at=previous.position_updated_at,
|
|
434
|
+
)
|
|
435
|
+
self.source_sessions[identity] = snapshot
|
|
436
|
+
self._unavailable_source_sessions.discard(identity)
|
|
437
|
+
if (
|
|
438
|
+
snapshot is None
|
|
439
|
+
or snapshot.player_id != self.player_id
|
|
440
|
+
or not snapshot.event_idle
|
|
441
|
+
or snapshot.event_active is not False
|
|
442
|
+
or snapshot.native_mode != "play"
|
|
443
|
+
):
|
|
444
|
+
return None
|
|
445
|
+
path = snapshot.parent_id or snapshot.audio_path
|
|
446
|
+
if not path:
|
|
447
|
+
return None
|
|
448
|
+
if path.startswith("spotify:"):
|
|
449
|
+
accounts = [a for a in self._source_accounts if a.get("user") == snapshot.source_name]
|
|
450
|
+
if len(accounts) != 1:
|
|
451
|
+
return None
|
|
452
|
+
path = "spotify@" + accounts[0]["id"] + ":" + path.removeprefix("spotify:")
|
|
453
|
+
context = bool(snapshot.parent_id) or any(
|
|
454
|
+
mark in path for mark in (":playlist:", ":album:", ":artist:", ":folder:", ":user:collection", ":show:")
|
|
455
|
+
)
|
|
456
|
+
target = self._recent_target(
|
|
457
|
+
{
|
|
458
|
+
"audiopath": path,
|
|
459
|
+
"type": 7 if context else 2,
|
|
460
|
+
"title": snapshot.title or snapshot.source or "Last selection",
|
|
461
|
+
"coverurl": snapshot.cover_url,
|
|
462
|
+
}
|
|
463
|
+
)
|
|
464
|
+
if target is not None and (label := self._source_label(target)) is not None:
|
|
465
|
+
if snapshot.source not in ("Local Library", "Spotify"):
|
|
466
|
+
descriptor = self.source_descriptors.get(label)
|
|
467
|
+
previous = self.source_sessions.get(descriptor)
|
|
468
|
+
if previous is not None and previous.received_at is not None and (
|
|
469
|
+
snapshot.received_at is None or snapshot.received_at <= previous.received_at
|
|
470
|
+
):
|
|
471
|
+
return None
|
|
472
|
+
if descriptor is not None:
|
|
473
|
+
self.source_sessions[descriptor] = snapshot
|
|
474
|
+
self._source_content_revision += 1
|
|
475
|
+
self._source_choice_versions[label] = self._source_content_revision
|
|
476
|
+
self.source_choices[label] = target
|
|
477
|
+
self.source_choices = dict(sorted(self.source_choices.items()))
|
|
478
|
+
return label
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
async def async_source_selection(self, source: str, *, retained: RadioTarget | None = None) -> str:
|
|
482
|
+
"""Refresh an advertised provider's exact last native selection before play."""
|
|
483
|
+
if source not in self.source_choices and source not in self.source_descriptors:
|
|
484
|
+
message = "Source was not discovered; use Browse to select content first"
|
|
485
|
+
raise ValueError(message)
|
|
486
|
+
revision = self._source_inventory_revision
|
|
487
|
+
refresh_generation = self._source_refresh_generation + 1
|
|
488
|
+
advertised = self.source_descriptors.get(source)
|
|
489
|
+
try:
|
|
490
|
+
await self.async_refresh_sources()
|
|
491
|
+
except (AudioCommandError, ValueError, TimeoutError):
|
|
492
|
+
descriptor = self.source_descriptors.get(source)
|
|
493
|
+
if (
|
|
494
|
+
self._source_refresh_generation != refresh_generation
|
|
495
|
+
or self._source_inventory_revision != revision + 1
|
|
496
|
+
or descriptor is None or descriptor.service != "linein"
|
|
497
|
+
or descriptor != advertised
|
|
498
|
+
):
|
|
499
|
+
raise
|
|
500
|
+
# Fresh input inventory, not stale history or an invented default.
|
|
501
|
+
target = next(item for item in self._source_inputs if item.folder == descriptor.input_id)
|
|
502
|
+
self.source_choices[source] = target
|
|
503
|
+
return self._item(target).media_content_id
|
|
504
|
+
if advertised is not None and self.source_descriptors.get(source) != advertised:
|
|
505
|
+
message = "Configured source identity changed; choose the source again"
|
|
506
|
+
raise ValueError(message)
|
|
507
|
+
if (
|
|
508
|
+
source not in self.source_choices
|
|
509
|
+
and isinstance(retained, CatalogueTarget)
|
|
510
|
+
and retained.service == "spotify"
|
|
511
|
+
and self._source_label(retained) == source
|
|
512
|
+
and retained.account in [account["id"] for account in self._source_accounts]
|
|
513
|
+
):
|
|
514
|
+
# Only omitted history may use the captured choice; a freshly
|
|
515
|
+
# discovered newer last selection always wins.
|
|
516
|
+
self.source_choices[source] = retained
|
|
517
|
+
if source not in self.source_descriptors or source not in self.source_choices:
|
|
518
|
+
message = "Choose content explicitly in Browse before selecting this source; no content was sent"
|
|
519
|
+
raise ValueError(message)
|
|
520
|
+
return self._item(self.source_choices[source]).media_content_id
|
|
521
|
+
|
|
522
|
+
async def async_source_return(self, source: str, *, retained: RadioTarget | None = None) -> str:
|
|
523
|
+
"""Keep exact-room playback separately from discovery/history choices."""
|
|
524
|
+
session = self.source_session(source)
|
|
525
|
+
if self.source_descriptors.get(source) in self._unavailable_source_sessions:
|
|
526
|
+
raise AudioCommandError("Latest exact-room source session is unavailable")
|
|
527
|
+
if session is None:
|
|
528
|
+
return (
|
|
529
|
+
await self.async_source_selection(source)
|
|
530
|
+
if retained is None else await self.async_source_selection(source, retained=retained)
|
|
531
|
+
)
|
|
532
|
+
advertised = self.source_descriptors.get(source)
|
|
533
|
+
revision = self._source_inventory_revision
|
|
534
|
+
generation = self._source_refresh_generation + 1
|
|
535
|
+
try:
|
|
536
|
+
await self.async_refresh_sources()
|
|
537
|
+
except (AudioCommandError, ValueError, TimeoutError):
|
|
538
|
+
# A positively revalidated provider plus this room's actual session
|
|
539
|
+
# does not require recent-history content to be readable as well.
|
|
540
|
+
if (
|
|
541
|
+
advertised is None or self.source_descriptors.get(source) != advertised
|
|
542
|
+
or self._source_inventory_revision != revision + 1
|
|
543
|
+
or self._source_refresh_generation != generation
|
|
544
|
+
):
|
|
545
|
+
raise
|
|
546
|
+
if advertised is None or self.source_descriptors.get(source) != advertised:
|
|
547
|
+
raise AudioCommandError("Configured source identity changed")
|
|
548
|
+
if self.source_session(source) is not session:
|
|
549
|
+
raise AudioCommandError("Source session changed during preparation")
|
|
550
|
+
if session.source not in ("Local Library", "Spotify"):
|
|
551
|
+
target = self._recent_target({
|
|
552
|
+
"audiopath": session.parent_id or session.audio_path,
|
|
553
|
+
"type": 2,
|
|
554
|
+
"title": session.title or source,
|
|
555
|
+
})
|
|
556
|
+
if target is None or self._source_label(target) != source:
|
|
557
|
+
raise AudioCommandError("Prior native source identity is unavailable")
|
|
558
|
+
return self._item(target).media_content_id
|
|
559
|
+
descriptor = self.source_descriptors.get(source)
|
|
560
|
+
service, account = "library", "nouser"
|
|
561
|
+
prefix = "library:local:"
|
|
562
|
+
if session.source == "Spotify":
|
|
563
|
+
if descriptor is None or descriptor.service != "spotify":
|
|
564
|
+
raise AudioCommandError("Prior Spotify account is no longer configured")
|
|
565
|
+
service, account, prefix = "spotify", descriptor.account, "spotify:"
|
|
566
|
+
accounts = [a for a in self._source_accounts if a.get("id") == account and a.get("user") == session.source_name]
|
|
567
|
+
if len(accounts) != 1:
|
|
568
|
+
raise AudioCommandError("Prior Spotify account changed")
|
|
569
|
+
path = session.audio_path
|
|
570
|
+
if not path or not path.startswith((prefix + "track:", prefix + "episode:")):
|
|
571
|
+
raise AudioCommandError("Prior source track identity is unavailable")
|
|
572
|
+
parent, index = session.parent_id, None
|
|
573
|
+
if parent and not parent.startswith(prefix):
|
|
574
|
+
parent = None
|
|
575
|
+
if parent:
|
|
576
|
+
async def read_listing() -> list[str]:
|
|
577
|
+
listing, offset, total = [], 0, None
|
|
578
|
+
while total is None or offset < total:
|
|
579
|
+
page = self._page(await self.commands.async_media_folder(service, account, parent, offset), offset)
|
|
580
|
+
if (
|
|
581
|
+
page["totalitems"] > _MAX_SEARCH_ITEMS
|
|
582
|
+
or (total is not None and total != page["totalitems"])
|
|
583
|
+
or (page.get("audiopath") is not None and page["audiopath"] != parent)
|
|
584
|
+
):
|
|
585
|
+
raise AudioCommandError("Prior context changed or exceeds its cursor bound")
|
|
586
|
+
total = page["totalitems"]
|
|
587
|
+
for row in page["items"]:
|
|
588
|
+
item = self._parse_source_item(row, service, account)
|
|
589
|
+
if (
|
|
590
|
+
item is None or item.kind != "item" or item.is_context
|
|
591
|
+
or row.get("type") != 2 or row.get("unplayable", False) is not False
|
|
592
|
+
):
|
|
593
|
+
raise AudioCommandError("Prior context contains a non-playable track row")
|
|
594
|
+
command_path, observed_path = item.folder, item.audio_path
|
|
595
|
+
if service == "spotify":
|
|
596
|
+
account_prefix = "spotify@" + account + ":"
|
|
597
|
+
command_path = command_path.replace(account_prefix, "spotify:", 1)
|
|
598
|
+
observed_path = observed_path.replace(account_prefix, "spotify:", 1)
|
|
599
|
+
if (
|
|
600
|
+
command_path != observed_path
|
|
601
|
+
or not observed_path.startswith((prefix + "track:", prefix + "episode:"))
|
|
602
|
+
):
|
|
603
|
+
raise AudioCommandError("Prior context track identities conflict")
|
|
604
|
+
listing.append(observed_path)
|
|
605
|
+
offset += len(page["items"])
|
|
606
|
+
if self.source_session(source) is not session:
|
|
607
|
+
raise AudioCommandError("Source session changed during cursor lookup")
|
|
608
|
+
return listing
|
|
609
|
+
|
|
610
|
+
# Context is optional enrichment, not permission to try this exact
|
|
611
|
+
# last-good item. No send has happened during this bounded lookup.
|
|
612
|
+
try:
|
|
613
|
+
async with asyncio.timeout(10):
|
|
614
|
+
listing = await read_listing()
|
|
615
|
+
if listing != await read_listing():
|
|
616
|
+
raise AudioCommandError("Prior context order changed during cursor lookup")
|
|
617
|
+
if listing.count(path) != 1:
|
|
618
|
+
raise AudioCommandError("Prior library track cursor is missing or ambiguous")
|
|
619
|
+
index = listing.index(path)
|
|
620
|
+
except (AudioCommandError, ValueError, TimeoutError, ClientError):
|
|
621
|
+
parent = None
|
|
622
|
+
if self.source_session(source) is not session:
|
|
623
|
+
raise AudioCommandError("Source session changed during preparation")
|
|
624
|
+
return self._item(CatalogueTarget(
|
|
625
|
+
"item", session.title or "Last track", service, path, audio_path=path, account=account,
|
|
626
|
+
parent_path=parent, parent_index=index, return_session=session,
|
|
627
|
+
)).media_content_id
|
|
628
|
+
|
|
629
|
+
@staticmethod
|
|
630
|
+
def _parse_item(row: object, service: str) -> RadioTarget | None:
|
|
631
|
+
"""Preserve supplied radio covers without changing folder dispatch."""
|
|
632
|
+
target = RadioCatalogue._parse_item(row, service) # noqa: SLF001
|
|
633
|
+
if target is None or target.kind != "station":
|
|
634
|
+
return target
|
|
635
|
+
return CatalogueTarget(
|
|
636
|
+
target.kind,
|
|
637
|
+
target.label,
|
|
638
|
+
target.service,
|
|
639
|
+
target.folder,
|
|
640
|
+
audio_path=target.audio_path,
|
|
641
|
+
thumbnail=_cover_url(row),
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
def _item(self, target: RadioTarget) -> BrowseMedia:
|
|
645
|
+
item = super()._item(target)
|
|
646
|
+
if target.kind == "favourite" and target.favourite is not None:
|
|
647
|
+
item.media_class = self._item_class(replace(target, audio_path=target.favourite.audio_path))
|
|
648
|
+
if isinstance(target, CatalogueTarget):
|
|
649
|
+
item.thumbnail = self._artwork(target)
|
|
650
|
+
item.can_play = target.kind in ("station", "item", "input") or target.is_context
|
|
651
|
+
item.can_expand = target.kind in ("folder", "search_group")
|
|
652
|
+
item.can_search = target.kind == "folder"
|
|
653
|
+
if target.service in ("library", "spotify", "playlist"):
|
|
654
|
+
item.media_class = MediaClass.PLAYLIST if target.service == "playlist" else self._item_class(target)
|
|
655
|
+
elif target.kind == "input":
|
|
656
|
+
item.media_class = MediaClass.CHANNEL
|
|
657
|
+
if target.kind == "search_group":
|
|
658
|
+
item.media_class = MediaClass.DIRECTORY
|
|
659
|
+
item.can_play = False
|
|
660
|
+
item.children_media_class = self._item_class(target.group_items[0]) if target.group_items else None
|
|
661
|
+
elif target.service in ("library", "spotify") and item.can_expand:
|
|
662
|
+
item.search_media_classes = [
|
|
663
|
+
MediaClass.TRACK,
|
|
664
|
+
MediaClass.ALBUM,
|
|
665
|
+
MediaClass.ARTIST,
|
|
666
|
+
MediaClass.PLAYLIST,
|
|
667
|
+
]
|
|
668
|
+
item.search_media_classes.extend(
|
|
669
|
+
[MediaClass.PODCAST, MediaClass.EPISODE] if target.service == "spotify" else [MediaClass.DIRECTORY]
|
|
670
|
+
)
|
|
671
|
+
return item
|
|
672
|
+
|
|
673
|
+
@staticmethod
|
|
674
|
+
def _artwork(target: CatalogueTarget) -> str | None:
|
|
675
|
+
"""Prefer real covers; never invent album art when a folder has none."""
|
|
676
|
+
if target.thumbnail:
|
|
677
|
+
return target.thumbnail
|
|
678
|
+
if target.service == "linein":
|
|
679
|
+
return _LINE_IN_ARTWORK
|
|
680
|
+
if target.service in ("library", "playlist"):
|
|
681
|
+
return _LIBRARY_ARTWORK
|
|
682
|
+
return None
|
|
683
|
+
|
|
684
|
+
def artwork(self, content_id: str) -> str | None:
|
|
685
|
+
"""Resolve existing room-local media IDs, not caller supplied URLs."""
|
|
686
|
+
if content_id == _INPUTS:
|
|
687
|
+
return _LINE_IN_ARTWORK
|
|
688
|
+
target = self.resolve(content_id)
|
|
689
|
+
return self._artwork(target) if isinstance(target, CatalogueTarget) else None
|
|
690
|
+
|
|
691
|
+
@staticmethod
|
|
692
|
+
def _item_class(target: RadioTarget) -> MediaClass:
|
|
693
|
+
if target.audio_path.startswith(("tunein:", "loxoneradio:", "linein:", "linein/")):
|
|
694
|
+
return MediaClass.CHANNEL
|
|
695
|
+
kind = target.audio_path.split(":")[-2] if ":" in target.audio_path else ""
|
|
696
|
+
return {
|
|
697
|
+
"track": MediaClass.TRACK,
|
|
698
|
+
"album": MediaClass.ALBUM,
|
|
699
|
+
"artist": MediaClass.ARTIST,
|
|
700
|
+
"playlist": MediaClass.PLAYLIST,
|
|
701
|
+
"show": MediaClass.PODCAST,
|
|
702
|
+
"episode": MediaClass.EPISODE,
|
|
703
|
+
}.get(kind, MediaClass.DIRECTORY)
|
|
704
|
+
|
|
705
|
+
def _group_spotify(self, children: list[BrowseMedia]) -> list[BrowseMedia]:
|
|
706
|
+
groups: dict[str, list[RadioTarget]] = {}
|
|
707
|
+
labels = {
|
|
708
|
+
MediaClass.TRACK: "Tracks",
|
|
709
|
+
MediaClass.ALBUM: "Albums",
|
|
710
|
+
MediaClass.ARTIST: "Artists",
|
|
711
|
+
MediaClass.PLAYLIST: "Other playlists",
|
|
712
|
+
MediaClass.PODCAST: "Podcasts",
|
|
713
|
+
MediaClass.EPISODE: "Episodes",
|
|
714
|
+
}
|
|
715
|
+
for child in children:
|
|
716
|
+
target = self.resolve(child.media_content_id)
|
|
717
|
+
label = (
|
|
718
|
+
target.playlist_group
|
|
719
|
+
if isinstance(target, CatalogueTarget) and target.playlist_group
|
|
720
|
+
else labels.get(child.media_class, "Other results")
|
|
721
|
+
)
|
|
722
|
+
groups.setdefault(label, []).append(target)
|
|
723
|
+
return [
|
|
724
|
+
self._item(
|
|
725
|
+
CatalogueTarget(
|
|
726
|
+
"search_group",
|
|
727
|
+
label,
|
|
728
|
+
group_items=tuple(items),
|
|
729
|
+
thumbnail=items[0].thumbnail if isinstance(items[0], CatalogueTarget) else None,
|
|
730
|
+
)
|
|
731
|
+
)
|
|
732
|
+
for label, items in groups.items()
|
|
733
|
+
]
|
|
734
|
+
|
|
735
|
+
async def _accounts(self) -> list[dict]:
|
|
736
|
+
rows = await self.commands.async_service_accounts()
|
|
737
|
+
accounts = parse_spotify_accounts(rows)
|
|
738
|
+
return [
|
|
739
|
+
row
|
|
740
|
+
for row in rows
|
|
741
|
+
if row.get("cmd") == "spotify" and row.get("id") in accounts and row.get("configerror") is False
|
|
742
|
+
]
|
|
743
|
+
|
|
744
|
+
async def async_browse(self, content_id: str | None = None) -> BrowseMedia: # noqa: PLR0911 - native source dispatch
|
|
745
|
+
"""Browse native catalogues; all selections remain deliberate play actions."""
|
|
746
|
+
if content_id == _FAVOURITES:
|
|
747
|
+
return await super().async_browse(content_id)
|
|
748
|
+
if content_id in (None, "", _ROOT):
|
|
749
|
+
children = [self._directory(_FAVOURITES, "Room favourites", []), self._directory(_RADIO, "Radio", [])]
|
|
750
|
+
children.extend(
|
|
751
|
+
[
|
|
752
|
+
self._item(CatalogueTarget("folder", "Loxone Radio", "loxoneradio", "start", account="noUser")),
|
|
753
|
+
self._item(CatalogueTarget("folder", "Local Library", "library", "0")),
|
|
754
|
+
]
|
|
755
|
+
)
|
|
756
|
+
for account in await self._accounts():
|
|
757
|
+
label = _text(account.get("name") or account["id"], 256)
|
|
758
|
+
children.append(
|
|
759
|
+
self._item(
|
|
760
|
+
CatalogueTarget("folder", "Spotify · " + label, "spotify", "start", account=account["id"])
|
|
761
|
+
)
|
|
762
|
+
)
|
|
763
|
+
inputs = self._directory(_INPUTS, "Line-In", [])
|
|
764
|
+
inputs.thumbnail = _LINE_IN_ARTWORK
|
|
765
|
+
inputs.can_search = False
|
|
766
|
+
inputs.search_media_classes = None
|
|
767
|
+
children.append(inputs)
|
|
768
|
+
result = self._directory(_ROOT, "Audio sources", children)
|
|
769
|
+
result.search_media_classes = [
|
|
770
|
+
MediaClass.TRACK,
|
|
771
|
+
MediaClass.ALBUM,
|
|
772
|
+
MediaClass.ARTIST,
|
|
773
|
+
MediaClass.PLAYLIST,
|
|
774
|
+
MediaClass.DIRECTORY,
|
|
775
|
+
MediaClass.CHANNEL,
|
|
776
|
+
MediaClass.PODCAST,
|
|
777
|
+
MediaClass.EPISODE,
|
|
778
|
+
]
|
|
779
|
+
return result
|
|
780
|
+
if content_id == _RADIO:
|
|
781
|
+
root = await super().async_browse()
|
|
782
|
+
return self._directory(_RADIO, "Radio", root.children[1:])
|
|
783
|
+
if content_id == _INPUTS:
|
|
784
|
+
directory = self._directory(
|
|
785
|
+
_INPUTS,
|
|
786
|
+
"Line-In",
|
|
787
|
+
[self._item(item) for item in parse_line_inputs(await self.commands.async_source_inputs())],
|
|
788
|
+
)
|
|
789
|
+
directory.can_search = False
|
|
790
|
+
directory.search_media_classes = None
|
|
791
|
+
return directory
|
|
792
|
+
target = self.resolve(content_id)
|
|
793
|
+
if not isinstance(target, CatalogueTarget):
|
|
794
|
+
return await super().async_browse(content_id)
|
|
795
|
+
if target.kind == "search_group":
|
|
796
|
+
result = self._directory(content_id, target.label, [self._item(item) for item in target.group_items])
|
|
797
|
+
result.can_search = False
|
|
798
|
+
result.search_media_classes = None
|
|
799
|
+
return result
|
|
800
|
+
if target.kind != "folder":
|
|
801
|
+
msg = "Only discovered catalogue folders can be expanded"
|
|
802
|
+
raise ValueError(msg)
|
|
803
|
+
if target.service == "spotify" and target.account not in [a["id"] for a in await self._accounts()]:
|
|
804
|
+
msg = "The linked Spotify account changed; browse again"
|
|
805
|
+
raise ValueError(msg)
|
|
806
|
+
payload = await self.commands.async_media_folder(target.service, target.account, target.folder, target.offset)
|
|
807
|
+
library_root = target.service == "library" and target.folder == "0"
|
|
808
|
+
page = self._page(payload, target.offset, library_root=library_root)
|
|
809
|
+
children = [
|
|
810
|
+
self._item(item)
|
|
811
|
+
for row in page["items"]
|
|
812
|
+
if (item := self._parse_source_item(row, target.service, target.account)) is not None
|
|
813
|
+
]
|
|
814
|
+
next_offset = target.offset + len(page["items"])
|
|
815
|
+
if target.service == "spotify" and target.folder == "3":
|
|
816
|
+
children = self._group_spotify(children)
|
|
817
|
+
if target.service == "spotify" and target.folder == "start" and target.offset == 0:
|
|
818
|
+
# Installed vendor SpotifyFolder.MyPlaylists is the literal native "3".
|
|
819
|
+
# Saved includes followed playlists; ownership remains separate below.
|
|
820
|
+
children.append(
|
|
821
|
+
self._item(CatalogueTarget("folder", "Saved playlists", "spotify", "3", account=target.account))
|
|
822
|
+
)
|
|
823
|
+
if next_offset < page["totalitems"] or (library_root and len(page["items"]) == _PAGE_SIZE):
|
|
824
|
+
children.append(self._item(replace(target, label="More items…", offset=next_offset, is_context=False)))
|
|
825
|
+
result = self._directory(content_id, target.label, children)
|
|
826
|
+
result.can_play = target.is_context
|
|
827
|
+
result.search_media_classes = self._item(target).search_media_classes
|
|
828
|
+
return result
|
|
829
|
+
|
|
830
|
+
@staticmethod
|
|
831
|
+
def _page(payload: object, offset: int, *, library_root: bool = False) -> dict:
|
|
832
|
+
if not isinstance(payload, list) or len(payload) != 1 or not isinstance(payload[0], dict):
|
|
833
|
+
msg = "Catalogue requires one complete bounded native page"
|
|
834
|
+
raise ValueError(msg)
|
|
835
|
+
page = payload[0]
|
|
836
|
+
rows, total = page.get("items"), page.get("totalitems")
|
|
837
|
+
# This device's Library root returns 21 indexed rows with totalitems=20,
|
|
838
|
+
# including the final row at offset 20. Allow only that observed one-row
|
|
839
|
+
# discrepancy, only at root. Full root pages expose a boundary read so
|
|
840
|
+
# an under-reported total cannot hide their final item. Do not guess
|
|
841
|
+
# which storage object the firmware omitted from its reported count.
|
|
842
|
+
count_margin = int(library_root)
|
|
843
|
+
if (
|
|
844
|
+
type(page.get("start")) is not int
|
|
845
|
+
or page["start"] != offset
|
|
846
|
+
or type(total) is not int
|
|
847
|
+
or not 0 <= total <= _MAX_NATIVE_TOTAL
|
|
848
|
+
or offset > total + count_margin
|
|
849
|
+
or not isinstance(rows, list)
|
|
850
|
+
or len(rows) > _PAGE_SIZE
|
|
851
|
+
or offset + len(rows) > total + count_margin
|
|
852
|
+
or (not rows and offset < total)
|
|
853
|
+
):
|
|
854
|
+
msg = "Catalogue page changed or is incomplete; browse again"
|
|
855
|
+
raise ValueError(msg)
|
|
856
|
+
return {**page, "totalitems": max(total, offset + len(rows))}
|
|
857
|
+
|
|
858
|
+
@staticmethod
|
|
859
|
+
def _parse_source_item(row: object, service: str, account: str = "nouser") -> CatalogueTarget | None: # noqa: PLR0911, PLR0912
|
|
860
|
+
if not isinstance(row, dict) or type(row.get("type")) is not int:
|
|
861
|
+
msg = "Native catalogue contains an invalid item"
|
|
862
|
+
raise ValueError(msg)
|
|
863
|
+
if row.get("name") == ".private":
|
|
864
|
+
return None
|
|
865
|
+
if service == "loxoneradio":
|
|
866
|
+
result = RadioCatalogue._parse_item(row, service) # noqa: SLF001 - shared inherited boundary
|
|
867
|
+
if result is None:
|
|
868
|
+
return None
|
|
869
|
+
return CatalogueTarget(
|
|
870
|
+
result.kind,
|
|
871
|
+
result.label,
|
|
872
|
+
service,
|
|
873
|
+
result.folder,
|
|
874
|
+
audio_path=result.audio_path,
|
|
875
|
+
account=account,
|
|
876
|
+
thumbnail=_cover_url(row),
|
|
877
|
+
)
|
|
878
|
+
kind = row["type"]
|
|
879
|
+
if kind not in (1, 2, 3, 7, 11, 12):
|
|
880
|
+
return None
|
|
881
|
+
folder = kind in (1, 3, 7, 11, 12)
|
|
882
|
+
if row.get("unplayable") and not folder:
|
|
883
|
+
return None
|
|
884
|
+
identifier = _text(row.get("id"))
|
|
885
|
+
path = _text(row.get("audiopath") or identifier)
|
|
886
|
+
if service == "spotify":
|
|
887
|
+
prefix = "spotify@" + account + ":"
|
|
888
|
+
identities = (identifier, path)
|
|
889
|
+
if any(not value.startswith((prefix, "spotify:")) for value in identities):
|
|
890
|
+
msg = "Spotify result does not belong to the selected account"
|
|
891
|
+
raise ValueError(msg)
|
|
892
|
+
if identifier.removeprefix(prefix).removeprefix("spotify:") != path.removeprefix(prefix).removeprefix(
|
|
893
|
+
"spotify:"
|
|
894
|
+
):
|
|
895
|
+
message = "Spotify command and observation identity disagree"
|
|
896
|
+
raise ValueError(message)
|
|
897
|
+
context = folder and row.get("tag") != "category" and not row.get("unplayable", False)
|
|
898
|
+
elif service == "library":
|
|
899
|
+
if kind == _NATIVE_PLAYLIST:
|
|
900
|
+
if not row.get("audiopath") or row.get("unplayable"):
|
|
901
|
+
return None
|
|
902
|
+
return CatalogueTarget(
|
|
903
|
+
"item",
|
|
904
|
+
_text(row.get("name"), 256),
|
|
905
|
+
"playlist",
|
|
906
|
+
path,
|
|
907
|
+
audio_path=path,
|
|
908
|
+
is_context=True,
|
|
909
|
+
thumbnail=_cover_url(row),
|
|
910
|
+
)
|
|
911
|
+
if not path.startswith("library:local:"):
|
|
912
|
+
msg = "Library result has no native indexed-library identity"
|
|
913
|
+
raise ValueError(msg)
|
|
914
|
+
context = folder and not row.get("unplayable", False)
|
|
915
|
+
else:
|
|
916
|
+
msg = "Unsupported catalogue provider"
|
|
917
|
+
raise ValueError(msg)
|
|
918
|
+
return CatalogueTarget(
|
|
919
|
+
"folder" if folder else "item",
|
|
920
|
+
_text(row.get("name") or row.get("title"), 256),
|
|
921
|
+
service,
|
|
922
|
+
identifier,
|
|
923
|
+
audio_path=path,
|
|
924
|
+
account=account,
|
|
925
|
+
is_context=context,
|
|
926
|
+
thumbnail=_cover_url(row),
|
|
927
|
+
playlist_group=(
|
|
928
|
+
"Your playlists"
|
|
929
|
+
if row.get("owner_id") == account
|
|
930
|
+
else "Public playlists"
|
|
931
|
+
if row.get("public") is True or row.get("is_public") is True
|
|
932
|
+
else "Other playlists"
|
|
933
|
+
)
|
|
934
|
+
if service == "spotify" and ":playlist:" in path
|
|
935
|
+
else None,
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
async def _loxone_radio_search(self, query: str) -> list[BrowseMedia]:
|
|
939
|
+
"""Loxone Radio has a bounded native channel list, not a global-search scope."""
|
|
940
|
+
children, offset = [], 0
|
|
941
|
+
while offset < _MAX_RADIO_ITEMS:
|
|
942
|
+
page = self._page(await self.commands.async_media_folder("loxoneradio", "noUser", "start", offset), offset)
|
|
943
|
+
for row in page["items"]:
|
|
944
|
+
item = self._parse_source_item(row, "loxoneradio", "noUser")
|
|
945
|
+
if item and query.casefold() in item.label.casefold():
|
|
946
|
+
children.append(self._item(replace(item, label="Loxone Radio · " + item.label)))
|
|
947
|
+
offset += len(page["items"])
|
|
948
|
+
if offset == page["totalitems"]:
|
|
949
|
+
return children
|
|
950
|
+
msg = "Loxone Radio search exceeded its bounded native list"
|
|
951
|
+
raise ValueError(msg)
|
|
952
|
+
|
|
953
|
+
@staticmethod
|
|
954
|
+
def _search_scopes(capabilities: object, scope: str | None) -> dict[str, tuple[str, ...]]:
|
|
955
|
+
if not isinstance(capabilities, dict) or len(capabilities) > _MAX_PROVIDERS:
|
|
956
|
+
message = "Native search capability discovery failed"
|
|
957
|
+
raise ValueError(message)
|
|
958
|
+
scopes = {}
|
|
959
|
+
for provider, supported in _CATEGORIES.items():
|
|
960
|
+
values = capabilities.get(provider, [])
|
|
961
|
+
if (
|
|
962
|
+
not isinstance(values, list)
|
|
963
|
+
or len(values) > _MAX_CAPABILITIES
|
|
964
|
+
or any(not isinstance(v, str) for v in values)
|
|
965
|
+
):
|
|
966
|
+
message = "Native search capabilities are malformed"
|
|
967
|
+
raise ValueError(message)
|
|
968
|
+
if scope in (None, provider) and (categories := tuple(c for c in supported if c in values)):
|
|
969
|
+
scopes[provider] = categories
|
|
970
|
+
return scopes
|
|
971
|
+
|
|
972
|
+
def _search_items(
|
|
973
|
+
self, results: dict, requested: dict, account: dict | None, *, show_account: bool, show_provider: bool = True
|
|
974
|
+
) -> list[BrowseMedia]:
|
|
975
|
+
children, seen = [], set()
|
|
976
|
+
account_id = account["id"] if account else None
|
|
977
|
+
for provider, content in results.items():
|
|
978
|
+
if not isinstance(content, dict):
|
|
979
|
+
message = "Malformed native search results"
|
|
980
|
+
raise ValueError(message) # noqa: TRY004 - normalised untrusted wire validation error
|
|
981
|
+
for category in requested[provider]:
|
|
982
|
+
group = content.get(category + "s", content.get(category))
|
|
983
|
+
if group is None:
|
|
984
|
+
continue
|
|
985
|
+
if (
|
|
986
|
+
not isinstance(group, dict)
|
|
987
|
+
or not isinstance(group.get("items", []), list)
|
|
988
|
+
or len(group.get("items", [])) > _PAGE_SIZE
|
|
989
|
+
):
|
|
990
|
+
message = "Native search category exceeds its bound"
|
|
991
|
+
raise ValueError(message)
|
|
992
|
+
for row in group.get("items", []):
|
|
993
|
+
item = (
|
|
994
|
+
self._parse_item(row, "custom" if category == "custom" else "world")
|
|
995
|
+
if provider == "tunein"
|
|
996
|
+
else self._parse_source_item(
|
|
997
|
+
row,
|
|
998
|
+
"library" if provider == "local" else "spotify",
|
|
999
|
+
account_id if provider == "spotify" else "nouser",
|
|
1000
|
+
)
|
|
1001
|
+
)
|
|
1002
|
+
if item is None:
|
|
1003
|
+
continue
|
|
1004
|
+
key = (provider, account_id, item.audio_path or item.folder)
|
|
1005
|
+
if key in seen:
|
|
1006
|
+
continue
|
|
1007
|
+
seen.add(key)
|
|
1008
|
+
label = {"local": "Local Library", "spotify": "Spotify", "tunein": "Radio"}[provider]
|
|
1009
|
+
if provider == "spotify" and show_account:
|
|
1010
|
+
label += " · " + (account.get("name") or account_id)
|
|
1011
|
+
children.append(
|
|
1012
|
+
self._item(replace(item, label=label + " · " + item.label) if show_provider else item)
|
|
1013
|
+
)
|
|
1014
|
+
return children
|
|
1015
|
+
|
|
1016
|
+
@staticmethod
|
|
1017
|
+
def _bounded_search(items: list[BrowseMedia]) -> list[BrowseMedia]:
|
|
1018
|
+
if len(items) > _MAX_SEARCH_ITEMS:
|
|
1019
|
+
message = "Native search exceeded its bound; refine the query"
|
|
1020
|
+
raise ValueError(message)
|
|
1021
|
+
return items
|
|
1022
|
+
|
|
1023
|
+
async def async_search( # noqa: PLR0912 - explicit native provider and class-filter dispatch
|
|
1024
|
+
self,
|
|
1025
|
+
query: str,
|
|
1026
|
+
content_id: str | None = None,
|
|
1027
|
+
*,
|
|
1028
|
+
media_filter_classes: list[MediaClass] | None = None,
|
|
1029
|
+
) -> SearchMedia:
|
|
1030
|
+
"""Root searches all discovered indexed providers; folders restrict provider."""
|
|
1031
|
+
query = _text(query, 256)
|
|
1032
|
+
if content_id == _INPUTS:
|
|
1033
|
+
msg = "Line-In is a direct input, not searchable content"
|
|
1034
|
+
raise ValueError(msg)
|
|
1035
|
+
if content_id == _FAVOURITES:
|
|
1036
|
+
page = await super().async_browse(_FAVOURITES)
|
|
1037
|
+
return SearchMedia(
|
|
1038
|
+
result=[
|
|
1039
|
+
item
|
|
1040
|
+
for item in page.children
|
|
1041
|
+
if query.casefold() in item.title.casefold()
|
|
1042
|
+
and (not media_filter_classes or item.media_class in media_filter_classes)
|
|
1043
|
+
]
|
|
1044
|
+
)
|
|
1045
|
+
selected = None if content_id in (None, "", _ROOT, _RADIO) else self.resolve(content_id)
|
|
1046
|
+
if selected is not None and selected.kind != "folder":
|
|
1047
|
+
msg = "Search must target a discovered provider folder"
|
|
1048
|
+
raise ValueError(msg)
|
|
1049
|
+
if selected is not None and selected.service == "loxoneradio":
|
|
1050
|
+
return SearchMedia(
|
|
1051
|
+
result=await self._loxone_radio_search(query)
|
|
1052
|
+
if not media_filter_classes or MediaClass.CHANNEL in media_filter_classes
|
|
1053
|
+
else []
|
|
1054
|
+
)
|
|
1055
|
+
capabilities = await self.commands.async_search_capabilities()
|
|
1056
|
+
scope = (
|
|
1057
|
+
"tunein"
|
|
1058
|
+
if content_id == _RADIO
|
|
1059
|
+
else {"library": "local", "spotify": "spotify"}.get(selected.service, "tunein")
|
|
1060
|
+
if selected
|
|
1061
|
+
else None
|
|
1062
|
+
)
|
|
1063
|
+
scopes = self._search_scopes(capabilities, scope)
|
|
1064
|
+
if media_filter_classes:
|
|
1065
|
+
category_classes = {
|
|
1066
|
+
"track": MediaClass.TRACK,
|
|
1067
|
+
"album": MediaClass.ALBUM,
|
|
1068
|
+
"artist": MediaClass.ARTIST,
|
|
1069
|
+
"playlist": MediaClass.PLAYLIST,
|
|
1070
|
+
"folder": MediaClass.DIRECTORY,
|
|
1071
|
+
"show": MediaClass.PODCAST,
|
|
1072
|
+
"episode": MediaClass.EPISODE,
|
|
1073
|
+
"station": MediaClass.CHANNEL,
|
|
1074
|
+
"custom": MediaClass.CHANNEL,
|
|
1075
|
+
}
|
|
1076
|
+
scopes = {
|
|
1077
|
+
provider: filtered_categories
|
|
1078
|
+
for provider, categories in scopes.items()
|
|
1079
|
+
if (
|
|
1080
|
+
filtered_categories := tuple(
|
|
1081
|
+
category for category in categories if category_classes[category] in media_filter_classes
|
|
1082
|
+
)
|
|
1083
|
+
)
|
|
1084
|
+
}
|
|
1085
|
+
selected_spotify = selected is not None and selected.service == "spotify"
|
|
1086
|
+
accounts = await self._accounts() if "spotify" in scopes or selected_spotify else []
|
|
1087
|
+
if selected and selected.service == "spotify":
|
|
1088
|
+
accounts = [a for a in accounts if a["id"] == selected.account]
|
|
1089
|
+
if not accounts:
|
|
1090
|
+
msg = "Spotify search account is no longer linked"
|
|
1091
|
+
raise ValueError(msg)
|
|
1092
|
+
if "spotify" not in scopes:
|
|
1093
|
+
accounts = []
|
|
1094
|
+
if not accounts:
|
|
1095
|
+
scopes.pop("spotify", None)
|
|
1096
|
+
children = []
|
|
1097
|
+
for index, account in enumerate(accounts or [None]):
|
|
1098
|
+
requested = scopes if index == 0 else {"spotify": scopes["spotify"]}
|
|
1099
|
+
if not requested:
|
|
1100
|
+
break
|
|
1101
|
+
account_id = account["id"] if account else None
|
|
1102
|
+
results = await self.commands.async_global_search(query, requested, spotify_account=account_id)
|
|
1103
|
+
if not isinstance(results, dict) or set(results) != set(requested):
|
|
1104
|
+
msg = "Native search is incomplete; no partial success returned"
|
|
1105
|
+
raise ValueError(msg)
|
|
1106
|
+
children.extend(
|
|
1107
|
+
self._search_items(
|
|
1108
|
+
results, requested, account, show_account=len(accounts) > 1, show_provider=scope is None
|
|
1109
|
+
)
|
|
1110
|
+
)
|
|
1111
|
+
self._bounded_search(children)
|
|
1112
|
+
if scope is None and (not media_filter_classes or MediaClass.CHANNEL in media_filter_classes):
|
|
1113
|
+
children.extend(await self._loxone_radio_search(query))
|
|
1114
|
+
return SearchMedia(
|
|
1115
|
+
result=self._group_spotify(children)
|
|
1116
|
+
if scope == "spotify" and not media_filter_classes
|
|
1117
|
+
else self._bounded_search(children)
|
|
1118
|
+
)
|