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.
File without changes
@@ -0,0 +1,211 @@
1
+ """Native HA radio browsing over the existing opt-in Audio Server client."""
2
+
3
+ # ruff: noqa: TRY004 - malformed discovery has one ValueError boundary
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections import OrderedDict
8
+ from dataclasses import dataclass
9
+ import re
10
+ from typing import TYPE_CHECKING
11
+ from uuid import uuid4
12
+
13
+ from .models import MediaNode as BrowseMedia, SearchResult as SearchMedia, MediaKind as MediaClass
14
+
15
+ from .sources import AudioFavourite, parse_room_favourites
16
+
17
+ if TYPE_CHECKING:
18
+ from .native import AudioServerCommands
19
+
20
+ RADIO_MEDIA_TYPE = "pyloxone_radio"
21
+ _ROOT = "pyloxone-radio:root"
22
+ _FAVOURITES = "pyloxone-radio:favourites"
23
+ _PAGE_SIZE = 50
24
+ _MAX_ENTRIES = 2048
25
+ _MAX_TOTAL = 1_000_000
26
+ _MAX_SERVICES = 16
27
+ _MAX_SEARCH_RESULTS = 100
28
+ _CONTROL_BOUNDARY = 32
29
+
30
+
31
+ def _text(value: object, limit: int = 2048) -> str:
32
+ """Keep observed identifiers literal and reject empty/control-bearing data."""
33
+ if (
34
+ not isinstance(value, str)
35
+ or not value.strip()
36
+ or len(value) > limit
37
+ or any(ord(c) < _CONTROL_BOUNDARY for c in value)
38
+ ):
39
+ message = "Radio catalogue contains an invalid identifier or label"
40
+ raise ValueError(message)
41
+ return value
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class RadioTarget:
46
+ """Only a discovered target can be expanded or selected through HA."""
47
+
48
+ kind: str
49
+ label: str
50
+ service: str = ""
51
+ folder: str = ""
52
+ offset: int = 0
53
+ audio_path: str = ""
54
+ favourite: AudioFavourite | None = None
55
+
56
+
57
+ class RadioCatalogue:
58
+ """Retain bounded per-entity identities, not commands supplied by the UI."""
59
+
60
+ def __init__(self, commands: AudioServerCommands, player_id: int) -> None:
61
+ """Bind browsing to the same configured client and exact audio zone."""
62
+ self.commands = commands
63
+ self.player_id = player_id
64
+ self.favourites: tuple[AudioFavourite, ...] | None = None
65
+ self._entries: OrderedDict[str, RadioTarget] = OrderedDict()
66
+
67
+ def resolve(self, content_id: str) -> RadioTarget:
68
+ """Reject unknown, expired, cross-entity or hand-crafted media IDs."""
69
+ if not isinstance(content_id, str) or content_id not in self._entries:
70
+ message = "Radio selection expired or was not discovered; browse or search again"
71
+ raise ValueError(message)
72
+ self._entries.move_to_end(content_id)
73
+ return self._entries[content_id]
74
+
75
+ def _item(self, target: RadioTarget) -> BrowseMedia:
76
+ """Register one discovery result and expose its opaque native HA ID."""
77
+ content_id = "pyloxone-radio:" + uuid4().hex
78
+ self._entries[content_id] = target
79
+ while len(self._entries) > _MAX_ENTRIES:
80
+ self._entries.popitem(last=False)
81
+ playable = target.kind in ("station", "favourite")
82
+ return BrowseMedia(
83
+ media_class=MediaClass.CHANNEL if playable else MediaClass.DIRECTORY,
84
+ media_content_id=content_id,
85
+ media_content_type=RADIO_MEDIA_TYPE,
86
+ title=target.label,
87
+ can_play=playable,
88
+ can_expand=not playable,
89
+ can_search=not playable,
90
+ search_media_classes=[MediaClass.CHANNEL] if not playable else None,
91
+ )
92
+
93
+ @staticmethod
94
+ def _directory(content_id: str, title: str, children: list[BrowseMedia]) -> BrowseMedia:
95
+ """Use the standard Home Assistant media-browser contract."""
96
+ return BrowseMedia(
97
+ media_class=MediaClass.DIRECTORY,
98
+ media_content_id=content_id,
99
+ media_content_type=RADIO_MEDIA_TYPE,
100
+ title=title,
101
+ can_play=False,
102
+ can_expand=True,
103
+ can_search=True,
104
+ search_media_classes=[MediaClass.CHANNEL],
105
+ children=children,
106
+ )
107
+
108
+ async def async_browse(self, content_id: str | None = None) -> BrowseMedia:
109
+ """Read one provider or one catalogue page; browsing never plays media."""
110
+ if content_id == _FAVOURITES:
111
+ self.favourites = parse_room_favourites(
112
+ await self.commands.async_room_favourites(self.player_id), self.player_id
113
+ )
114
+ children = [self._item(RadioTarget("favourite", item.label, favourite=item)) for item in self.favourites]
115
+ return self._directory(_FAVOURITES, "Room favourites", children)
116
+ if content_id in (None, "", _ROOT):
117
+ services = await self.commands.async_radio_services()
118
+ if services is None:
119
+ services = []
120
+ if not isinstance(services, list) or len(services) > _MAX_SERVICES:
121
+ message = "Radio service discovery is invalid or exceeds its bound"
122
+ raise ValueError(message)
123
+ children = [self._directory(_FAVOURITES, "Room favourites", [])]
124
+ seen = set()
125
+ for service in services:
126
+ if not isinstance(service, dict):
127
+ message = "Radio service discovery contains an invalid entry"
128
+ raise ValueError(message)
129
+ command = _text(service.get("cmd"), 32)
130
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", command) or command in seen:
131
+ message = "Radio service discovery contains an invalid or duplicate service"
132
+ raise ValueError(message)
133
+ seen.add(command)
134
+ target = RadioTarget("folder", _text(service.get("name"), 256), command, _text(service.get("root")))
135
+ children.append(self._item(target))
136
+ return self._directory(_ROOT, "Radio stations", children)
137
+ target = self.resolve(content_id)
138
+ if target.kind != "folder":
139
+ message = "A radio station cannot be expanded as a folder"
140
+ raise ValueError(message)
141
+ payload = await self.commands.async_radio_folder(target.service, target.folder, target.offset)
142
+ if not isinstance(payload, list) or len(payload) != 1 or not isinstance(payload[0], dict):
143
+ message = "Radio browsing requires one bounded native page"
144
+ raise ValueError(message)
145
+ page = payload[0]
146
+ items, total = page.get("items"), page.get("totalitems")
147
+ if (
148
+ type(page.get("start")) is not int
149
+ or page["start"] != target.offset
150
+ or type(total) is not int
151
+ or not target.offset <= total <= _MAX_TOTAL
152
+ or not isinstance(items, list)
153
+ or len(items) > _PAGE_SIZE
154
+ or target.offset + len(items) > total
155
+ or (not items and target.offset < total)
156
+ ):
157
+ message = "Radio page changed or is incomplete; browse again"
158
+ raise ValueError(message)
159
+ children = [self._item(item) for row in items if (item := self._parse_item(row, target.service)) is not None]
160
+ offset = target.offset + len(items)
161
+ if offset < total:
162
+ children.append(self._item(RadioTarget("folder", "More stations…", target.service, target.folder, offset)))
163
+ return self._directory(content_id, target.label, children)
164
+
165
+ async def async_search(self, query: str) -> SearchMedia:
166
+ """Search native radio stations/custom URLs without external directories."""
167
+ payload = await self.commands.async_radio_search(query)
168
+ if not isinstance(payload, dict) or payload.get("error"):
169
+ message = "Radio search returned an invalid result"
170
+ raise ValueError(message)
171
+ result = payload.get("result", payload)
172
+ if not isinstance(result, dict):
173
+ message = "Radio search returned an invalid result"
174
+ raise ValueError(message)
175
+ children = []
176
+ seen = set()
177
+ for category, service in (("station", "world"), ("custom", "custom")):
178
+ if category not in result:
179
+ continue
180
+ group = result[category]
181
+ if (
182
+ not isinstance(group, dict)
183
+ or not isinstance(group.get("items"), list)
184
+ or len(group["items"]) > _MAX_SEARCH_RESULTS
185
+ ):
186
+ message = "Radio search exceeded its bounded category or returned invalid items"
187
+ raise ValueError(message)
188
+ for row in group["items"]:
189
+ target = self._parse_item(row, service)
190
+ if target is not None and target.kind == "station" and (service, target.audio_path) not in seen:
191
+ seen.add((service, target.audio_path))
192
+ children.append(self._item(target))
193
+ return SearchMedia(result=children)
194
+
195
+ @staticmethod
196
+ def _parse_item(row: object, service: str) -> RadioTarget | None:
197
+ """Separate actual folders from playable native radio paths."""
198
+ if not isinstance(row, dict) or type(row.get("type")) is not int:
199
+ message = "Radio catalogue contains an invalid item"
200
+ raise ValueError(message)
201
+ kind = row["type"]
202
+ if kind not in (1, 2) or row.get("unplayable", False):
203
+ return None
204
+ label = _text(row.get("name"), 256)
205
+ if kind == 1:
206
+ return RadioTarget("folder", label, service, _text(row.get("id")))
207
+ path = _text(row.get("audiopath"))
208
+ if not path.startswith(("tunein:", "loxoneradio:")) or not path.partition(":")[2].strip():
209
+ message = "Radio catalogue item has no supported native station path"
210
+ raise ValueError(message)
211
+ return RadioTarget("station", label, service, audio_path=path)
@@ -0,0 +1,194 @@
1
+ """Reusable native source routing and strict manual-track qualification."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import TYPE_CHECKING
7
+ from aiohttp import ClientError
8
+ from .native import AudioCommandError
9
+ from .catalogue import CatalogueTarget
10
+
11
+ from .sources import SourceEnvironment, async_native_catalogue_noop, native_selection_unchanged
12
+ from .spotify import NativeSessionUnavailableError
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Awaitable, Callable
16
+ from .catalogue import AudioCatalogue
17
+ from .metadata import AudioMetadataCoordinator
18
+ from .native import AudioServerCommands
19
+ from .radio import RadioTarget
20
+ from .native import AudioZoneMetadata
21
+
22
+
23
+ class NativeResumeOutcome(Enum):
24
+ """Explicit result of the host's existing linked-session bridge."""
25
+
26
+ UNAVAILABLE = "unavailable"
27
+ COMPLETE = "complete"
28
+
29
+
30
+ async def async_optional_native_resume(
31
+ commands: AudioServerCommands, metadata: AudioMetadataCoordinator, player_id: int,
32
+ target: RadioTarget, attempt: Callable[[RadioTarget], Awaitable[NativeResumeOutcome]],
33
+ *, is_current: Callable[[], bool],
34
+ ) -> NativeResumeOutcome:
35
+ """Own optional-attempt pinning and the no-retained-fallback safety decision."""
36
+ original = metadata.get(player_id)
37
+
38
+ def retained_allowed() -> bool:
39
+ return bool(
40
+ is_current() and native_selection_unchanged(original, metadata.get(player_id))
41
+ and player_id not in commands.source_fences and player_id not in commands.announcement_fences
42
+ )
43
+
44
+ try:
45
+ outcome = await attempt(target)
46
+ except (NativeSessionUnavailableError, TimeoutError, ClientError):
47
+ if not retained_allowed():
48
+ raise
49
+ return NativeResumeOutcome.UNAVAILABLE
50
+ if outcome is NativeResumeOutcome.UNAVAILABLE:
51
+ if not retained_allowed():
52
+ message = "Source return was superseded before native dispatch"
53
+ raise AudioCommandError(message)
54
+ elif outcome is not NativeResumeOutcome.COMPLETE:
55
+ message = "Native session bridge returned an invalid outcome"
56
+ raise AudioCommandError(message)
57
+ return outcome
58
+
59
+
60
+ def manual_seek_supported(snapshot: AudioZoneMetadata | None) -> bool:
61
+ """Require the native track capability, not an inferred source feature."""
62
+ return bool(
63
+ snapshot is not None and snapshot.event_active is False
64
+ and snapshot.source in {"Spotify", "Local Library"}
65
+ and snapshot.position is not None and snapshot.duration is not None
66
+ and snapshot.playback_restrictions is not None and snapshot.playback_restrictions & 32
67
+ )
68
+
69
+
70
+ def confirmed_library_session(snapshot: AudioZoneMetadata | None, saved: AudioZoneMetadata | None) -> bool:
71
+ """Qualify the retained native Library selection independently of host uncertainty."""
72
+ return bool(
73
+ snapshot is not None and saved is not None and snapshot.source == "Local Library"
74
+ and snapshot.event_idle and snapshot.event_active is False
75
+ and snapshot.native_mode in ("play", "pause")
76
+ and all(getattr(snapshot, field) == getattr(saved, field)
77
+ for field in ("player_id", "source", "audio_path", "parent_id"))
78
+ )
79
+
80
+
81
+ def native_track_identity(snapshot: AudioZoneMetadata | None) -> tuple | None:
82
+ """Use the accepted native track generation key, also suitable for UI clocks."""
83
+ return (
84
+ (snapshot.audio_path, snapshot.title, snapshot.artist, snapshot.album, snapshot.duration)
85
+ if snapshot is not None and snapshot.duration is not None else None
86
+ )
87
+
88
+
89
+ def manual_seek_position(position: float, duration: float) -> float:
90
+ """Reject invalid offsets and stop before the queue-advancing track end."""
91
+ if isinstance(position, bool) or not isinstance(position, (int, float)) or not 0 <= position < float("inf"):
92
+ message = "Invalid manual seek position"
93
+ raise ValueError(message)
94
+ return min(position, max(0, duration - 1))
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class ManualSeekQualification:
99
+ """Pin native identity/account and neutral host-observed track revision."""
100
+
101
+ original: AudioZoneMetadata
102
+ identity: tuple | None
103
+ revision: int
104
+ duration: float
105
+
106
+ def is_current(
107
+ self, snapshot: AudioZoneMetadata | None, *, identity: tuple | None, revision: int,
108
+ duration: float | None, enabled: bool,
109
+ ) -> bool:
110
+ """Leave only lifecycle/request liveness to the host availability callback."""
111
+ return bool(
112
+ enabled and manual_seek_supported(snapshot)
113
+ and identity == self.identity and revision == self.revision and duration == self.duration
114
+ and native_track_identity(snapshot) == self.identity
115
+ and snapshot.player_id == self.original.player_id
116
+ and snapshot.source == self.original.source
117
+ and snapshot.source_name == self.original.source_name
118
+ )
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class SourceRoute:
123
+ """A native completion or retained opaque item qualified for one dispatch."""
124
+
125
+ original: AudioZoneMetadata | None
126
+ target: RadioTarget
127
+ media_id: str | None
128
+
129
+
130
+ class SourceContentRequiredError(ValueError):
131
+ """A discovered source has no qualified return content; host may translate."""
132
+
133
+
134
+ async def async_source_route(
135
+ commands: AudioServerCommands, metadata: AudioMetadataCoordinator, catalogue: AudioCatalogue,
136
+ player_id: int, source: str, *, is_current: Callable[[], bool],
137
+ ensure_snapshot: Callable[[], Awaitable[None]],
138
+ resume_native: Callable[[RadioTarget], Awaitable[NativeResumeOutcome]],
139
+ native_is_available: Callable[[], bool], on_target: Callable[[RadioTarget], None],
140
+ environment: SourceEnvironment | None = None,
141
+ ) -> SourceRoute:
142
+ """Resolve native-session versus retained content without owning HA tasks."""
143
+ original = metadata.get(player_id)
144
+
145
+ hint = catalogue.source_choices.get(source) or catalogue.source_descriptors.get(source)
146
+
147
+ if (
148
+ getattr(hint, "service", None) != "spotify"
149
+ and (source not in catalogue.source_choices or original is None)
150
+ and not catalogue.source_session_known(source)
151
+ ):
152
+ try:
153
+ await catalogue.async_source_selection(source)
154
+ except ValueError:
155
+ message = "Choose content explicitly in Browse before selecting this source"
156
+ raise SourceContentRequiredError(message) from None
157
+ if not is_current() or not (
158
+ (original is None and metadata.get(player_id) is None)
159
+ or native_selection_unchanged(original, metadata.get(player_id))
160
+ ):
161
+ message = "Source return was superseded before native dispatch"
162
+ raise AudioCommandError(message)
163
+ await ensure_snapshot()
164
+ original = metadata.get(player_id)
165
+ target = catalogue.source_choices.get(source)
166
+ if target is None:
167
+ descriptor = catalogue.source_descriptors.get(source)
168
+ if descriptor is None:
169
+ message = "Source selection is unavailable"
170
+ raise AudioCommandError(message)
171
+ target = CatalogueTarget("item", source, descriptor.service, account=descriptor.account)
172
+
173
+ on_target(target)
174
+ outcome = await async_optional_native_resume(
175
+ commands, metadata, player_id, target, resume_native, is_current=is_current,
176
+ )
177
+ native_done = outcome is NativeResumeOutcome.COMPLETE
178
+ if not native_done and isinstance(target, CatalogueTarget) and target.service == "spotify":
179
+ native_done = await async_native_catalogue_noop(
180
+ commands, metadata, player_id, target, is_available=native_is_available, resume_paused=True,
181
+ environment=environment,
182
+ )
183
+ if native_done:
184
+ catalogue.remember_source(metadata.get(player_id))
185
+ return SourceRoute(original, target, None)
186
+ try:
187
+ media_id = await catalogue.async_source_return(source)
188
+ except ValueError:
189
+ message = "Choose content explicitly in Browse before selecting this source"
190
+ raise SourceContentRequiredError(message) from None
191
+ if not is_current() or not native_selection_unchanged(original, metadata.get(player_id)):
192
+ message = "Source return was superseded before native dispatch"
193
+ raise AudioCommandError(message)
194
+ return SourceRoute(original, target, media_id)