python-linkplay 0.0.18__py3-none-any.whl → 0.0.20__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.
- linkplay/__version__.py +1 -1
- linkplay/bridge.py +62 -24
- linkplay/consts.py +2 -0
- linkplay/controller.py +24 -15
- linkplay/discovery.py +20 -17
- linkplay/exceptions.py +4 -0
- linkplay/utils.py +23 -4
- {python_linkplay-0.0.18.dist-info → python_linkplay-0.0.20.dist-info}/METADATA +1 -1
- python_linkplay-0.0.20.dist-info/RECORD +15 -0
- python_linkplay-0.0.18.dist-info/RECORD +0 -15
- {python_linkplay-0.0.18.dist-info → python_linkplay-0.0.20.dist-info}/LICENSE +0 -0
- {python_linkplay-0.0.18.dist-info → python_linkplay-0.0.20.dist-info}/WHEEL +0 -0
- {python_linkplay-0.0.18.dist-info → python_linkplay-0.0.20.dist-info}/top_level.txt +0 -0
linkplay/__version__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__ = '0.0.
|
1
|
+
__version__ = '0.0.20'
|
linkplay/bridge.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import time
|
3
4
|
from typing import Any
|
4
5
|
|
5
6
|
from linkplay.consts import (
|
@@ -20,6 +21,7 @@ from linkplay.consts import (
|
|
20
21
|
SpeakerType,
|
21
22
|
)
|
22
23
|
from linkplay.endpoint import LinkPlayEndpoint
|
24
|
+
from linkplay.exceptions import LinkPlayInvalidDataException
|
23
25
|
from linkplay.utils import fixup_player_properties
|
24
26
|
|
25
27
|
|
@@ -71,7 +73,22 @@ class LinkPlayDevice:
|
|
71
73
|
def eth(self) -> str | None:
|
72
74
|
"""Returns the ethernet address."""
|
73
75
|
eth2 = self.properties.get(DeviceAttribute.ETH2)
|
74
|
-
|
76
|
+
eth0 = self.properties.get(DeviceAttribute.ETH0)
|
77
|
+
for eth in [eth2, eth0]:
|
78
|
+
if eth == "0.0.0.0":
|
79
|
+
eth = None
|
80
|
+
return (
|
81
|
+
eth2
|
82
|
+
if eth2
|
83
|
+
else eth0
|
84
|
+
if eth0
|
85
|
+
else self.properties.get(DeviceAttribute.APCLI0)
|
86
|
+
)
|
87
|
+
|
88
|
+
async def timesync(self) -> None:
|
89
|
+
"""Sync the time."""
|
90
|
+
timestamp = time.strftime("%Y%m%d%H%M%S")
|
91
|
+
await self.bridge.request(LinkPlayCommand.TIMESYNC.format(timestamp))
|
75
92
|
|
76
93
|
|
77
94
|
class LinkPlayPlayer:
|
@@ -172,12 +189,14 @@ class LinkPlayPlayer:
|
|
172
189
|
)
|
173
190
|
await self.bridge.request(LinkPlayCommand.PLAY_PRESET.format(preset_number))
|
174
191
|
|
175
|
-
async def
|
176
|
-
"""
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
192
|
+
async def seek(self, position: int) -> None:
|
193
|
+
"""Seek to a position."""
|
194
|
+
if (
|
195
|
+
self.total_length_in_seconds > 0
|
196
|
+
and position >= 0
|
197
|
+
and position <= self.total_length_in_seconds
|
198
|
+
):
|
199
|
+
await self.bridge.request(LinkPlayCommand.SEEK.format(position))
|
181
200
|
|
182
201
|
@property
|
183
202
|
def muted(self) -> bool:
|
@@ -217,6 +236,16 @@ class LinkPlayPlayer:
|
|
217
236
|
"""Returns the total length of the track in milliseconds."""
|
218
237
|
return int(self.properties.get(PlayerAttribute.TOTAL_LENGTH, 0))
|
219
238
|
|
239
|
+
@property
|
240
|
+
def current_position_in_seconds(self) -> int:
|
241
|
+
"""Returns the current position of the track in seconds."""
|
242
|
+
return int(int(self.properties.get(PlayerAttribute.CURRENT_POSITION, 0)) / 1000)
|
243
|
+
|
244
|
+
@property
|
245
|
+
def total_length_in_seconds(self) -> int:
|
246
|
+
"""Returns the total length of the track in seconds."""
|
247
|
+
return int(int(self.properties.get(PlayerAttribute.TOTAL_LENGTH, 0)) / 1000)
|
248
|
+
|
220
249
|
@property
|
221
250
|
def status(self) -> PlayingStatus:
|
222
251
|
"""Returns the current playing status."""
|
@@ -328,26 +357,31 @@ class LinkPlayMultiroom:
|
|
328
357
|
|
329
358
|
async def update_status(self, bridges: list[LinkPlayBridge]) -> None:
|
330
359
|
"""Updates the multiroom status."""
|
331
|
-
|
332
|
-
|
333
|
-
|
360
|
+
try:
|
361
|
+
properties: dict[Any, Any] = await self.leader.json_request(
|
362
|
+
LinkPlayCommand.MULTIROOM_LIST
|
363
|
+
)
|
334
364
|
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
365
|
+
self.followers = []
|
366
|
+
if int(properties[MultiroomAttribute.NUM_FOLLOWERS]) == 0:
|
367
|
+
return
|
368
|
+
|
369
|
+
follower_uuids = [
|
370
|
+
follower[MultiroomAttribute.UUID]
|
371
|
+
for follower in properties[MultiroomAttribute.FOLLOWER_LIST]
|
372
|
+
]
|
373
|
+
new_followers = [
|
374
|
+
bridge for bridge in bridges if bridge.device.uuid in follower_uuids
|
375
|
+
]
|
376
|
+
self.followers.extend(new_followers)
|
377
|
+
except LinkPlayInvalidDataException as exc:
|
378
|
+
LOGGER.exception(exc)
|
347
379
|
|
348
380
|
async def ungroup(self) -> None:
|
349
381
|
"""Ungroups the multiroom group."""
|
350
382
|
await self.leader.request(LinkPlayCommand.MULTIROOM_UNGROUP)
|
383
|
+
for follewer in self.followers:
|
384
|
+
follewer.multiroom = None
|
351
385
|
self.followers = []
|
352
386
|
|
353
387
|
async def add_follower(self, follower: LinkPlayBridge) -> None:
|
@@ -355,14 +389,18 @@ class LinkPlayMultiroom:
|
|
355
389
|
await follower.request(
|
356
390
|
LinkPlayCommand.MULTIROOM_JOIN.format(self.leader.device.eth)
|
357
391
|
) # type: ignore[str-format]
|
358
|
-
self.followers
|
392
|
+
if follower not in self.followers:
|
393
|
+
follower.multiroom = self
|
394
|
+
self.followers.append(follower)
|
359
395
|
|
360
396
|
async def remove_follower(self, follower: LinkPlayBridge) -> None:
|
361
397
|
"""Removes a follower from the multiroom group."""
|
362
398
|
await self.leader.request(
|
363
399
|
LinkPlayCommand.MULTIROOM_KICK.format(follower.device.eth)
|
364
400
|
) # type: ignore[str-format]
|
365
|
-
self.followers
|
401
|
+
if follower in self.followers:
|
402
|
+
follower.multiroom = None
|
403
|
+
self.followers.remove(follower)
|
366
404
|
|
367
405
|
async def set_volume(self, value: int) -> None:
|
368
406
|
"""Sets the volume for the multiroom group."""
|
linkplay/consts.py
CHANGED
@@ -145,6 +145,7 @@ class PlayingMode(StrEnum):
|
|
145
145
|
HTTP_MAX = "29"
|
146
146
|
ALARM = "30"
|
147
147
|
SPOTIFY = "31"
|
148
|
+
TIDAL = "32"
|
148
149
|
LINE_IN = "40"
|
149
150
|
BLUETOOTH = "41"
|
150
151
|
OPTICAL = "43"
|
@@ -179,6 +180,7 @@ PLAY_MODE_SEND_MAP: dict[PlayingMode, str] = { # case sensitive!
|
|
179
180
|
PlayingMode.UDISK: "udisk",
|
180
181
|
PlayingMode.ALARM: "Alarm",
|
181
182
|
PlayingMode.SPOTIFY: "Spotify",
|
183
|
+
PlayingMode.TIDAL: "Tidal",
|
182
184
|
PlayingMode.LINE_IN: "line-in",
|
183
185
|
PlayingMode.BLUETOOTH: "bluetooth",
|
184
186
|
PlayingMode.OPTICAL: "optical",
|
linkplay/controller.py
CHANGED
@@ -1,7 +1,9 @@
|
|
1
1
|
from aiohttp import ClientSession
|
2
2
|
|
3
3
|
from linkplay.bridge import LinkPlayBridge, LinkPlayMultiroom
|
4
|
+
from linkplay.consts import LOGGER
|
4
5
|
from linkplay.discovery import discover_linkplay_bridges
|
6
|
+
from linkplay.exceptions import LinkPlayInvalidDataException
|
5
7
|
|
6
8
|
|
7
9
|
class LinkPlayController:
|
@@ -49,28 +51,35 @@ class LinkPlayController:
|
|
49
51
|
|
50
52
|
removed_multirooms = []
|
51
53
|
for multiroom in multirooms:
|
52
|
-
|
53
|
-
follower.multiroom = None
|
54
|
-
await multiroom.update_status(self.bridges)
|
55
|
-
if len(multiroom.followers) > 0:
|
54
|
+
try:
|
56
55
|
for follower in multiroom.followers:
|
57
|
-
follower.multiroom =
|
58
|
-
|
59
|
-
multiroom.
|
60
|
-
|
56
|
+
follower.multiroom = None
|
57
|
+
|
58
|
+
await multiroom.update_status(self.bridges)
|
59
|
+
if len(multiroom.followers) > 0:
|
60
|
+
for follower in multiroom.followers:
|
61
|
+
follower.multiroom = multiroom
|
62
|
+
else:
|
63
|
+
multiroom.leader.multiroom = None
|
64
|
+
removed_multirooms.append(multiroom)
|
65
|
+
except LinkPlayInvalidDataException as exc:
|
66
|
+
LOGGER.exception(exc)
|
61
67
|
|
62
68
|
# Create new multirooms from new bridges
|
63
69
|
for bridge in self.bridges:
|
64
70
|
if bridge.multiroom:
|
65
71
|
continue
|
66
72
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
follower
|
73
|
+
try:
|
74
|
+
multiroom = LinkPlayMultiroom(bridge)
|
75
|
+
await multiroom.update_status(self.bridges)
|
76
|
+
if len(multiroom.followers) > 0:
|
77
|
+
multirooms.append(multiroom)
|
78
|
+
bridge.multiroom = multiroom
|
79
|
+
for follower in multiroom.followers:
|
80
|
+
follower.multiroom = multiroom
|
81
|
+
except LinkPlayInvalidDataException as exc:
|
82
|
+
LOGGER.exception(exc)
|
74
83
|
|
75
84
|
# Remove multirooms with no followers
|
76
85
|
multirooms = [item for item in multirooms if item not in removed_multirooms]
|
linkplay/discovery.py
CHANGED
@@ -8,7 +8,7 @@ from deprecated import deprecated
|
|
8
8
|
from linkplay.bridge import LinkPlayBridge
|
9
9
|
from linkplay.consts import UPNP_DEVICE_TYPE, LinkPlayCommand, MultiroomAttribute
|
10
10
|
from linkplay.endpoint import LinkPlayApiEndpoint, LinkPlayEndpoint
|
11
|
-
from linkplay.exceptions import LinkPlayRequestException
|
11
|
+
from linkplay.exceptions import LinkPlayInvalidDataException, LinkPlayRequestException
|
12
12
|
|
13
13
|
|
14
14
|
@deprecated(
|
@@ -98,21 +98,24 @@ async def discover_bridges_through_multiroom(
|
|
98
98
|
bridge: LinkPlayBridge, session: ClientSession
|
99
99
|
) -> list[LinkPlayBridge]:
|
100
100
|
"""Discovers bridges through the multiroom of the provided bridge."""
|
101
|
-
|
102
|
-
|
103
|
-
|
101
|
+
try:
|
102
|
+
properties: dict[Any, Any] = await bridge.json_request(
|
103
|
+
LinkPlayCommand.MULTIROOM_LIST
|
104
|
+
)
|
104
105
|
|
105
|
-
|
106
|
+
if int(properties[MultiroomAttribute.NUM_FOLLOWERS]) == 0:
|
107
|
+
return []
|
108
|
+
|
109
|
+
followers: list[LinkPlayBridge] = []
|
110
|
+
for follower in properties[MultiroomAttribute.FOLLOWER_LIST]:
|
111
|
+
try:
|
112
|
+
new_bridge = await linkplay_factory_httpapi_bridge(
|
113
|
+
follower[MultiroomAttribute.IP], session
|
114
|
+
)
|
115
|
+
followers.append(new_bridge)
|
116
|
+
except LinkPlayRequestException:
|
117
|
+
pass
|
118
|
+
|
119
|
+
return followers
|
120
|
+
except LinkPlayInvalidDataException:
|
106
121
|
return []
|
107
|
-
|
108
|
-
followers: list[LinkPlayBridge] = []
|
109
|
-
for follower in properties[MultiroomAttribute.FOLLOWER_LIST]:
|
110
|
-
try:
|
111
|
-
new_bridge = await linkplay_factory_httpapi_bridge(
|
112
|
-
follower[MultiroomAttribute.IP], session
|
113
|
-
)
|
114
|
-
followers.append(new_bridge)
|
115
|
-
except LinkPlayRequestException:
|
116
|
-
pass
|
117
|
-
|
118
|
-
return followers
|
linkplay/exceptions.py
CHANGED
linkplay/utils.py
CHANGED
@@ -22,7 +22,7 @@ from linkplay.consts import (
|
|
22
22
|
PlayerAttribute,
|
23
23
|
PlayingStatus,
|
24
24
|
)
|
25
|
-
from linkplay.exceptions import LinkPlayRequestException
|
25
|
+
from linkplay.exceptions import LinkPlayInvalidDataException, LinkPlayRequestException
|
26
26
|
|
27
27
|
|
28
28
|
async def session_call_api(endpoint: str, session: ClientSession, command: str) -> str:
|
@@ -61,9 +61,28 @@ async def session_call_api(endpoint: str, session: ClientSession, command: str)
|
|
61
61
|
async def session_call_api_json(
|
62
62
|
endpoint: str, session: ClientSession, command: str
|
63
63
|
) -> dict[str, str]:
|
64
|
-
"""Calls the LinkPlay API and returns the result as a JSON object
|
65
|
-
|
66
|
-
|
64
|
+
"""Calls the LinkPlay API and returns the result as a JSON object
|
65
|
+
|
66
|
+
Args:
|
67
|
+
endpoint (str): The endpoint to use.
|
68
|
+
session (ClientSession): The client session to use.
|
69
|
+
command (str): The command to use.
|
70
|
+
|
71
|
+
Raises:
|
72
|
+
LinkPlayRequestException: Thrown when the request fails (timeout, error http status).
|
73
|
+
LinkPlayInvalidDataException: Thrown when the request has succeeded with invalid json.
|
74
|
+
|
75
|
+
Returns:
|
76
|
+
str: The response of the API call.
|
77
|
+
"""
|
78
|
+
try:
|
79
|
+
result = await session_call_api(endpoint, session, command)
|
80
|
+
return json.loads(result) # type: ignore
|
81
|
+
except json.JSONDecodeError as jsonexc:
|
82
|
+
url = API_ENDPOINT.format(endpoint, command)
|
83
|
+
raise LinkPlayInvalidDataException(
|
84
|
+
f"Unexpected JSON ({result}) received from '{url}'"
|
85
|
+
) from jsonexc
|
67
86
|
|
68
87
|
|
69
88
|
async def session_call_api_ok(
|
@@ -0,0 +1,15 @@
|
|
1
|
+
linkplay/__init__.py,sha256=y9ZehEq-KhS3cwn-PUpwVSJGfDUx7e5wf_G6guODcTk,56
|
2
|
+
linkplay/__main__.py,sha256=Wcza80QaWfOaHjyJEfQYhB9kiPLE0NOqIj4zVWv2Nqs,577
|
3
|
+
linkplay/__version__.py,sha256=OWJaWY0JFPzGIxOCNzOeu5pU5449ij8d2xsJ2vFyXeA,23
|
4
|
+
linkplay/bridge.py,sha256=Xj1YRz0oJJ41Bvjwfa-tMKQNVWgluADryFPTgD18qJM,15059
|
5
|
+
linkplay/consts.py,sha256=98VtgV4xOfXWr00yl1DwfmNxDI6Ul5fNPCyLp8ixggE,13535
|
6
|
+
linkplay/controller.py,sha256=IBFhnt-VhdIZnI_3OLU6hA8oQa9IZWY7-8cN0uCh3-w,3277
|
7
|
+
linkplay/discovery.py,sha256=XgzzsOkxtUPu5f7V1KTIqQWP6_UCQncpbWGaVN1lULU,4457
|
8
|
+
linkplay/endpoint.py,sha256=5Ybr54aroFVEZ6fnFYP41QAuSP7-J9qHYAzLod4S3KY,2459
|
9
|
+
linkplay/exceptions.py,sha256=Kow13uJPSL4y6rXMnkcl_Yp9wH1weOyKw_knd0p-Exc,173
|
10
|
+
linkplay/utils.py,sha256=Kmbzw8zC9mV89ZOC5-GNtbiLkgUkuvAEUcsJdRkSY-w,8485
|
11
|
+
python_linkplay-0.0.20.dist-info/LICENSE,sha256=bgEtxMyjEHX_4uwaAY3GCFTm234D4AOZ5dM15sk26ms,1073
|
12
|
+
python_linkplay-0.0.20.dist-info/METADATA,sha256=Hg0whBUuHM-m4yP5JVi7n19xcsceooEzxoWJGIMsusY,2988
|
13
|
+
python_linkplay-0.0.20.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
|
14
|
+
python_linkplay-0.0.20.dist-info/top_level.txt,sha256=CpSaOVPTzJf5TVIL7MrotSCR34gcIOQy-11l4zGmxxM,9
|
15
|
+
python_linkplay-0.0.20.dist-info/RECORD,,
|
@@ -1,15 +0,0 @@
|
|
1
|
-
linkplay/__init__.py,sha256=y9ZehEq-KhS3cwn-PUpwVSJGfDUx7e5wf_G6guODcTk,56
|
2
|
-
linkplay/__main__.py,sha256=Wcza80QaWfOaHjyJEfQYhB9kiPLE0NOqIj4zVWv2Nqs,577
|
3
|
-
linkplay/__version__.py,sha256=Te1DfrvgEn6zd58FwD1NO8Ms7gZvtmQkfEmQw_u7h7k,23
|
4
|
-
linkplay/bridge.py,sha256=dJ0WfqkSEiFhp7vydqUCzBRt_Nxhylfg8pYZfs2e_N0,13619
|
5
|
-
linkplay/consts.py,sha256=5JQ-5CgmbPMBhrxhTb_0APnt1EnYhiNwsHn-68yDiTI,13486
|
6
|
-
linkplay/controller.py,sha256=i3eLlaZ5pWoGgRT27I564Z9Bmi_aiY9g6lUocXE2qmk,2894
|
7
|
-
linkplay/discovery.py,sha256=aEzN_94pKLmHKYIL7DxSW0FYRsaF2ruZe2bwXz0zf5U,4299
|
8
|
-
linkplay/endpoint.py,sha256=5Ybr54aroFVEZ6fnFYP41QAuSP7-J9qHYAzLod4S3KY,2459
|
9
|
-
linkplay/exceptions.py,sha256=tWJWHsKVkUEq3Yet1Z739IxcaQT8YamDeSp0tqHde9c,107
|
10
|
-
linkplay/utils.py,sha256=aU_9TL39ngQaEMhFWMtlwB3POba2GjGlfNBshLFFS90,7788
|
11
|
-
python_linkplay-0.0.18.dist-info/LICENSE,sha256=bgEtxMyjEHX_4uwaAY3GCFTm234D4AOZ5dM15sk26ms,1073
|
12
|
-
python_linkplay-0.0.18.dist-info/METADATA,sha256=V4CmIWB_LMwkchCc3Ya0L3ao5BVM13CxcYtNq2VOet8,2988
|
13
|
-
python_linkplay-0.0.18.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
|
14
|
-
python_linkplay-0.0.18.dist-info/top_level.txt,sha256=CpSaOVPTzJf5TVIL7MrotSCR34gcIOQy-11l4zGmxxM,9
|
15
|
-
python_linkplay-0.0.18.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|