pywiim 2.3.0__tar.gz → 2.3.2__tar.gz
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.
- {pywiim-2.3.0/pywiim.egg-info → pywiim-2.3.2}/PKG-INFO +1 -1
- {pywiim-2.3.0 → pywiim-2.3.2}/pyproject.toml +1 -1
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/__init__.py +1 -1
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/base.py +9 -1
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/diagnostics.py +58 -20
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/__init__.py +36 -5
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/media.py +157 -2
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/properties.py +67 -18
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/statemgr.py +7 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/profiles.py +8 -25
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/state.py +116 -27
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/upnp/client.py +32 -17
- pywiim-2.3.2/pywiim/upnp/playqueue.py +165 -0
- {pywiim-2.3.0 → pywiim-2.3.2/pywiim.egg-info}/PKG-INFO +1 -1
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim.egg-info/SOURCES.txt +2 -1
- {pywiim-2.3.0 → pywiim-2.3.2}/LICENSE +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/README.md +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/__init__.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/audio_pro.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/audio_settings.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/bluetooth.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/constants.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/device.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/endpoints.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/eq.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/firmware.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/group.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/lms.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/loop_mode.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/misc.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/parser.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/peq.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/playback.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/preset.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/ssl.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/subwoofer.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/api/timer.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/backoff.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/capabilities.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/__init__.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/diagnostics.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/discovery_cli.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/group_test_cli.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/join_test_cli.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/monitor_cli.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/cli/verify_cli.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/client.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/device_capabilities.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/discovery.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/exceptions.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/group.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/group_helpers.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/mcp/__init__.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/mcp/__main__.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/mcp/config.example.json +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/mcp/config.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/mcp/context.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/mcp/server.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/metadata.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/model_names.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/models.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/normalize.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/audio.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/base.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/bluetooth.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/coverart.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/debounce.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/diagnostics.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/groupops.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/playback.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/source_capabilities.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/stream.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/stream_enricher.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/player/volume.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/polling.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/py.typed +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/role.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/upnp/__init__.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/upnp/eventer.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/upnp/health.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim/upnp/metadata.py +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim.egg-info/dependency_links.txt +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim.egg-info/entry_points.txt +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim.egg-info/requires.txt +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/pywiim.egg-info/top_level.txt +0 -0
- {pywiim-2.3.0 → pywiim-2.3.2}/setup.cfg +0 -0
|
@@ -489,7 +489,15 @@ class BaseWiiMClient:
|
|
|
489
489
|
except Exception: # noqa: BLE001
|
|
490
490
|
pass # Device info not available, continue without it
|
|
491
491
|
|
|
492
|
-
|
|
492
|
+
error_cls = (
|
|
493
|
+
WiiMConnectionError
|
|
494
|
+
if isinstance(
|
|
495
|
+
err,
|
|
496
|
+
(aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError, WiiMConnectionError),
|
|
497
|
+
)
|
|
498
|
+
else WiiMRequestError
|
|
499
|
+
)
|
|
500
|
+
raise error_cls(
|
|
493
501
|
f"Request failed after {retry_count} attempts: {err}",
|
|
494
502
|
endpoint=endpoint,
|
|
495
503
|
attempts=retry_count,
|
|
@@ -11,6 +11,21 @@ from .constants import API_ENDPOINT_DEBUG_INFO
|
|
|
11
11
|
|
|
12
12
|
_LOGGER = logging.getLogger(__name__)
|
|
13
13
|
|
|
14
|
+
# Tried after the profile/capability command if the device returns "unknown command".
|
|
15
|
+
# WiiM Amp (and some current WiiM firmware) rejects `reboot` and accepts StartRebootTime:1.
|
|
16
|
+
# Audio Pro devices use StartRebootTime:0.
|
|
17
|
+
_REBOOT_FALLBACKS = ("StartRebootTime:1", "StartRebootTime:0", "reboot")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_unknown_reboot_command(resp: Any) -> bool:
|
|
21
|
+
"""Return True when the device rejected the reboot command as unknown."""
|
|
22
|
+
if resp is None:
|
|
23
|
+
return False
|
|
24
|
+
raw = getattr(resp, "raw", None)
|
|
25
|
+
parsed = getattr(resp, "parsed", None)
|
|
26
|
+
text = str(raw if raw not in (None, "") else parsed or "").lower()
|
|
27
|
+
return "unknown command" in text
|
|
28
|
+
|
|
14
29
|
|
|
15
30
|
class DiagnosticsAPI:
|
|
16
31
|
"""Low-level device maintenance helpers.
|
|
@@ -27,51 +42,74 @@ class DiagnosticsAPI:
|
|
|
27
42
|
even if the device stops responding.
|
|
28
43
|
|
|
29
44
|
The reboot command varies by device:
|
|
30
|
-
- WiiM devices: "reboot"
|
|
45
|
+
- WiiM devices (legacy): "reboot"
|
|
46
|
+
- WiiM Amp / current WiiM firmware: "StartRebootTime:1"
|
|
31
47
|
- Audio Pro devices: "StartRebootTime:0"
|
|
32
48
|
|
|
33
|
-
The
|
|
34
|
-
|
|
49
|
+
The first command comes from device capabilities/profile. If the device
|
|
50
|
+
returns "unknown command", remaining fallbacks are tried and the working
|
|
51
|
+
command is cached on the client. See: https://github.com/mjcumming/wiim/issues/260
|
|
35
52
|
|
|
36
53
|
Raises:
|
|
37
54
|
WiiMError: If the request fails before the device reboots.
|
|
38
55
|
"""
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
56
|
+
primary = self._capabilities.get("reboot_command", "reboot") # type: ignore[attr-defined]
|
|
57
|
+
commands: list[str] = [primary]
|
|
58
|
+
for fallback in _REBOOT_FALLBACKS:
|
|
59
|
+
if fallback not in commands:
|
|
60
|
+
commands.append(fallback)
|
|
61
|
+
|
|
62
|
+
last_err: Exception | None = None
|
|
63
|
+
for command in commands:
|
|
64
|
+
endpoint = f"/httpapi.asp?command={command}"
|
|
65
|
+
_LOGGER.debug("Sending reboot command: %s", command)
|
|
66
|
+
try:
|
|
67
|
+
resp = await self._request_reboot(endpoint)
|
|
68
|
+
except Exception as err:
|
|
69
|
+
last_err = err
|
|
70
|
+
error_str = str(err).lower()
|
|
71
|
+
if "unknown command" in error_str:
|
|
72
|
+
_LOGGER.debug("Reboot command %s not supported, trying fallback", command)
|
|
73
|
+
continue
|
|
74
|
+
# Connection drop / empty body: command was likely accepted
|
|
75
|
+
_LOGGER.info("Reboot command sent to device (device may not respond): %s", err)
|
|
76
|
+
return
|
|
44
77
|
|
|
45
|
-
|
|
78
|
+
if _is_unknown_reboot_command(resp):
|
|
79
|
+
_LOGGER.debug("Reboot command %s returned unknown command, trying fallback", command)
|
|
80
|
+
continue
|
|
46
81
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
_LOGGER.info("Reboot command sent to device (device may not respond): %s",
|
|
54
|
-
# Don't re-raise - reboot command was sent successfully
|
|
82
|
+
if command != primary:
|
|
83
|
+
self._capabilities["reboot_command"] = command # type: ignore[attr-defined]
|
|
84
|
+
_LOGGER.info("Reboot command %s succeeded; cached for this device", command)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
if last_err:
|
|
88
|
+
_LOGGER.info("Reboot command sent to device (device may not respond): %s", last_err)
|
|
55
89
|
|
|
56
|
-
async def _request_reboot(self, endpoint: str) ->
|
|
90
|
+
async def _request_reboot(self, endpoint: str) -> Any:
|
|
57
91
|
"""Special request method for reboot that handles empty responses gracefully.
|
|
58
92
|
|
|
59
93
|
Args:
|
|
60
94
|
endpoint: The reboot endpoint to call.
|
|
61
95
|
|
|
96
|
+
Returns:
|
|
97
|
+
ApiResponse from the device, or None when the device dropped the
|
|
98
|
+
connection after accepting the command.
|
|
99
|
+
|
|
62
100
|
Raises:
|
|
63
101
|
WiiMError: If the request fails for reasons other than expected reboot behavior.
|
|
64
102
|
"""
|
|
65
103
|
try:
|
|
66
104
|
# Try to send the reboot command
|
|
67
|
-
await self._request(endpoint) # type: ignore[attr-defined]
|
|
105
|
+
return await self._request(endpoint) # type: ignore[attr-defined]
|
|
68
106
|
except Exception as err:
|
|
69
107
|
# If the request fails due to parsing issues (common with reboot),
|
|
70
108
|
# we still consider it successful since the command was sent
|
|
71
109
|
error_str = str(err).lower()
|
|
72
110
|
if any(x in error_str for x in ["expecting value", "json decode", "empty response"]):
|
|
73
111
|
_LOGGER.info("Reboot command sent successfully (device stopped responding as expected)")
|
|
74
|
-
return
|
|
112
|
+
return None
|
|
75
113
|
else:
|
|
76
114
|
# Re-raise other types of errors
|
|
77
115
|
raise
|
|
@@ -522,6 +522,22 @@ class Player(PlayerBase):
|
|
|
522
522
|
if self._on_state_changed:
|
|
523
523
|
self._on_state_changed()
|
|
524
524
|
|
|
525
|
+
async def set_main_speaker_bass(self, enabled: bool) -> None:
|
|
526
|
+
"""Enable or disable bass output to the main speakers.
|
|
527
|
+
|
|
528
|
+
When disabled, frequencies below the crossover go only to the subwoofer.
|
|
529
|
+
When enabled, bass is also sent to the main speakers.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
enabled: True to send bass to main speakers, False to filter it out.
|
|
533
|
+
"""
|
|
534
|
+
await self.client.set_main_speaker_bass(enabled)
|
|
535
|
+
if self._subwoofer_status:
|
|
536
|
+
# API uses inverted logic: main_filter=1 means bass filtered from mains
|
|
537
|
+
self._subwoofer_status["main_filter"] = 0 if enabled else 1
|
|
538
|
+
if self._on_state_changed:
|
|
539
|
+
self._on_state_changed()
|
|
540
|
+
|
|
525
541
|
# === Display (WiiM Ultra; ADR 005) ===
|
|
526
542
|
|
|
527
543
|
async def set_display_enabled(
|
|
@@ -1234,6 +1250,20 @@ class Player(PlayerBase):
|
|
|
1234
1250
|
return False
|
|
1235
1251
|
return self.client.capabilities.get("supports_subwoofer") is True
|
|
1236
1252
|
|
|
1253
|
+
@property
|
|
1254
|
+
def main_speaker_bass(self) -> bool | None:
|
|
1255
|
+
"""Whether bass is sent to the main speakers.
|
|
1256
|
+
|
|
1257
|
+
Returns:
|
|
1258
|
+
True if mains receive bass, False if the main-speaker bass filter
|
|
1259
|
+
is active, None if subwoofer status has not been read.
|
|
1260
|
+
"""
|
|
1261
|
+
if self._subwoofer_status is None:
|
|
1262
|
+
return None
|
|
1263
|
+
if "main_filter" not in self._subwoofer_status:
|
|
1264
|
+
return None
|
|
1265
|
+
return int(self._subwoofer_status.get("main_filter", 0)) == 0
|
|
1266
|
+
|
|
1237
1267
|
@property
|
|
1238
1268
|
def supports_trigger_out(self) -> bool:
|
|
1239
1269
|
"""Whether 12V trigger output is supported (WiiM Ultra / Pro / Pro Plus)."""
|
|
@@ -1377,18 +1407,19 @@ class Player(PlayerBase):
|
|
|
1377
1407
|
|
|
1378
1408
|
@property
|
|
1379
1409
|
def supports_queue_browse(self) -> bool:
|
|
1380
|
-
"""Whether full queue retrieval is available
|
|
1410
|
+
"""Whether full queue retrieval is available.
|
|
1381
1411
|
|
|
1382
|
-
|
|
1383
|
-
|
|
1412
|
+
True for ContentDirectory (Amp/Ultra + USB) or PlayQueue BrowseQueue
|
|
1413
|
+
(WiiM Pro / Pro Plus after a queue has been created).
|
|
1384
1414
|
"""
|
|
1385
1415
|
return self._properties.supports_queue_browse
|
|
1386
1416
|
|
|
1387
1417
|
@property
|
|
1388
1418
|
def supports_queue_add(self) -> bool:
|
|
1389
|
-
"""Whether adding items to queue is supported
|
|
1419
|
+
"""Whether adding items to queue is supported.
|
|
1390
1420
|
|
|
1391
|
-
|
|
1421
|
+
True for AVTransport AddURIToQueue or PlayQueue
|
|
1422
|
+
CreateQueue/AppendTracksInQueueEx/PlayQueueWithIndex.
|
|
1392
1423
|
"""
|
|
1393
1424
|
return self._properties.supports_queue_add
|
|
1394
1425
|
|
|
@@ -9,6 +9,14 @@ from typing import TYPE_CHECKING, Any, Literal
|
|
|
9
9
|
from xml.etree import ElementTree as ET
|
|
10
10
|
|
|
11
11
|
from ..exceptions import WiiMError
|
|
12
|
+
from ..upnp.playqueue import (
|
|
13
|
+
CURRENT_QUEUE,
|
|
14
|
+
build_empty_playlist,
|
|
15
|
+
build_track_playlist,
|
|
16
|
+
parse_queue_context,
|
|
17
|
+
playqueue_supports_enqueue,
|
|
18
|
+
service_has_action,
|
|
19
|
+
)
|
|
12
20
|
from .source_capabilities import SOURCE_CAPABILITIES, source_supports_native_notification_prompt
|
|
13
21
|
|
|
14
22
|
if TYPE_CHECKING:
|
|
@@ -175,9 +183,9 @@ class MediaControl:
|
|
|
175
183
|
# Call API (raises on failure)
|
|
176
184
|
await self.player.client.stop()
|
|
177
185
|
|
|
178
|
-
# Update cached state immediately (optimistic)
|
|
186
|
+
# Update cached state immediately (optimistic). "stop" normalizes to idle.
|
|
179
187
|
if self.player._status_model:
|
|
180
|
-
self.player._status_model.play_state = "
|
|
188
|
+
self.player._status_model.play_state = "idle"
|
|
181
189
|
|
|
182
190
|
# Update state synchronizer (for immediate property reads)
|
|
183
191
|
self.player._state_synchronizer.update_from_http({"play_state": "stop"})
|
|
@@ -393,9 +401,20 @@ class MediaControl:
|
|
|
393
401
|
|
|
394
402
|
return result
|
|
395
403
|
|
|
404
|
+
def _use_playqueue_enqueue(self) -> bool:
|
|
405
|
+
"""Prefer vendor PlayQueue when AVTransport has no AddURIToQueue."""
|
|
406
|
+
upnp = self.player._upnp_client
|
|
407
|
+
if not upnp:
|
|
408
|
+
return False
|
|
409
|
+
if service_has_action(upnp.av_transport, "AddURIToQueue"):
|
|
410
|
+
return False
|
|
411
|
+
return playqueue_supports_enqueue(upnp.play_queue)
|
|
412
|
+
|
|
396
413
|
async def add_to_queue(self, url: str, metadata: str = "") -> None:
|
|
397
414
|
"""Add URL to end of queue (requires UPnP client).
|
|
398
415
|
|
|
416
|
+
Uses PlayQueue on WiiM firmware that lacks AVTransport AddURIToQueue.
|
|
417
|
+
|
|
399
418
|
Args:
|
|
400
419
|
url: URL to add to queue.
|
|
401
420
|
metadata: Optional DIDL-Lite metadata.
|
|
@@ -403,6 +422,10 @@ class MediaControl:
|
|
|
403
422
|
if not self.player._upnp_client:
|
|
404
423
|
raise WiiMError("Queue management requires UPnP client.")
|
|
405
424
|
|
|
425
|
+
if self._use_playqueue_enqueue():
|
|
426
|
+
await self._playqueue_add(url, metadata)
|
|
427
|
+
return
|
|
428
|
+
|
|
406
429
|
await self.player._upnp_client.async_call_action(
|
|
407
430
|
"AVTransport",
|
|
408
431
|
"AddURIToQueue",
|
|
@@ -425,6 +448,10 @@ class MediaControl:
|
|
|
425
448
|
if not self.player._upnp_client:
|
|
426
449
|
raise WiiMError("Queue management requires UPnP client.")
|
|
427
450
|
|
|
451
|
+
if self._use_playqueue_enqueue():
|
|
452
|
+
await self._playqueue_insert_next(url, metadata)
|
|
453
|
+
return
|
|
454
|
+
|
|
428
455
|
await self.player._upnp_client.async_call_action(
|
|
429
456
|
"AVTransport",
|
|
430
457
|
"InsertURIToQueue",
|
|
@@ -443,6 +470,74 @@ class MediaControl:
|
|
|
443
470
|
elif enqueue == "next":
|
|
444
471
|
await self.insert_next(url)
|
|
445
472
|
|
|
473
|
+
async def _playqueue_call(self, action: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
474
|
+
"""Call a PlayQueue action."""
|
|
475
|
+
upnp = self.player._upnp_client
|
|
476
|
+
if not upnp:
|
|
477
|
+
raise WiiMError("Queue management requires UPnP client.")
|
|
478
|
+
return await upnp.async_call_action("play_queue", action, arguments)
|
|
479
|
+
|
|
480
|
+
async def _playqueue_ensure(self) -> None:
|
|
481
|
+
"""Create CurrentQueue if BrowseQueue says it does not exist."""
|
|
482
|
+
try:
|
|
483
|
+
await self._playqueue_call("BrowseQueue", {"QueueName": CURRENT_QUEUE})
|
|
484
|
+
except Exception:
|
|
485
|
+
await self._playqueue_call(
|
|
486
|
+
"CreateQueue",
|
|
487
|
+
{"QueueContext": build_empty_playlist()},
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
async def _playqueue_append(
|
|
491
|
+
self,
|
|
492
|
+
url: str,
|
|
493
|
+
metadata: str = "",
|
|
494
|
+
*,
|
|
495
|
+
start_index: int = 0,
|
|
496
|
+
play: int = 0,
|
|
497
|
+
) -> None:
|
|
498
|
+
"""Append one track via AppendTracksInQueueEx."""
|
|
499
|
+
await self._playqueue_call(
|
|
500
|
+
"AppendTracksInQueueEx",
|
|
501
|
+
{
|
|
502
|
+
"QueueContext": build_track_playlist(url, metadata),
|
|
503
|
+
"Direction": 1,
|
|
504
|
+
"StartIndex": start_index,
|
|
505
|
+
"Play": play,
|
|
506
|
+
"Action": "",
|
|
507
|
+
},
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
async def _playqueue_add(self, url: str, metadata: str = "") -> None:
|
|
511
|
+
"""Add URL to the end of CurrentQueue without starting playback."""
|
|
512
|
+
await self._playqueue_ensure()
|
|
513
|
+
await self._playqueue_append(url, metadata)
|
|
514
|
+
_LOGGER.debug("Added URL to PlayQueue on %s", self.player.host)
|
|
515
|
+
|
|
516
|
+
async def _playqueue_insert_next(self, url: str, metadata: str = "") -> None:
|
|
517
|
+
"""Insert URL after the current PlayQueue index."""
|
|
518
|
+
await self._playqueue_ensure()
|
|
519
|
+
start_index = 1
|
|
520
|
+
try:
|
|
521
|
+
result = await self._playqueue_call("GetQueueIndex", {"QueueName": CURRENT_QUEUE})
|
|
522
|
+
current = result.get("CurrentIndex")
|
|
523
|
+
if current is not None:
|
|
524
|
+
start_index = int(current)
|
|
525
|
+
except Exception as err:
|
|
526
|
+
_LOGGER.debug("GetQueueIndex failed on %s, appending after index 1: %s", self.player.host, err)
|
|
527
|
+
await self._playqueue_append(url, metadata, start_index=start_index)
|
|
528
|
+
_LOGGER.debug("Inserted URL next in PlayQueue on %s", self.player.host)
|
|
529
|
+
|
|
530
|
+
async def _playqueue_get(self) -> list[dict[str, Any]]:
|
|
531
|
+
"""Browse CurrentQueue and parse Track entries."""
|
|
532
|
+
try:
|
|
533
|
+
result = await self._playqueue_call("BrowseQueue", {"QueueName": CURRENT_QUEUE})
|
|
534
|
+
except Exception as err:
|
|
535
|
+
_LOGGER.debug("PlayQueue BrowseQueue empty or failed on %s: %s", self.player.host, err)
|
|
536
|
+
return []
|
|
537
|
+
items = parse_queue_context((result or {}).get("QueueContext", "") or "")
|
|
538
|
+
_LOGGER.debug("Retrieved %d PlayQueue items from %s", len(items), self.player.host)
|
|
539
|
+
return items
|
|
540
|
+
|
|
446
541
|
async def play_preset(self, preset: int, index: int | None = None) -> None:
|
|
447
542
|
"""Play a preset by number, optionally starting at a track index.
|
|
448
543
|
|
|
@@ -705,6 +800,9 @@ class MediaControl:
|
|
|
705
800
|
if not self.player._upnp_client:
|
|
706
801
|
raise WiiMError("Queue retrieval requires UPnP client.")
|
|
707
802
|
|
|
803
|
+
if self._use_playqueue_enqueue():
|
|
804
|
+
return await self._playqueue_get()
|
|
805
|
+
|
|
708
806
|
try:
|
|
709
807
|
# Browse queue using ContentDirectory service
|
|
710
808
|
result = await self.player._upnp_client.browse_queue(
|
|
@@ -748,6 +846,27 @@ class MediaControl:
|
|
|
748
846
|
if queue_position < 0:
|
|
749
847
|
raise WiiMError(f"Invalid queue position: {queue_position} (must be >= 0)")
|
|
750
848
|
|
|
849
|
+
if self._use_playqueue_enqueue():
|
|
850
|
+
try:
|
|
851
|
+
await self._playqueue_call(
|
|
852
|
+
"PlayQueueWithIndex",
|
|
853
|
+
{"QueueName": CURRENT_QUEUE, "Index": queue_position + 1},
|
|
854
|
+
)
|
|
855
|
+
_LOGGER.debug(
|
|
856
|
+
"Started PlayQueue playback at position %d on %s",
|
|
857
|
+
queue_position,
|
|
858
|
+
self.player.host,
|
|
859
|
+
)
|
|
860
|
+
return
|
|
861
|
+
except Exception as err:
|
|
862
|
+
_LOGGER.warning(
|
|
863
|
+
"Failed to play PlayQueue position %d on %s: %s",
|
|
864
|
+
queue_position,
|
|
865
|
+
self.player.host,
|
|
866
|
+
err,
|
|
867
|
+
)
|
|
868
|
+
raise WiiMError(f"Failed to play queue position {queue_position}: {err}") from err
|
|
869
|
+
|
|
751
870
|
try:
|
|
752
871
|
# Use Seek with TRACK_NR unit to jump to queue position
|
|
753
872
|
# UPnP uses 1-based track numbers, so add 1 to 0-based position
|
|
@@ -794,6 +913,33 @@ class MediaControl:
|
|
|
794
913
|
if queue_position < 0:
|
|
795
914
|
raise WiiMError(f"Invalid queue position: {queue_position} (must be >= 0)")
|
|
796
915
|
|
|
916
|
+
if self._use_playqueue_enqueue():
|
|
917
|
+
index = queue_position + 1
|
|
918
|
+
try:
|
|
919
|
+
await self._playqueue_call(
|
|
920
|
+
"RemoveTracksInQueue",
|
|
921
|
+
{
|
|
922
|
+
"QueueName": CURRENT_QUEUE,
|
|
923
|
+
"RangStart": index,
|
|
924
|
+
"RangEnd": index,
|
|
925
|
+
"Action": "",
|
|
926
|
+
},
|
|
927
|
+
)
|
|
928
|
+
_LOGGER.debug(
|
|
929
|
+
"Removed PlayQueue item at position %d from %s",
|
|
930
|
+
queue_position,
|
|
931
|
+
self.player.host,
|
|
932
|
+
)
|
|
933
|
+
return
|
|
934
|
+
except Exception as err:
|
|
935
|
+
_LOGGER.warning(
|
|
936
|
+
"Failed to remove PlayQueue position %d on %s: %s",
|
|
937
|
+
queue_position,
|
|
938
|
+
self.player.host,
|
|
939
|
+
err,
|
|
940
|
+
)
|
|
941
|
+
raise WiiMError(f"Failed to remove queue position {queue_position}: {err}") from err
|
|
942
|
+
|
|
797
943
|
try:
|
|
798
944
|
# RemoveTrackFromQueue uses ObjectID which is typically "Q:0/position"
|
|
799
945
|
# UPnP uses 1-based track numbers
|
|
@@ -835,6 +981,15 @@ class MediaControl:
|
|
|
835
981
|
if not self.player._upnp_client:
|
|
836
982
|
raise WiiMError("Queue management requires UPnP client.")
|
|
837
983
|
|
|
984
|
+
if self._use_playqueue_enqueue():
|
|
985
|
+
try:
|
|
986
|
+
await self._playqueue_call("DeleteQueue", {"QueueName": CURRENT_QUEUE})
|
|
987
|
+
_LOGGER.debug("Cleared PlayQueue on %s", self.player.host)
|
|
988
|
+
return
|
|
989
|
+
except Exception as err:
|
|
990
|
+
_LOGGER.warning("Failed to clear PlayQueue on %s: %s", self.player.host, err)
|
|
991
|
+
raise WiiMError(f"Failed to clear queue: {err}") from err
|
|
992
|
+
|
|
838
993
|
try:
|
|
839
994
|
await self.player._upnp_client.async_call_action(
|
|
840
995
|
"av_transport",
|
|
@@ -9,6 +9,7 @@ from ..device_capabilities import filter_plm_inputs, get_device_inputs
|
|
|
9
9
|
from ..metadata import is_valid_image_url, is_valid_metadata_value
|
|
10
10
|
from ..normalize import HARDWARE_SOURCE_KEYS as _HARDWARE_SOURCE_KEYS
|
|
11
11
|
from ..normalize import canonical_source_key, source_rename_reverse
|
|
12
|
+
from ..upnp.playqueue import playqueue_supports_enqueue, service_has_action
|
|
12
13
|
from .source_capabilities import SourceCapability, get_source_capabilities
|
|
13
14
|
|
|
14
15
|
if TYPE_CHECKING:
|
|
@@ -184,12 +185,10 @@ class PlayerProperties:
|
|
|
184
185
|
Returns True when device has no active media. This indicates the device
|
|
185
186
|
is not playing and has no media to resume.
|
|
186
187
|
|
|
187
|
-
Note: "stop"
|
|
188
|
-
devices are considered paused, not idle. Use is_paused to check for
|
|
189
|
-
both paused and stopped states.
|
|
188
|
+
Note: HTTP/UPnP "stop" and "stopped" normalize to idle.
|
|
190
189
|
"""
|
|
191
190
|
state = self.play_state
|
|
192
|
-
return state is None or state in ("idle", "none")
|
|
191
|
+
return state is None or state in ("idle", "none", "stop", "stopped")
|
|
193
192
|
|
|
194
193
|
@property
|
|
195
194
|
def is_buffering(self) -> bool:
|
|
@@ -239,11 +238,22 @@ class PlayerProperties:
|
|
|
239
238
|
# === Media Metadata ===
|
|
240
239
|
|
|
241
240
|
def _status_field(self, *names: str) -> str | None:
|
|
242
|
-
"""Return the first non-empty attribute from merged state or cached status.
|
|
243
|
-
|
|
244
|
-
|
|
241
|
+
"""Return the first non-empty attribute from merged state or cached status.
|
|
242
|
+
|
|
243
|
+
Only fall back to the raw cached status for names the synchronizer has
|
|
244
|
+
never resolved. If it resolved a name to no value (live stream, source
|
|
245
|
+
change), that decision must not be overridden by leftover firmware
|
|
246
|
+
fields still sitting on ``_status_model`` (wiim #263).
|
|
247
|
+
"""
|
|
248
|
+
state_obj = self.player._state_synchronizer.get_state_object()
|
|
249
|
+
names_unresolved_by_synchronizer: list[str] = []
|
|
245
250
|
for n in names:
|
|
246
|
-
|
|
251
|
+
field = getattr(state_obj, n, None)
|
|
252
|
+
if field is None:
|
|
253
|
+
names_unresolved_by_synchronizer.append(n)
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
val = field.value
|
|
247
257
|
if val is not None:
|
|
248
258
|
# Field-aware filtering: artwork fields must be valid URLs,
|
|
249
259
|
# other metadata must be non-placeholder values.
|
|
@@ -270,11 +280,18 @@ class PlayerProperties:
|
|
|
270
280
|
if val not in (None, ""):
|
|
271
281
|
return str(val) if val is not None else None
|
|
272
282
|
|
|
273
|
-
# Fallback to cached status
|
|
274
|
-
|
|
283
|
+
# Fallback to cached status only before the first merge. After that the
|
|
284
|
+
# raw status model is a leftover firmware snapshot and must not reopen
|
|
285
|
+
# fields the synchronizer already decided are empty (wiim #263).
|
|
286
|
+
if (
|
|
287
|
+
self.player._status_model is None
|
|
288
|
+
or not names_unresolved_by_synchronizer
|
|
289
|
+
or state_obj.http_last_update is not None
|
|
290
|
+
or state_obj.upnp_last_update is not None
|
|
291
|
+
):
|
|
275
292
|
return None
|
|
276
293
|
|
|
277
|
-
for n in
|
|
294
|
+
for n in names_unresolved_by_synchronizer:
|
|
278
295
|
if hasattr(self.player._status_model, n):
|
|
279
296
|
val = getattr(self.player._status_model, n)
|
|
280
297
|
if n in {"image_url", "entity_picture", "cover_url"}:
|
|
@@ -311,6 +328,11 @@ class PlayerProperties:
|
|
|
311
328
|
if title:
|
|
312
329
|
return title
|
|
313
330
|
|
|
331
|
+
# URL filename is only a stand-in for URL playback. Hardware inputs and
|
|
332
|
+
# idle/stop must not keep showing the last play_url() filename (#263).
|
|
333
|
+
if self.is_idle or self._is_physical_input_source():
|
|
334
|
+
return None
|
|
335
|
+
|
|
314
336
|
# Fallback: extract filename from last played URL
|
|
315
337
|
url = self.player._last_played_url
|
|
316
338
|
if url:
|
|
@@ -335,6 +357,26 @@ class PlayerProperties:
|
|
|
335
357
|
|
|
336
358
|
return None
|
|
337
359
|
|
|
360
|
+
def _is_physical_input_source(self) -> bool:
|
|
361
|
+
"""Return True for passthrough inputs that never use play_url() metadata."""
|
|
362
|
+
source = (self.source or "").strip().lower().replace("-", "_")
|
|
363
|
+
return source in {
|
|
364
|
+
"line_in",
|
|
365
|
+
"linein",
|
|
366
|
+
"optical",
|
|
367
|
+
"coaxial",
|
|
368
|
+
"coax",
|
|
369
|
+
"aux",
|
|
370
|
+
"hdmi",
|
|
371
|
+
"phono",
|
|
372
|
+
"rca",
|
|
373
|
+
"line_in_2",
|
|
374
|
+
"linein_2",
|
|
375
|
+
"bluetooth",
|
|
376
|
+
"usb_dac",
|
|
377
|
+
"usbdac",
|
|
378
|
+
}
|
|
379
|
+
|
|
338
380
|
@property
|
|
339
381
|
def media_artist(self) -> str | None:
|
|
340
382
|
"""Current track artist from cached status."""
|
|
@@ -1713,24 +1755,31 @@ class PlayerProperties:
|
|
|
1713
1755
|
|
|
1714
1756
|
@property
|
|
1715
1757
|
def supports_queue_browse(self) -> bool:
|
|
1716
|
-
"""Whether full queue retrieval is available
|
|
1758
|
+
"""Whether full queue retrieval is available.
|
|
1717
1759
|
|
|
1718
|
-
|
|
1719
|
-
|
|
1760
|
+
True when ContentDirectory is present (Amp/Ultra + USB) or when the
|
|
1761
|
+
vendor PlayQueue service can BrowseQueue (WiiM Pro / Pro Plus).
|
|
1720
1762
|
"""
|
|
1721
1763
|
if not self.player._upnp_client:
|
|
1722
1764
|
return False
|
|
1723
|
-
|
|
1765
|
+
if self.player._upnp_client.content_directory is not None:
|
|
1766
|
+
return True
|
|
1767
|
+
return service_has_action(self.player._upnp_client.play_queue, "BrowseQueue")
|
|
1724
1768
|
|
|
1725
1769
|
@property
|
|
1726
1770
|
def supports_queue_add(self) -> bool:
|
|
1727
|
-
"""Whether adding items to queue is supported
|
|
1771
|
+
"""Whether adding items to queue is supported.
|
|
1728
1772
|
|
|
1729
|
-
|
|
1773
|
+
True when AVTransport advertises ``AddURIToQueue`` (Sonos-style) or
|
|
1774
|
+
when PlayQueue advertises CreateQueue + AppendTracksInQueueEx +
|
|
1775
|
+
PlayQueueWithIndex (WiiM Pro / Pro Plus). Presence of AVTransport
|
|
1776
|
+
alone is not enough.
|
|
1730
1777
|
"""
|
|
1731
1778
|
if not self.player._upnp_client:
|
|
1732
1779
|
return False
|
|
1733
|
-
|
|
1780
|
+
if service_has_action(self.player._upnp_client.av_transport, "AddURIToQueue"):
|
|
1781
|
+
return True
|
|
1782
|
+
return playqueue_supports_enqueue(self.player._upnp_client.play_queue)
|
|
1734
1783
|
|
|
1735
1784
|
@property
|
|
1736
1785
|
def supports_queue_count(self) -> bool:
|
|
@@ -670,6 +670,13 @@ class StateManager:
|
|
|
670
670
|
self._last_source = current_source
|
|
671
671
|
elif source_changed:
|
|
672
672
|
self._last_source = current_source
|
|
673
|
+
# play_url() filename fallback must not survive a source switch (#263)
|
|
674
|
+
self.player._last_played_url = None
|
|
675
|
+
if status:
|
|
676
|
+
status.title = None
|
|
677
|
+
status.artist = None
|
|
678
|
+
status.album = None
|
|
679
|
+
status.duration = None
|
|
673
680
|
|
|
674
681
|
# Check if we need to enrich metadata (title/artist are Unknown - common with Bluetooth AVRCP)
|
|
675
682
|
status_title = status.title if status else None
|