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
pyloxone_audio/py.typed
ADDED
|
File without changes
|
pyloxone_audio/radio.py
ADDED
|
@@ -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)
|