pywiim 2.3.2__tar.gz → 2.3.4__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.2/pywiim.egg-info → pywiim-2.3.4}/PKG-INFO +1 -1
- {pywiim-2.3.2 → pywiim-2.3.4}/pyproject.toml +1 -1
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/__init__.py +1 -1
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/__init__.py +6 -2
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/base.py +4 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/media.py +75 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/properties.py +31 -20
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/statemgr.py +48 -6
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/state.py +77 -86
- {pywiim-2.3.2 → pywiim-2.3.4/pywiim.egg-info}/PKG-INFO +1 -1
- {pywiim-2.3.2 → pywiim-2.3.4}/LICENSE +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/README.md +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/__init__.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/audio_pro.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/audio_settings.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/base.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/bluetooth.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/constants.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/device.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/diagnostics.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/endpoints.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/eq.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/firmware.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/group.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/lms.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/loop_mode.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/misc.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/parser.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/peq.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/playback.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/preset.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/ssl.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/subwoofer.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/timer.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/backoff.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/capabilities.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/__init__.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/diagnostics.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/discovery_cli.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/group_test_cli.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/join_test_cli.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/monitor_cli.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/verify_cli.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/client.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/device_capabilities.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/discovery.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/exceptions.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/group.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/group_helpers.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/mcp/__init__.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/mcp/__main__.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/mcp/config.example.json +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/mcp/config.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/mcp/context.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/mcp/server.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/metadata.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/model_names.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/models.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/normalize.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/audio.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/bluetooth.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/coverart.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/debounce.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/diagnostics.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/groupops.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/playback.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/source_capabilities.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/stream.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/stream_enricher.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/volume.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/polling.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/profiles.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/py.typed +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/role.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/upnp/__init__.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/upnp/client.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/upnp/eventer.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/upnp/health.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/upnp/metadata.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/upnp/playqueue.py +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim.egg-info/SOURCES.txt +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim.egg-info/dependency_links.txt +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim.egg-info/entry_points.txt +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim.egg-info/requires.txt +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/pywiim.egg-info/top_level.txt +0 -0
- {pywiim-2.3.2 → pywiim-2.3.4}/setup.cfg +0 -0
|
@@ -987,12 +987,16 @@ class Player(PlayerBase):
|
|
|
987
987
|
|
|
988
988
|
@property
|
|
989
989
|
def queue_count(self) -> int | None:
|
|
990
|
-
"""Total
|
|
990
|
+
"""Total tracks in queue (HTTP plicount, or PlayQueue overlay)."""
|
|
991
991
|
return self._properties.queue_count
|
|
992
992
|
|
|
993
993
|
@property
|
|
994
994
|
def queue_position(self) -> int | None:
|
|
995
|
-
"""Current
|
|
995
|
+
"""Current queue position (HTTP plicurr, or PlayQueue overlay).
|
|
996
|
+
|
|
997
|
+
Overlay is 0-based to match play_queue()/get_queue() item positions.
|
|
998
|
+
HTTP plicount/plicurr stay 0/empty on the PlayQueue path (ADR 004).
|
|
999
|
+
"""
|
|
996
1000
|
return self._properties.queue_position
|
|
997
1001
|
|
|
998
1002
|
@property
|
|
@@ -148,6 +148,10 @@ class PlayerBase:
|
|
|
148
148
|
# Last played URL tracking (for media_title fallback)
|
|
149
149
|
self._last_played_url: str | None = None
|
|
150
150
|
|
|
151
|
+
# PlayQueue overlay when HTTP plicount/plicurr stay empty (ADR 004)
|
|
152
|
+
self._playqueue_count: int | None = None
|
|
153
|
+
self._playqueue_position: int | None = None
|
|
154
|
+
|
|
151
155
|
# Cover art cache (in-memory, keyed by URL hash)
|
|
152
156
|
# Format: {url_hash: (image_bytes, content_type, timestamp)}
|
|
153
157
|
self._cover_art_cache: dict[str, tuple[bytes, str, float]] = {}
|
|
@@ -8,6 +8,7 @@ from html import unescape
|
|
|
8
8
|
from typing import TYPE_CHECKING, Any, Literal
|
|
9
9
|
from xml.etree import ElementTree as ET
|
|
10
10
|
|
|
11
|
+
from ..api.loop_mode import resolve_loop_mode_mapping_for_player
|
|
11
12
|
from ..exceptions import WiiMError
|
|
12
13
|
from ..upnp.playqueue import (
|
|
13
14
|
CURRENT_QUEUE,
|
|
@@ -507,10 +508,76 @@ class MediaControl:
|
|
|
507
508
|
},
|
|
508
509
|
)
|
|
509
510
|
|
|
511
|
+
def _playqueue_loop_mode(self) -> int:
|
|
512
|
+
"""PlayQueue LoopMode: 4 = play through once; map shuffle/repeat when set."""
|
|
513
|
+
try:
|
|
514
|
+
mapping = resolve_loop_mode_mapping_for_player(self.player)
|
|
515
|
+
shuffle = bool(self.player.shuffle_state)
|
|
516
|
+
repeat = (self.player.repeat_mode or "off").lower()
|
|
517
|
+
return mapping.to_loop_mode(
|
|
518
|
+
shuffle=shuffle,
|
|
519
|
+
repeat_one=repeat == "one",
|
|
520
|
+
repeat_all=repeat == "all",
|
|
521
|
+
)
|
|
522
|
+
except Exception:
|
|
523
|
+
return 4
|
|
524
|
+
|
|
525
|
+
async def _playqueue_apply_loop_mode(self) -> None:
|
|
526
|
+
"""Enable PlayQueue auto-advance via SetQueueLoopMode if advertised."""
|
|
527
|
+
upnp = self.player._upnp_client
|
|
528
|
+
if not upnp or not service_has_action(upnp.play_queue, "SetQueueLoopMode"):
|
|
529
|
+
return
|
|
530
|
+
mode = self._playqueue_loop_mode()
|
|
531
|
+
try:
|
|
532
|
+
await self._playqueue_call("SetQueueLoopMode", {"LoopMode": mode})
|
|
533
|
+
_LOGGER.debug("Set PlayQueue loop mode %s on %s", mode, self.player.host)
|
|
534
|
+
except Exception as err:
|
|
535
|
+
_LOGGER.debug("SetQueueLoopMode failed on %s: %s", self.player.host, err)
|
|
536
|
+
|
|
537
|
+
def _playqueue_set_overlay(self, *, count: int | None = None, position: int | None = None) -> None:
|
|
538
|
+
if count is not None:
|
|
539
|
+
self.player._playqueue_count = count
|
|
540
|
+
if position is not None:
|
|
541
|
+
self.player._playqueue_position = position
|
|
542
|
+
|
|
543
|
+
async def _playqueue_sync_overlay(self, *, items: list[dict[str, Any]] | None = None) -> None:
|
|
544
|
+
"""Refresh overlay from GetQueueIndex / BrowseQueue (ADR 004)."""
|
|
545
|
+
if items is not None:
|
|
546
|
+
self._playqueue_set_overlay(count=len(items))
|
|
547
|
+
try:
|
|
548
|
+
result = await self._playqueue_call("GetQueueIndex", {"QueueName": CURRENT_QUEUE})
|
|
549
|
+
except Exception as err:
|
|
550
|
+
_LOGGER.debug("GetQueueIndex overlay failed on %s: %s", self.player.host, err)
|
|
551
|
+
result = None
|
|
552
|
+
if result:
|
|
553
|
+
if items is None:
|
|
554
|
+
track_nums = result.get("TrackNums")
|
|
555
|
+
if track_nums is not None:
|
|
556
|
+
try:
|
|
557
|
+
self._playqueue_set_overlay(count=int(track_nums))
|
|
558
|
+
except (TypeError, ValueError):
|
|
559
|
+
pass
|
|
560
|
+
current = result.get("CurrentIndex")
|
|
561
|
+
if current is not None:
|
|
562
|
+
try:
|
|
563
|
+
idx = int(current)
|
|
564
|
+
# CurrentIndex is 1-based
|
|
565
|
+
self._playqueue_set_overlay(position=max(idx - 1, 0) if idx > 0 else None)
|
|
566
|
+
except (TypeError, ValueError):
|
|
567
|
+
pass
|
|
568
|
+
if items is None and self.player._playqueue_count is None:
|
|
569
|
+
try:
|
|
570
|
+
result = await self._playqueue_call("BrowseQueue", {"QueueName": CURRENT_QUEUE})
|
|
571
|
+
parsed = parse_queue_context((result or {}).get("QueueContext", "") or "")
|
|
572
|
+
self._playqueue_set_overlay(count=len(parsed))
|
|
573
|
+
except Exception as err:
|
|
574
|
+
_LOGGER.debug("BrowseQueue overlay failed on %s: %s", self.player.host, err)
|
|
575
|
+
|
|
510
576
|
async def _playqueue_add(self, url: str, metadata: str = "") -> None:
|
|
511
577
|
"""Add URL to the end of CurrentQueue without starting playback."""
|
|
512
578
|
await self._playqueue_ensure()
|
|
513
579
|
await self._playqueue_append(url, metadata)
|
|
580
|
+
await self._playqueue_sync_overlay()
|
|
514
581
|
_LOGGER.debug("Added URL to PlayQueue on %s", self.player.host)
|
|
515
582
|
|
|
516
583
|
async def _playqueue_insert_next(self, url: str, metadata: str = "") -> None:
|
|
@@ -525,6 +592,7 @@ class MediaControl:
|
|
|
525
592
|
except Exception as err:
|
|
526
593
|
_LOGGER.debug("GetQueueIndex failed on %s, appending after index 1: %s", self.player.host, err)
|
|
527
594
|
await self._playqueue_append(url, metadata, start_index=start_index)
|
|
595
|
+
await self._playqueue_sync_overlay()
|
|
528
596
|
_LOGGER.debug("Inserted URL next in PlayQueue on %s", self.player.host)
|
|
529
597
|
|
|
530
598
|
async def _playqueue_get(self) -> list[dict[str, Any]]:
|
|
@@ -535,6 +603,7 @@ class MediaControl:
|
|
|
535
603
|
_LOGGER.debug("PlayQueue BrowseQueue empty or failed on %s: %s", self.player.host, err)
|
|
536
604
|
return []
|
|
537
605
|
items = parse_queue_context((result or {}).get("QueueContext", "") or "")
|
|
606
|
+
await self._playqueue_sync_overlay(items=items)
|
|
538
607
|
_LOGGER.debug("Retrieved %d PlayQueue items from %s", len(items), self.player.host)
|
|
539
608
|
return items
|
|
540
609
|
|
|
@@ -852,6 +921,9 @@ class MediaControl:
|
|
|
852
921
|
"PlayQueueWithIndex",
|
|
853
922
|
{"QueueName": CURRENT_QUEUE, "Index": queue_position + 1},
|
|
854
923
|
)
|
|
924
|
+
await self._playqueue_apply_loop_mode()
|
|
925
|
+
self._playqueue_set_overlay(position=queue_position)
|
|
926
|
+
await self._playqueue_sync_overlay()
|
|
855
927
|
_LOGGER.debug(
|
|
856
928
|
"Started PlayQueue playback at position %d on %s",
|
|
857
929
|
queue_position,
|
|
@@ -925,6 +997,7 @@ class MediaControl:
|
|
|
925
997
|
"Action": "",
|
|
926
998
|
},
|
|
927
999
|
)
|
|
1000
|
+
await self._playqueue_sync_overlay()
|
|
928
1001
|
_LOGGER.debug(
|
|
929
1002
|
"Removed PlayQueue item at position %d from %s",
|
|
930
1003
|
queue_position,
|
|
@@ -984,6 +1057,8 @@ class MediaControl:
|
|
|
984
1057
|
if self._use_playqueue_enqueue():
|
|
985
1058
|
try:
|
|
986
1059
|
await self._playqueue_call("DeleteQueue", {"QueueName": CURRENT_QUEUE})
|
|
1060
|
+
self.player._playqueue_count = 0
|
|
1061
|
+
self.player._playqueue_position = None
|
|
987
1062
|
_LOGGER.debug("Cleared PlayQueue on %s", self.player.host)
|
|
988
1063
|
return
|
|
989
1064
|
except Exception as err:
|
|
@@ -480,31 +480,42 @@ class PlayerProperties:
|
|
|
480
480
|
|
|
481
481
|
return DEFAULT_WIIM_LOGO_URL
|
|
482
482
|
|
|
483
|
-
|
|
484
|
-
def queue_count(self) -> int | None:
|
|
485
|
-
"""Total number of tracks in queue (from HTTP API plicount field)."""
|
|
483
|
+
def _http_queue_int(self, field: str) -> int | None:
|
|
486
484
|
if self.player._status_model is None:
|
|
487
485
|
return None
|
|
488
|
-
|
|
489
|
-
if
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
486
|
+
value = getattr(self.player._status_model, field, None)
|
|
487
|
+
if value is None:
|
|
488
|
+
return None
|
|
489
|
+
try:
|
|
490
|
+
return int(value)
|
|
491
|
+
except (TypeError, ValueError):
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
@property
|
|
495
|
+
def queue_count(self) -> int | None:
|
|
496
|
+
"""Total tracks in queue (HTTP plicount, or PlayQueue overlay)."""
|
|
497
|
+
http = self._http_queue_int("queue_count")
|
|
498
|
+
overlay = self.player._playqueue_count
|
|
499
|
+
if http:
|
|
500
|
+
return http
|
|
501
|
+
if overlay is not None:
|
|
502
|
+
return overlay
|
|
503
|
+
return http
|
|
495
504
|
|
|
496
505
|
@property
|
|
497
506
|
def queue_position(self) -> int | None:
|
|
498
|
-
"""Current
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
507
|
+
"""Current queue position (HTTP plicurr, or PlayQueue overlay).
|
|
508
|
+
|
|
509
|
+
Overlay is 0-based to match play_queue()/get_queue() item positions.
|
|
510
|
+
HTTP plicount/plicurr stay 0/empty on the PlayQueue path (ADR 004).
|
|
511
|
+
"""
|
|
512
|
+
http = self._http_queue_int("queue_position")
|
|
513
|
+
overlay = self.player._playqueue_position
|
|
514
|
+
if http:
|
|
515
|
+
return http
|
|
516
|
+
if overlay is not None:
|
|
517
|
+
return overlay
|
|
518
|
+
return http
|
|
508
519
|
|
|
509
520
|
@property
|
|
510
521
|
def media_sample_rate(self) -> int | None:
|
|
@@ -37,6 +37,19 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
37
37
|
# UPnP retry cooldown - wait this many seconds between failed creation attempts
|
|
38
38
|
UPNP_RETRY_COOLDOWN = 60.0
|
|
39
39
|
|
|
40
|
+
# Sources where play_url() filename fallback is the intended title.
|
|
41
|
+
# WiiM reports URL playback as custompushurl (display name "URL Stream").
|
|
42
|
+
_URL_PLAYBACK_SOURCES = frozenset({"wifi", "network", "ethernet", "usb", "udisk", "http", "custompushurl"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_url_playback_source(source: Any) -> bool:
|
|
46
|
+
"""Return True for URL/network playback sources (keep play_url filename)."""
|
|
47
|
+
if source is None:
|
|
48
|
+
return False
|
|
49
|
+
key = str(source).strip().lower().replace("-", "_").replace(" ", "_")
|
|
50
|
+
return key in _URL_PLAYBACK_SOURCES or key in {"wi_fi"}
|
|
51
|
+
|
|
52
|
+
|
|
40
53
|
# Standard sources that shouldn't be cleared when checking for multiroom/master names
|
|
41
54
|
STANDARD_SOURCES = {
|
|
42
55
|
"spotify",
|
|
@@ -340,9 +353,11 @@ class StateManager:
|
|
|
340
353
|
async def _refresh_core_status(self) -> PlayerStatus:
|
|
341
354
|
"""Fetch core player status and update state synchronizer.
|
|
342
355
|
|
|
343
|
-
For slaves in multi-room groups, we
|
|
344
|
-
|
|
345
|
-
via propagation.
|
|
356
|
+
For slaves in multi-room groups, we poll the capability-configured status
|
|
357
|
+
endpoint for volume/mute/group only. Playback state (track, position, etc.)
|
|
358
|
+
comes from the master via propagation. Volume and mute are written to the
|
|
359
|
+
state synchronizer so ``Player.volume_level`` / ``is_muted`` reflect device
|
|
360
|
+
changes (physical buttons, app) on the next poll.
|
|
346
361
|
|
|
347
362
|
Returns:
|
|
348
363
|
PlayerStatus model from device.
|
|
@@ -380,12 +395,18 @@ class StateManager:
|
|
|
380
395
|
status = PlayerStatus.model_construct() # Empty status, fields populated below
|
|
381
396
|
|
|
382
397
|
# Update volume/mute/group from device status (these are slave-specific)
|
|
398
|
+
observed_volume = False
|
|
399
|
+
observed_mute = False
|
|
383
400
|
if "volume" in status_dict:
|
|
384
401
|
vol = status_dict.get("volume")
|
|
385
402
|
if vol is not None:
|
|
386
403
|
status.volume = int(vol) if not isinstance(vol, int) else vol
|
|
404
|
+
observed_volume = True
|
|
387
405
|
if "mute" in status_dict:
|
|
388
|
-
|
|
406
|
+
mute_val = status_dict.get("mute")
|
|
407
|
+
if mute_val is not None:
|
|
408
|
+
status.mute = mute_val
|
|
409
|
+
observed_mute = True
|
|
389
410
|
if "group" in status_dict:
|
|
390
411
|
status.group = status_dict.get("group")
|
|
391
412
|
|
|
@@ -402,12 +423,30 @@ class StateManager:
|
|
|
402
423
|
mute_from_upnp = await self.player._upnp_client.get_mute()
|
|
403
424
|
if volume_from_upnp is not None:
|
|
404
425
|
status.volume = volume_from_upnp
|
|
426
|
+
observed_volume = True
|
|
405
427
|
if mute_from_upnp is not None:
|
|
406
428
|
status.mute = mute_from_upnp
|
|
429
|
+
observed_mute = True
|
|
407
430
|
except Exception as err:
|
|
408
431
|
_LOGGER.debug("UPnP GetVolume failed for slave %s: %s", self.player.host, err)
|
|
409
432
|
|
|
410
433
|
self.player._status_model = status
|
|
434
|
+
|
|
435
|
+
# volume_level / is_muted read the synchronizer first. Physical-button
|
|
436
|
+
# and app changes on a slave only arrive via this poll (HA does not
|
|
437
|
+
# subscribe to UPnP events), so we must push slave-local volume/mute
|
|
438
|
+
# without playback/metadata (those stay master-propagated).
|
|
439
|
+
# Only stamp fields observed this cycle so a missing reading cannot
|
|
440
|
+
# refresh a stale cache timestamp over a newer UPnP event.
|
|
441
|
+
# See: https://github.com/mjcumming/wiim/issues/269
|
|
442
|
+
http_update: dict[str, Any] = {}
|
|
443
|
+
if observed_volume and status.volume is not None:
|
|
444
|
+
http_update["volume"] = status.volume
|
|
445
|
+
if observed_mute and status.mute is not None:
|
|
446
|
+
http_update["muted"] = status.mute
|
|
447
|
+
if http_update:
|
|
448
|
+
self.player._state_synchronizer.update_from_http(http_update)
|
|
449
|
+
|
|
411
450
|
self.player._last_refresh = time.time()
|
|
412
451
|
self.player._available = True
|
|
413
452
|
return status
|
|
@@ -670,8 +709,11 @@ class StateManager:
|
|
|
670
709
|
self._last_source = current_source
|
|
671
710
|
elif source_changed:
|
|
672
711
|
self._last_source = current_source
|
|
673
|
-
#
|
|
674
|
-
|
|
712
|
+
# Filename fallback is for URL/network playback. Keep it when
|
|
713
|
+
# switching *to* wifi/network (play_url just changed the source).
|
|
714
|
+
# Clear it for physical inputs and other sources (#263).
|
|
715
|
+
if not _is_url_playback_source(current_source):
|
|
716
|
+
self.player._last_played_url = None
|
|
675
717
|
if status:
|
|
676
718
|
status.title = None
|
|
677
719
|
status.artist = None
|
|
@@ -347,13 +347,6 @@ class StateSynchronizer:
|
|
|
347
347
|
timestamp=ts,
|
|
348
348
|
)
|
|
349
349
|
|
|
350
|
-
if "duration" in data and not self._is_leftover_after_source_change("duration", data.get("duration")):
|
|
351
|
-
self._http_state["duration"] = TimestampedField(
|
|
352
|
-
value=data.get("duration"),
|
|
353
|
-
source=source,
|
|
354
|
-
timestamp=ts,
|
|
355
|
-
)
|
|
356
|
-
|
|
357
350
|
# Extract volume and mute
|
|
358
351
|
# Only update if value is not None (preserve existing values when API doesn't return volume)
|
|
359
352
|
# This prevents clearing volume when some devices (e.g., Audio Pro) don't return volume
|
|
@@ -372,40 +365,14 @@ class StateSynchronizer:
|
|
|
372
365
|
timestamp=ts,
|
|
373
366
|
)
|
|
374
367
|
|
|
375
|
-
#
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
self._remember_stale_metadata(data)
|
|
384
|
-
self._clear_track_metadata()
|
|
385
|
-
self._http_state["source"] = TimestampedField(
|
|
386
|
-
value=new_source,
|
|
387
|
-
source=source,
|
|
388
|
-
timestamp=ts,
|
|
389
|
-
)
|
|
390
|
-
|
|
391
|
-
self._release_stale_guard_if_fresh_track(data)
|
|
392
|
-
|
|
393
|
-
# Extract metadata (preserve if playing)
|
|
394
|
-
if not source_changed and (force_metadata_update or not self._should_clear_metadata()):
|
|
395
|
-
for field_name in ["title", "artist", "album", "image_url"]:
|
|
396
|
-
if field_name in data:
|
|
397
|
-
value = data.get(field_name)
|
|
398
|
-
if self._is_leftover_after_source_change(field_name, value):
|
|
399
|
-
continue
|
|
400
|
-
# Always update if field is present in data (even if None)
|
|
401
|
-
# This ensures metadata gets populated when available
|
|
402
|
-
# If value is None/empty, we still update to track that HTTP doesn't have it
|
|
403
|
-
# (but existing metadata from other sources will be preserved via merge)
|
|
404
|
-
self._http_state[field_name] = TimestampedField(
|
|
405
|
-
value=value,
|
|
406
|
-
source=source,
|
|
407
|
-
timestamp=ts,
|
|
408
|
-
)
|
|
368
|
+
# Source first so leftover-guard is set before duration/title are applied.
|
|
369
|
+
self._apply_source_and_track_fields(
|
|
370
|
+
data,
|
|
371
|
+
self._http_state,
|
|
372
|
+
source,
|
|
373
|
+
ts,
|
|
374
|
+
force_metadata_update=force_metadata_update,
|
|
375
|
+
)
|
|
409
376
|
|
|
410
377
|
self._merged_state.http_last_update = ts
|
|
411
378
|
self._merge_state()
|
|
@@ -445,13 +412,6 @@ class StateSynchronizer:
|
|
|
445
412
|
timestamp=ts,
|
|
446
413
|
)
|
|
447
414
|
|
|
448
|
-
if "duration" in data and not self._is_leftover_after_source_change("duration", data.get("duration")):
|
|
449
|
-
self._upnp_state["duration"] = TimestampedField(
|
|
450
|
-
value=data.get("duration"),
|
|
451
|
-
source="upnp",
|
|
452
|
-
timestamp=ts,
|
|
453
|
-
)
|
|
454
|
-
|
|
455
415
|
# Extract volume and mute
|
|
456
416
|
if "volume" in data:
|
|
457
417
|
self._upnp_state["volume"] = TimestampedField(
|
|
@@ -467,40 +427,13 @@ class StateSynchronizer:
|
|
|
467
427
|
timestamp=ts,
|
|
468
428
|
)
|
|
469
429
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
if source_changed:
|
|
478
|
-
self._remember_stale_metadata(data)
|
|
479
|
-
self._clear_track_metadata()
|
|
480
|
-
self._upnp_state["source"] = TimestampedField(
|
|
481
|
-
value=new_source,
|
|
482
|
-
source="upnp",
|
|
483
|
-
timestamp=ts,
|
|
484
|
-
)
|
|
485
|
-
|
|
486
|
-
self._release_stale_guard_if_fresh_track(data)
|
|
487
|
-
|
|
488
|
-
# Extract metadata (preserve if playing)
|
|
489
|
-
if not source_changed and (force_metadata_update or not self._should_clear_metadata()):
|
|
490
|
-
for field_name in ["title", "artist", "album", "image_url"]:
|
|
491
|
-
if field_name in data:
|
|
492
|
-
value = data.get(field_name)
|
|
493
|
-
if self._is_leftover_after_source_change(field_name, value):
|
|
494
|
-
continue
|
|
495
|
-
# Always update if field is present in data (even if None)
|
|
496
|
-
# This ensures metadata gets populated when available
|
|
497
|
-
# If value is None/empty, we still update to track that UPnP doesn't have it
|
|
498
|
-
# (but existing metadata from other sources will be preserved via merge)
|
|
499
|
-
self._upnp_state[field_name] = TimestampedField(
|
|
500
|
-
value=value,
|
|
501
|
-
source="upnp",
|
|
502
|
-
timestamp=ts,
|
|
503
|
-
)
|
|
430
|
+
self._apply_source_and_track_fields(
|
|
431
|
+
data,
|
|
432
|
+
self._upnp_state,
|
|
433
|
+
"upnp",
|
|
434
|
+
ts,
|
|
435
|
+
force_metadata_update=force_metadata_update,
|
|
436
|
+
)
|
|
504
437
|
|
|
505
438
|
self._merged_state.upnp_last_update = ts
|
|
506
439
|
self._merge_state()
|
|
@@ -914,14 +847,72 @@ class StateSynchronizer:
|
|
|
914
847
|
new_n = str(new).strip().lower().replace("_", "-").replace(" ", "-")
|
|
915
848
|
return bool(old_n and new_n and old_n != new_n)
|
|
916
849
|
|
|
917
|
-
def
|
|
918
|
-
|
|
850
|
+
def _apply_source_and_track_fields(
|
|
851
|
+
self,
|
|
852
|
+
data: dict[str, Any],
|
|
853
|
+
dest: dict[str, TimestampedField],
|
|
854
|
+
source: str,
|
|
855
|
+
ts: float,
|
|
856
|
+
*,
|
|
857
|
+
force_metadata_update: bool,
|
|
858
|
+
) -> None:
|
|
859
|
+
"""Apply source, then duration/title so leftover-guard is already set.
|
|
860
|
+
|
|
861
|
+
On a source change, snapshot only the *pre-switch* merged track and then
|
|
862
|
+
still accept incoming fields that are not that leftover. Skipping the
|
|
863
|
+
transition poll (and snapshotting incoming) hid Spotify title/artist
|
|
864
|
+
until the next track (wiim #263).
|
|
865
|
+
"""
|
|
866
|
+
source_changed = False
|
|
867
|
+
if "source" in data:
|
|
868
|
+
new_source = data.get("source")
|
|
869
|
+
old_field = self._merged_state.source
|
|
870
|
+
old_source = old_field.value if old_field is not None else None
|
|
871
|
+
source_changed = self._sources_differ(old_source, new_source)
|
|
872
|
+
if source_changed:
|
|
873
|
+
self._remember_stale_metadata()
|
|
874
|
+
self._clear_track_metadata()
|
|
875
|
+
dest["source"] = TimestampedField(
|
|
876
|
+
value=new_source,
|
|
877
|
+
source=source,
|
|
878
|
+
timestamp=ts,
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
self._release_stale_guard_if_fresh_track(data)
|
|
882
|
+
|
|
883
|
+
if "duration" in data and not self._is_leftover_after_source_change("duration", data.get("duration")):
|
|
884
|
+
dest["duration"] = TimestampedField(
|
|
885
|
+
value=data.get("duration"),
|
|
886
|
+
source=source,
|
|
887
|
+
timestamp=ts,
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
apply_metadata = force_metadata_update or source_changed or not self._should_clear_metadata()
|
|
891
|
+
if not apply_metadata:
|
|
892
|
+
return
|
|
893
|
+
for field_name in ("title", "artist", "album", "image_url"):
|
|
894
|
+
if field_name not in data:
|
|
895
|
+
continue
|
|
896
|
+
value = data.get(field_name)
|
|
897
|
+
if self._is_leftover_after_source_change(field_name, value):
|
|
898
|
+
continue
|
|
899
|
+
dest[field_name] = TimestampedField(
|
|
900
|
+
value=value,
|
|
901
|
+
source=source,
|
|
902
|
+
timestamp=ts,
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
def _remember_stale_metadata(self) -> None:
|
|
906
|
+
"""Snapshot pre-switch merged metadata so later polls cannot re-apply it.
|
|
907
|
+
|
|
908
|
+
Incoming values on the same poll may already be the new track
|
|
909
|
+
(network stream → Spotify) and must not be added to the leftover set.
|
|
910
|
+
"""
|
|
919
911
|
stale: dict[str, set[Any]] = {}
|
|
920
912
|
for field_name in ("title", "artist", "album", "image_url", "duration"):
|
|
921
|
-
incoming = data.get(field_name)
|
|
922
913
|
merged_field = getattr(self._merged_state, field_name, None)
|
|
923
914
|
merged_val = merged_field.value if merged_field is not None else None
|
|
924
|
-
values = {v for v in (
|
|
915
|
+
values = {v for v in (merged_val,) if v not in (None, "")}
|
|
925
916
|
stale[field_name] = values
|
|
926
917
|
self._stale_metadata_after_source = stale
|
|
927
918
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|