PyLoxone-Audio 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """UTC observations without a Home Assistant dependency."""
2
+ from datetime import UTC, datetime
3
+
4
+
5
+ def utcnow() -> datetime:
6
+ """Use the same aware UTC observation semantics as the accepted policy."""
7
+ return datetime.now(UTC)
@@ -0,0 +1,98 @@
1
+ """Entry-owned optional Audioserver metadata, shared by the entry's players."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from contextlib import suppress
7
+ import logging
8
+ from typing import TYPE_CHECKING
9
+ from urllib.parse import urljoin, urlsplit
10
+
11
+ from .native import AudioServerCommands, AudioServerListener, AudioZoneMetadata, audioserver_uri
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable
15
+
16
+ from collections.abc import Coroutine
17
+ from typing import Any
18
+
19
+ _LOGGER = logging.getLogger(__name__)
20
+
21
+
22
+ class AudioMetadataCoordinator:
23
+ """Keep the event connection independent of ordinary Miniserver controls."""
24
+
25
+ def __init__(self, host: str) -> None:
26
+ """Prepare a receive-only listener without opening a connection."""
27
+ self._listeners: dict[int, set[Callable[[], None]]] = {}
28
+ self._client = AudioServerListener(host, self._on_update)
29
+ self._http_origin = audioserver_uri(host).replace("ws://", "http://", 1)
30
+ self._task: asyncio.Task[None] | None = None
31
+
32
+ def async_start(self, create_task: Callable[[Coroutine[Any, Any, None]], asyncio.Task[None]] = asyncio.create_task) -> None:
33
+ """Start observation with an optional host-owned task scheduler."""
34
+ if self._task is not None and not self._task.done():
35
+ return
36
+ self._task = create_task(self._client.async_run())
37
+
38
+ async def async_stop(self) -> None:
39
+ """Cancel and await the connection before dropping entity subscriptions."""
40
+ if self._task is not None:
41
+ self._task.cancel()
42
+ with suppress(asyncio.CancelledError):
43
+ await self._task
44
+ self._task = None
45
+ self._listeners.clear()
46
+
47
+ def get(self, player_id: int) -> AudioZoneMetadata | None:
48
+ """Return an exact player's current metadata, never a name-based guess."""
49
+ return self._client.get(player_id)
50
+
51
+ async def async_ensure_snapshot(
52
+ self, commands: AudioServerCommands, player_id: int, *, is_current: Callable[[], bool],
53
+ ) -> None:
54
+ """Read missing initial state only for an explicit opted-in action."""
55
+ await self._client.async_ensure_snapshot(
56
+ player_id, lambda: commands.async_player_status(player_id, is_current=is_current), is_current=is_current,
57
+ )
58
+
59
+ def artwork_url(self, cover_url: str | None) -> str | None:
60
+ """Resolve optional artwork against this entry's configured Audioserver."""
61
+ if not cover_url:
62
+ return None
63
+ try:
64
+ resolved = urljoin(self._http_origin, cover_url)
65
+ parsed = urlsplit(resolved)
66
+ if (
67
+ parsed.scheme not in ("http", "https")
68
+ or not parsed.hostname
69
+ or parsed.username is not None
70
+ or parsed.password is not None
71
+ or parsed.port == 0
72
+ ):
73
+ return None
74
+ except ValueError:
75
+ return None
76
+ return resolved
77
+
78
+ def async_add_listener(self, player_id: int, listener: Callable[[], None]) -> Callable[[], None]:
79
+ """Subscribe one player; the entity owns its unsubscribe callback."""
80
+ self._listeners.setdefault(player_id, set()).add(listener)
81
+
82
+ def unsubscribe() -> None:
83
+ if listeners := self._listeners.get(player_id):
84
+ listeners.discard(listener)
85
+ if player_id in self._listeners and not self._listeners[player_id]:
86
+ del self._listeners[player_id]
87
+
88
+ return unsubscribe
89
+
90
+ def _on_update(self, updates: tuple[AudioZoneMetadata, ...]) -> None:
91
+ """Notify affected players; an empty update invalidates every snapshot."""
92
+ player_ids = {row.player_id for row in updates} if updates else set(self._listeners)
93
+ for player_id in player_ids:
94
+ for listener in tuple(self._listeners.get(player_id, ())):
95
+ try:
96
+ listener()
97
+ except Exception as err: # noqa: BLE001
98
+ _LOGGER.warning("Audioserver metadata subscriber failed (%s)", type(err).__name__)
@@ -0,0 +1,48 @@
1
+ """Neutral catalogue DTOs; HA serialization and enums belong to the host."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from enum import StrEnum
6
+
7
+
8
+ class MediaKind(StrEnum):
9
+ """Kinds actually emitted by the existing native catalogues."""
10
+
11
+ ALBUM = 'album'
12
+ ARTIST = 'artist'
13
+ CHANNEL = 'channel'
14
+ DIRECTORY = 'directory'
15
+ EPISODE = 'episode'
16
+ PLAYLIST = 'playlist'
17
+ PODCAST = 'podcast'
18
+ TRACK = 'track'
19
+
20
+
21
+ @dataclass(kw_only=True)
22
+ class MediaNode:
23
+ """Bounded discovered navigation/play item, not an HA UI object.
24
+
25
+ Mutable while the catalogue assembles provider-specific capabilities. Opaque
26
+ IDs remain room-local; a node is not permission to issue arbitrary commands.
27
+ """
28
+
29
+ media_class: MediaKind | str
30
+ media_content_id: str
31
+ media_content_type: str
32
+ title: str
33
+ can_play: bool
34
+ can_expand: bool
35
+ children: list[MediaNode] | None = None
36
+ children_media_class: MediaKind | str | None = None
37
+ thumbnail: str | None = None
38
+ not_shown: int = 0
39
+ can_search: bool = False
40
+ search_media_classes: list[MediaKind] | None = None
41
+
42
+
43
+ @dataclass(kw_only=True, frozen=True)
44
+ class SearchResult:
45
+ """Neutral search results; no HA schema, token/session or UI dependency."""
46
+
47
+ result: list[MediaNode]
48
+ version: int = 1