PyLoxone-Audio 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ )