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.
Files changed (86) hide show
  1. {pywiim-2.3.2/pywiim.egg-info → pywiim-2.3.4}/PKG-INFO +1 -1
  2. {pywiim-2.3.2 → pywiim-2.3.4}/pyproject.toml +1 -1
  3. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/__init__.py +1 -1
  4. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/__init__.py +6 -2
  5. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/base.py +4 -0
  6. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/media.py +75 -0
  7. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/properties.py +31 -20
  8. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/statemgr.py +48 -6
  9. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/state.py +77 -86
  10. {pywiim-2.3.2 → pywiim-2.3.4/pywiim.egg-info}/PKG-INFO +1 -1
  11. {pywiim-2.3.2 → pywiim-2.3.4}/LICENSE +0 -0
  12. {pywiim-2.3.2 → pywiim-2.3.4}/README.md +0 -0
  13. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/__init__.py +0 -0
  14. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/audio_pro.py +0 -0
  15. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/audio_settings.py +0 -0
  16. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/base.py +0 -0
  17. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/bluetooth.py +0 -0
  18. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/constants.py +0 -0
  19. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/device.py +0 -0
  20. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/diagnostics.py +0 -0
  21. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/endpoints.py +0 -0
  22. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/eq.py +0 -0
  23. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/firmware.py +0 -0
  24. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/group.py +0 -0
  25. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/lms.py +0 -0
  26. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/loop_mode.py +0 -0
  27. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/misc.py +0 -0
  28. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/parser.py +0 -0
  29. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/peq.py +0 -0
  30. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/playback.py +0 -0
  31. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/preset.py +0 -0
  32. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/ssl.py +0 -0
  33. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/subwoofer.py +0 -0
  34. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/api/timer.py +0 -0
  35. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/backoff.py +0 -0
  36. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/capabilities.py +0 -0
  37. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/__init__.py +0 -0
  38. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/diagnostics.py +0 -0
  39. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/discovery_cli.py +0 -0
  40. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/group_test_cli.py +0 -0
  41. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/join_test_cli.py +0 -0
  42. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/monitor_cli.py +0 -0
  43. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/cli/verify_cli.py +0 -0
  44. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/client.py +0 -0
  45. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/device_capabilities.py +0 -0
  46. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/discovery.py +0 -0
  47. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/exceptions.py +0 -0
  48. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/group.py +0 -0
  49. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/group_helpers.py +0 -0
  50. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/mcp/__init__.py +0 -0
  51. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/mcp/__main__.py +0 -0
  52. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/mcp/config.example.json +0 -0
  53. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/mcp/config.py +0 -0
  54. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/mcp/context.py +0 -0
  55. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/mcp/server.py +0 -0
  56. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/metadata.py +0 -0
  57. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/model_names.py +0 -0
  58. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/models.py +0 -0
  59. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/normalize.py +0 -0
  60. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/audio.py +0 -0
  61. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/bluetooth.py +0 -0
  62. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/coverart.py +0 -0
  63. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/debounce.py +0 -0
  64. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/diagnostics.py +0 -0
  65. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/groupops.py +0 -0
  66. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/playback.py +0 -0
  67. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/source_capabilities.py +0 -0
  68. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/stream.py +0 -0
  69. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/stream_enricher.py +0 -0
  70. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/player/volume.py +0 -0
  71. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/polling.py +0 -0
  72. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/profiles.py +0 -0
  73. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/py.typed +0 -0
  74. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/role.py +0 -0
  75. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/upnp/__init__.py +0 -0
  76. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/upnp/client.py +0 -0
  77. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/upnp/eventer.py +0 -0
  78. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/upnp/health.py +0 -0
  79. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/upnp/metadata.py +0 -0
  80. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim/upnp/playqueue.py +0 -0
  81. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim.egg-info/SOURCES.txt +0 -0
  82. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim.egg-info/dependency_links.txt +0 -0
  83. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim.egg-info/entry_points.txt +0 -0
  84. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim.egg-info/requires.txt +0 -0
  85. {pywiim-2.3.2 → pywiim-2.3.4}/pywiim.egg-info/top_level.txt +0 -0
  86. {pywiim-2.3.2 → pywiim-2.3.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywiim
3
- Version: 2.3.2
3
+ Version: 2.3.4
4
4
  Summary: Python library for WiiM/LinkPlay device communication
5
5
  Author-email: Michael Cumming <mjcumming@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pywiim"
7
- version = "2.3.2"
7
+ version = "2.3.4"
8
8
  description = "Python library for WiiM/LinkPlay device communication"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -102,7 +102,7 @@ from .profiles import (
102
102
  from .role import RoleDetectionResult, detect_role
103
103
  from .state import GroupStateSynchronizer, StateSynchronizer
104
104
 
105
- __version__ = "2.3.2"
105
+ __version__ = "2.3.4"
106
106
  __all__ = [
107
107
  # Main client
108
108
  "WiiMClient",
@@ -987,12 +987,16 @@ class Player(PlayerBase):
987
987
 
988
988
  @property
989
989
  def queue_count(self) -> int | None:
990
- """Total number of tracks in queue (from HTTP API plicount field)."""
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 track position in queue (from HTTP API plicurr field)."""
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
- @property
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
- count = getattr(self.player._status_model, "queue_count", None)
489
- if count is not None:
490
- try:
491
- return int(count)
492
- except (TypeError, ValueError):
493
- return None
494
- return None
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 track position in queue (from HTTP API plicurr field)."""
499
- if self.player._status_model is None:
500
- return None
501
- position = getattr(self.player._status_model, "queue_position", None)
502
- if position is not None:
503
- try:
504
- return int(position)
505
- except (TypeError, ValueError):
506
- return None
507
- return None
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 use getStatusEx (device status) instead of
344
- getPlayerStatusEx. Playback state (track, position, etc.) comes from the master
345
- via propagation. We only need volume/mute/group from slaves.
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
- status.mute = status_dict.get("mute")
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
- # play_url() filename fallback must not survive a source switch (#263)
674
- self.player._last_played_url = None
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
- # Extract source — clear track metadata when the input actually changes
376
- source_changed = False
377
- if "source" in data:
378
- new_source = data.get("source")
379
- old_field = self._merged_state.source
380
- old_source = old_field.value if old_field is not None else None
381
- source_changed = self._sources_differ(old_source, new_source)
382
- if source_changed:
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
- # Extract source — clear track metadata when the input actually changes
471
- source_changed = False
472
- if "source" in data:
473
- new_source = data.get("source")
474
- old_field = self._merged_state.source
475
- old_source = old_field.value if old_field is not None else None
476
- source_changed = self._sources_differ(old_source, new_source)
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 _remember_stale_metadata(self, data: dict[str, Any]) -> None:
918
- """Snapshot leftover firmware metadata so later polls cannot re-apply it."""
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 (incoming, merged_val) if v not in (None, "")}
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywiim
3
- Version: 2.3.2
3
+ Version: 2.3.4
4
4
  Summary: Python library for WiiM/LinkPlay device communication
5
5
  Author-email: Michael Cumming <mjcumming@gmail.com>
6
6
  License: MIT
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