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.
- pyloxone_audio/__init__.py +90 -0
- pyloxone_audio/announcement.py +101 -0
- pyloxone_audio/catalogue.py +1118 -0
- pyloxone_audio/clock.py +7 -0
- pyloxone_audio/metadata.py +98 -0
- pyloxone_audio/models.py +48 -0
- pyloxone_audio/native.py +1136 -0
- pyloxone_audio/py.typed +0 -0
- pyloxone_audio/radio.py +211 -0
- pyloxone_audio/selection.py +194 -0
- pyloxone_audio/sources.py +739 -0
- pyloxone_audio/spotify.py +88 -0
- pyloxone_audio-0.1.0.dist-info/METADATA +138 -0
- pyloxone_audio-0.1.0.dist-info/RECORD +18 -0
- pyloxone_audio-0.1.0.dist-info/WHEEL +5 -0
- pyloxone_audio-0.1.0.dist-info/licenses/LICENSE +201 -0
- pyloxone_audio-0.1.0.dist-info/licenses/NOTICE.md +15 -0
- pyloxone_audio-0.1.0.dist-info/top_level.txt +1 -0
pyloxone_audio/clock.py
ADDED
|
@@ -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__)
|
pyloxone_audio/models.py
ADDED
|
@@ -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
|