pywiim 2.2.6__tar.gz → 2.2.7__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 (85) hide show
  1. {pywiim-2.2.6/pywiim.egg-info → pywiim-2.2.7}/PKG-INFO +1 -1
  2. {pywiim-2.2.6 → pywiim-2.2.7}/pyproject.toml +1 -1
  3. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/__init__.py +1 -1
  4. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/coverart.py +27 -21
  5. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/statemgr.py +1 -5
  6. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/upnp/client.py +5 -4
  7. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/upnp/metadata.py +4 -1
  8. {pywiim-2.2.6 → pywiim-2.2.7/pywiim.egg-info}/PKG-INFO +1 -1
  9. {pywiim-2.2.6 → pywiim-2.2.7}/LICENSE +0 -0
  10. {pywiim-2.2.6 → pywiim-2.2.7}/README.md +0 -0
  11. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/__init__.py +0 -0
  12. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/audio_pro.py +0 -0
  13. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/audio_settings.py +0 -0
  14. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/base.py +0 -0
  15. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/bluetooth.py +0 -0
  16. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/constants.py +0 -0
  17. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/device.py +0 -0
  18. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/diagnostics.py +0 -0
  19. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/endpoints.py +0 -0
  20. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/eq.py +0 -0
  21. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/firmware.py +0 -0
  22. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/group.py +0 -0
  23. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/lms.py +0 -0
  24. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/loop_mode.py +0 -0
  25. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/misc.py +0 -0
  26. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/parser.py +0 -0
  27. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/peq.py +0 -0
  28. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/playback.py +0 -0
  29. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/preset.py +0 -0
  30. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/ssl.py +0 -0
  31. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/subwoofer.py +0 -0
  32. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/api/timer.py +0 -0
  33. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/backoff.py +0 -0
  34. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/capabilities.py +0 -0
  35. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/cli/__init__.py +0 -0
  36. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/cli/diagnostics.py +0 -0
  37. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/cli/discovery_cli.py +0 -0
  38. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/cli/group_test_cli.py +0 -0
  39. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/cli/join_test_cli.py +0 -0
  40. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/cli/monitor_cli.py +0 -0
  41. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/cli/verify_cli.py +0 -0
  42. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/client.py +0 -0
  43. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/device_capabilities.py +0 -0
  44. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/discovery.py +0 -0
  45. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/exceptions.py +0 -0
  46. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/group.py +0 -0
  47. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/group_helpers.py +0 -0
  48. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/mcp/__init__.py +0 -0
  49. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/mcp/__main__.py +0 -0
  50. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/mcp/config.example.json +0 -0
  51. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/mcp/config.py +0 -0
  52. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/mcp/context.py +0 -0
  53. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/mcp/server.py +0 -0
  54. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/metadata.py +0 -0
  55. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/model_names.py +0 -0
  56. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/models.py +0 -0
  57. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/normalize.py +0 -0
  58. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/__init__.py +0 -0
  59. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/audio.py +0 -0
  60. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/base.py +0 -0
  61. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/bluetooth.py +0 -0
  62. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/debounce.py +0 -0
  63. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/diagnostics.py +0 -0
  64. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/groupops.py +0 -0
  65. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/media.py +0 -0
  66. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/playback.py +0 -0
  67. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/properties.py +0 -0
  68. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/source_capabilities.py +0 -0
  69. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/stream.py +0 -0
  70. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/stream_enricher.py +0 -0
  71. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/player/volume.py +0 -0
  72. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/polling.py +0 -0
  73. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/profiles.py +0 -0
  74. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/py.typed +0 -0
  75. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/role.py +0 -0
  76. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/state.py +0 -0
  77. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/upnp/__init__.py +0 -0
  78. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/upnp/eventer.py +0 -0
  79. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim/upnp/health.py +0 -0
  80. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim.egg-info/SOURCES.txt +0 -0
  81. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim.egg-info/dependency_links.txt +0 -0
  82. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim.egg-info/entry_points.txt +0 -0
  83. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim.egg-info/requires.txt +0 -0
  84. {pywiim-2.2.6 → pywiim-2.2.7}/pywiim.egg-info/top_level.txt +0 -0
  85. {pywiim-2.2.6 → pywiim-2.2.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywiim
3
- Version: 2.2.6
3
+ Version: 2.2.7
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.2.6"
7
+ version = "2.2.7"
8
8
  description = "Python library for WiiM/LinkPlay device communication"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -87,7 +87,7 @@ from .profiles import (
87
87
  from .role import RoleDetectionResult, detect_role
88
88
  from .state import GroupStateSynchronizer, StateSynchronizer
89
89
 
90
- __version__ = "2.2.6"
90
+ __version__ = "2.2.7"
91
91
  __all__ = [
92
92
  # Main client
93
93
  "WiiMClient",
@@ -244,10 +244,14 @@ class CoverArtManager:
244
244
  current_signature and self._last_track_signature and current_signature != self._last_track_signature
245
245
  )
246
246
 
247
- # Check if metadata needs enrichment (title/artist/album are Unknown)
247
+ # Check if metadata needs enrichment (title/artist/album are present but Unknown)
248
248
  # This is common with Bluetooth AVRCP where getPlayerStatusEx returns "Unknown"
249
249
  # but getMetaInfo has the actual track info
250
- needs_metadata_enrichment = is_invalid_metadata(title) or is_invalid_metadata(artist)
250
+ needs_metadata_enrichment = (
251
+ ("title" in merged_state and is_invalid_metadata(title))
252
+ or ("artist" in merged_state and is_invalid_metadata(artist))
253
+ or ("album" in merged_state and is_invalid_metadata(album))
254
+ )
251
255
 
252
256
  if track_changed:
253
257
  self._last_track_signature = current_signature
@@ -270,29 +274,30 @@ class CoverArtManager:
270
274
  has_track_metadata = bool(title or artist or album)
271
275
  needs_artwork_enrichment = not has_valid_artwork and has_track_metadata
272
276
  should_fetch_metadata = (
273
- (track_changed and not has_valid_artwork)
274
- or needs_metadata_enrichment
275
- or needs_artwork_enrichment
277
+ (track_changed and not has_valid_artwork) or needs_metadata_enrichment or needs_artwork_enrichment
276
278
  )
277
279
 
278
280
  if should_fetch_metadata:
279
281
  # Cancel any existing fetch task
280
- if self._artwork_fetch_task and not self._artwork_fetch_task.done():
282
+ current_task = asyncio.current_task()
283
+ if (
284
+ self._artwork_fetch_task
285
+ and not self._artwork_fetch_task.done()
286
+ and self._artwork_fetch_task is not current_task
287
+ ):
281
288
  self._artwork_fetch_task.cancel()
282
289
 
283
- # Start background task to fetch metadata/artwork
284
- try:
285
- loop = asyncio.get_event_loop()
286
- self._artwork_fetch_task = loop.create_task(self._fetch_artwork_from_metainfo(merged_state))
287
- if needs_metadata_enrichment:
288
- _LOGGER.debug("Metadata is Unknown, fetching enrichment sources")
289
- elif needs_artwork_enrichment:
290
- _LOGGER.debug("Artwork missing with track metadata, fetching enrichment sources")
291
- else:
292
- _LOGGER.debug("Track changed, fetching enrichment sources")
293
- except RuntimeError:
294
- # No event loop available (sync context) - will fetch on next poll
295
- _LOGGER.debug("No event loop available, metadata will be fetched on next poll")
290
+ if current_task:
291
+ self._artwork_fetch_task = current_task
292
+
293
+ if needs_metadata_enrichment:
294
+ _LOGGER.debug("Metadata is Unknown, fetching enrichment sources")
295
+ elif needs_artwork_enrichment:
296
+ _LOGGER.debug("Artwork missing with track metadata, fetching enrichment sources")
297
+ else:
298
+ _LOGGER.debug("Track changed, fetching enrichment sources")
299
+
300
+ await self._fetch_artwork_from_metainfo(merged_state)
296
301
 
297
302
  if not self._last_track_signature and current_signature:
298
303
  # First track detected
@@ -347,6 +352,7 @@ class CoverArtManager:
347
352
  encoded = quote(cache_key)
348
353
  sep = "&" if "?" in str(artwork_url) else "?"
349
354
  artwork_url = f"{artwork_url}{sep}cache={encoded}"
355
+ update["image_url"] = artwork_url
350
356
  update["entity_picture"] = artwork_url
351
357
 
352
358
  if update:
@@ -367,7 +373,7 @@ class CoverArtManager:
367
373
  self.player._status_model.artist = merged.get("artist")
368
374
  if "album" in update:
369
375
  self.player._status_model.album = merged.get("album")
370
- if "entity_picture" in update:
376
+ if "image_url" in update or "entity_picture" in update:
371
377
  image_url = merged.get("image_url")
372
378
  self.player._status_model.entity_picture = image_url
373
379
  self.player._status_model.cover_url = image_url
@@ -427,7 +433,7 @@ class CoverArtManager:
427
433
  if update:
428
434
  self._apply_metadata_update(update, source_name="getMetaInfo")
429
435
 
430
- if not update.get("entity_picture"):
436
+ if not (update.get("image_url") or update.get("entity_picture")):
431
437
  await self._fetch_artwork_from_getinfoex(merged_state)
432
438
  except asyncio.CancelledError:
433
439
  # Task was cancelled (new track change detected)
@@ -983,11 +983,7 @@ class StateManager:
983
983
 
984
984
  # HTTP polling path: enrich artwork/metadata when missing (e.g. Arylic GetInfoEx)
985
985
  merged = self.player._state_synchronizer.get_merged_state()
986
- try:
987
- loop = asyncio.get_event_loop()
988
- loop.create_task(self.player._coverart_mgr.enrich_metadata_on_track_change(merged))
989
- except RuntimeError:
990
- _LOGGER.debug("No event loop available for metadata enrichment after refresh")
986
+ await self.player._coverart_mgr.enrich_metadata_on_track_change(merged)
991
987
 
992
988
  # Notify callback
993
989
  if self.player._on_state_changed:
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import asyncio
10
10
  import logging
11
11
  import ssl
12
+ from collections.abc import Callable
12
13
  from datetime import timedelta
13
14
  from typing import Any, cast
14
15
 
@@ -818,9 +819,9 @@ class UpnpClient:
818
819
 
819
820
  parsed.update(parse_didl_metadata(track_metadata))
820
821
  _LOGGER.debug("GetInfoEx result for %s: has_artwork=%s", self.host, bool(parsed.get("image_url")))
821
- return parsed
822
- except UpnpError:
823
- raise
822
+ if parsed.get("image_url"):
823
+ return parsed
824
+ _LOGGER.debug("GetInfoEx action for %s returned no artwork; trying raw SOAP", self.host)
824
825
  except Exception as err:
825
826
  _LOGGER.debug("GetInfoEx via async_upnp_client failed for %s: %s", self.host, err)
826
827
 
@@ -829,7 +830,7 @@ class UpnpClient:
829
830
  async def _get_info_ex_raw(
830
831
  self,
831
832
  service_type: str,
832
- parse_response: Any,
833
+ parse_response: Callable[[str], dict[str, Any]],
833
834
  ) -> dict[str, Any]:
834
835
  """Call LinkPlay GetInfoEx via raw SOAP when SCPD does not advertise it."""
835
836
  control_url = getattr(self._av_transport_service, "control_url", None)
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import re
6
7
  from html import unescape
7
8
  from typing import Any
8
9
  from urllib.parse import urlparse
@@ -10,6 +11,8 @@ from xml.etree import ElementTree as ET
10
11
 
11
12
  _LOGGER = logging.getLogger(__name__)
12
13
 
14
+ _BARE_AMPERSAND_RE = re.compile(r"&(?!#\d+;|#x[0-9a-fA-F]+;|[A-Za-z][A-Za-z0-9]+;)")
15
+
13
16
 
14
17
  def is_valid_image_url(url: str | None) -> bool:
15
18
  """Return True when value is a usable HTTP/HTTPS artwork URL."""
@@ -39,7 +42,7 @@ def parse_didl_metadata(didl_xml: str, *, allow_clear: bool = False) -> dict[str
39
42
  return changes
40
43
 
41
44
  try:
42
- didl_xml = unescape(didl_xml)
45
+ didl_xml = _BARE_AMPERSAND_RE.sub("&amp;", unescape(didl_xml))
43
46
  root = ET.fromstring(didl_xml)
44
47
 
45
48
  namespaces = {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywiim
3
- Version: 2.2.6
3
+ Version: 2.2.7
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