pyblu 2.0.2__py2.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.
- pyblu/__init__.py +6 -0
- pyblu/entities.py +165 -0
- pyblu/errors.py +52 -0
- pyblu/parse.py +236 -0
- pyblu/player.py +514 -0
- pyblu/py.typed +0 -0
- pyblu-2.0.2.dist-info/METADATA +41 -0
- pyblu-2.0.2.dist-info/RECORD +10 -0
- pyblu-2.0.2.dist-info/WHEEL +5 -0
- pyblu-2.0.2.dist-info/licenses/LICENSE +21 -0
pyblu/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""A Python library for controlling BluOS players."""
|
|
2
|
+
|
|
3
|
+
from .player import Player
|
|
4
|
+
from .entities import Status, Volume, SyncStatus, PairedPlayer, PlayQueue, Preset, Input
|
|
5
|
+
|
|
6
|
+
__all__ = ["Player", "Status", "Volume", "SyncStatus", "PairedPlayer", "PlayQueue", "Preset", "Input"]
|
pyblu/entities.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class Status:
|
|
6
|
+
etag: str
|
|
7
|
+
"""Cursor for long polling requests. Can be passed to next status call."""
|
|
8
|
+
|
|
9
|
+
input_id: str | None
|
|
10
|
+
"""Unique id of the input. Is not set for radio."""
|
|
11
|
+
service: str | None
|
|
12
|
+
"""Service id of current input. 'Capture' for regular inputs."""
|
|
13
|
+
|
|
14
|
+
state: str
|
|
15
|
+
"""Playback state"""
|
|
16
|
+
shuffle: bool
|
|
17
|
+
"""Shuffle enabled"""
|
|
18
|
+
|
|
19
|
+
album: str | None
|
|
20
|
+
"""Album name"""
|
|
21
|
+
artist: str | None
|
|
22
|
+
"""Artist name"""
|
|
23
|
+
name: str | None
|
|
24
|
+
"""Track name"""
|
|
25
|
+
image: str | None
|
|
26
|
+
"""URL of the album art"""
|
|
27
|
+
|
|
28
|
+
volume: int
|
|
29
|
+
"""Volume level with a range of 0-100"""
|
|
30
|
+
volume_db: float
|
|
31
|
+
"""Volume level in dB"""
|
|
32
|
+
|
|
33
|
+
mute: bool
|
|
34
|
+
"""Mute status"""
|
|
35
|
+
mute_volume: int | None
|
|
36
|
+
"""If the player is muted, then this is the unmuted volume level. Absent if the player is not muted."""
|
|
37
|
+
mute_volume_db: float | None
|
|
38
|
+
"""If the player is muted, then this is the unmuted volume level in dB. Absent if the player is not muted."""
|
|
39
|
+
|
|
40
|
+
seconds: float | None
|
|
41
|
+
"""Current playback position in seconds"""
|
|
42
|
+
total_seconds: float | None
|
|
43
|
+
"""Total track length in seconds"""
|
|
44
|
+
can_seek: bool
|
|
45
|
+
"""True if the current track can be seeked"""
|
|
46
|
+
|
|
47
|
+
sleep: int
|
|
48
|
+
"""Sleep timer in minutes. 0 means the sleep timer is off."""
|
|
49
|
+
|
|
50
|
+
group_name: str | None
|
|
51
|
+
"""Name of the group the player is in. Only present on leader."""
|
|
52
|
+
group_volume: int | None
|
|
53
|
+
"""Volume level of the group. Only present on leader. Range is 0-100."""
|
|
54
|
+
|
|
55
|
+
indexing: bool
|
|
56
|
+
"""True if the player is currently indexing."""
|
|
57
|
+
|
|
58
|
+
stream_url: str | None
|
|
59
|
+
"""The presence of this element should be treated as a flag and its contents as an opaque value.
|
|
60
|
+
Seems to be present for radio stations and to be the same as the url from the matching preset(for Radio Stations)."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class PairedPlayer:
|
|
65
|
+
ip: str
|
|
66
|
+
"""IP address of the player"""
|
|
67
|
+
port: int
|
|
68
|
+
"""Port of the player"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class SyncStatus:
|
|
73
|
+
etag: str
|
|
74
|
+
"""Cursor for long polling requests. Can be passed to next sync_status call."""
|
|
75
|
+
|
|
76
|
+
id: str
|
|
77
|
+
"""Player IP and port"""
|
|
78
|
+
mac: str
|
|
79
|
+
"""MAC address of the player"""
|
|
80
|
+
name: str
|
|
81
|
+
"""Name of the player"""
|
|
82
|
+
|
|
83
|
+
image: str
|
|
84
|
+
"""URL of the player image"""
|
|
85
|
+
initialized: bool
|
|
86
|
+
"""True means the player is already setup, false means the player needs to be setup"""
|
|
87
|
+
|
|
88
|
+
group: str | None
|
|
89
|
+
"""Group name of the player"""
|
|
90
|
+
leader: PairedPlayer | None
|
|
91
|
+
"""Player leading the group. Only present if the player is grouped and not leader itself"""
|
|
92
|
+
followers: list[PairedPlayer] | None
|
|
93
|
+
"""List of following players. Only present if the player is leader of a group"""
|
|
94
|
+
|
|
95
|
+
zone: str | None
|
|
96
|
+
"""Name of the zone the player is in. Zones are fixed groups."""
|
|
97
|
+
zone_leader: bool | None
|
|
98
|
+
"""True if the player is the leader of the zone, false otherwise"""
|
|
99
|
+
zone_follower: bool | None
|
|
100
|
+
"""True if the player is a follower in the zone, false otherwise"""
|
|
101
|
+
|
|
102
|
+
brand: str
|
|
103
|
+
"""Brand name of the player"""
|
|
104
|
+
model: str
|
|
105
|
+
"""Model name of the player"""
|
|
106
|
+
model_name: str
|
|
107
|
+
"""Model name of the player"""
|
|
108
|
+
|
|
109
|
+
mute_volume_db: float | None
|
|
110
|
+
"""If the player is muted, then this is the unmuted volume level in dB. Absent if the player is not muted."""
|
|
111
|
+
mute_volume: int | None
|
|
112
|
+
"""If the player is muted, then this is the unmuted volume level. Absent if the player is not muted."""
|
|
113
|
+
|
|
114
|
+
volume_db: float
|
|
115
|
+
"""Volume level in dB"""
|
|
116
|
+
volume: int
|
|
117
|
+
"""Volume level with a range of 0-100. -1 means fixed volume."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class Volume:
|
|
122
|
+
volume: int
|
|
123
|
+
"""Volume level with a range of 0-100"""
|
|
124
|
+
db: float
|
|
125
|
+
"""Volume level in dB"""
|
|
126
|
+
mute: bool
|
|
127
|
+
"""Mute status"""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class PlayQueue:
|
|
132
|
+
id: str
|
|
133
|
+
"""Unique id for the current play queue state. Changes whenever the play queue changes."""
|
|
134
|
+
shuffle: bool
|
|
135
|
+
"""PlayQueue is shuffled"""
|
|
136
|
+
modified: bool
|
|
137
|
+
"""PlayQueue was modified since it was loaded"""
|
|
138
|
+
length: int
|
|
139
|
+
"""Number of tracks in the play queue"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class Preset:
|
|
144
|
+
name: str
|
|
145
|
+
"""Name of the preset"""
|
|
146
|
+
id: int
|
|
147
|
+
"""Unique id of the preset"""
|
|
148
|
+
url: str
|
|
149
|
+
"""URL of the preset. Can be used with *play_url* to play the preset"""
|
|
150
|
+
image: str | None
|
|
151
|
+
"""URL of the preset image"""
|
|
152
|
+
volume: int | None
|
|
153
|
+
"""Volume level with a range of 0-100. None means the volume is not set."""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class Input:
|
|
158
|
+
id: str | None
|
|
159
|
+
"""Unique id of the input"""
|
|
160
|
+
text: str
|
|
161
|
+
"""User friendly name of the input"""
|
|
162
|
+
image: str
|
|
163
|
+
"""URL of the input image"""
|
|
164
|
+
url: str
|
|
165
|
+
"""URL to play the input. Can be passed to *play_url*"""
|
pyblu/errors.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing import Awaitable, Callable, TypeVar, Any, cast
|
|
2
|
+
|
|
3
|
+
from functools import wraps
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
|
|
7
|
+
__all__ = ["PlayerError", "PlayerUnreachableError", "PlayerUnexpectedResponseError"]
|
|
8
|
+
|
|
9
|
+
FuncT = TypeVar("FuncT", bound=Callable[..., Any])
|
|
10
|
+
AsyncFuncT = TypeVar("AsyncFuncT", bound=Callable[..., Awaitable[Any]])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PlayerError(Exception):
|
|
14
|
+
"""Base class for exceptions in this package."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PlayerUnreachableError(PlayerError):
|
|
21
|
+
"""Exception raised when the player is not reachable.
|
|
22
|
+
|
|
23
|
+
This could be due to a timeout or the player being offline.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PlayerUnexpectedResponseError(PlayerError):
|
|
28
|
+
"""Exception raised when the player returns an unexpected response. This is likely a bug in this library."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _wrap_in_unxpected_response_error(func: FuncT) -> FuncT:
|
|
32
|
+
@wraps(func)
|
|
33
|
+
def wrapped(*args, **kwargs):
|
|
34
|
+
try:
|
|
35
|
+
return func(*args, **kwargs)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
raise PlayerUnexpectedResponseError(f"Unexpected response from player: {e}") from e
|
|
38
|
+
|
|
39
|
+
return cast(FuncT, wrapped)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _wrap_in_unreachable_error(func: AsyncFuncT) -> AsyncFuncT:
|
|
43
|
+
@wraps(func)
|
|
44
|
+
async def wrapped(*args, **kwargs):
|
|
45
|
+
try:
|
|
46
|
+
return await func(*args, **kwargs)
|
|
47
|
+
except TimeoutError as e:
|
|
48
|
+
raise PlayerUnreachableError(f"Timout during request: {e}") from e
|
|
49
|
+
except aiohttp.ClientConnectionError as e:
|
|
50
|
+
raise PlayerUnreachableError(f"Connection error: {e}") from e
|
|
51
|
+
|
|
52
|
+
return cast(AsyncFuncT, wrapped)
|
pyblu/parse.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from urllib.parse import unquote
|
|
2
|
+
|
|
3
|
+
from lxml import etree
|
|
4
|
+
|
|
5
|
+
from pyblu.entities import Input, PairedPlayer, SyncStatus, Status, Volume, PlayQueue, Preset
|
|
6
|
+
from pyblu.errors import _wrap_in_unxpected_response_error
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@_wrap_in_unxpected_response_error
|
|
10
|
+
def parse_add_follower(response: bytes) -> list[PairedPlayer]:
|
|
11
|
+
"""
|
|
12
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected.
|
|
13
|
+
"""
|
|
14
|
+
# pylint: disable=c-extension-no-member
|
|
15
|
+
tree = etree.fromstring(response)
|
|
16
|
+
follower_elements = tree.xpath("//addSlave/slave")
|
|
17
|
+
|
|
18
|
+
return [PairedPlayer(ip=x.attrib["id"], port=int(x.attrib["port"])) for x in follower_elements]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@_wrap_in_unxpected_response_error
|
|
22
|
+
def parse_sync_status(response: bytes) -> SyncStatus:
|
|
23
|
+
"""
|
|
24
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected.
|
|
25
|
+
"""
|
|
26
|
+
# pylint: disable=c-extension-no-member
|
|
27
|
+
tree = etree.fromstring(response)
|
|
28
|
+
|
|
29
|
+
leader: PairedPlayer | None = None
|
|
30
|
+
leader_elements = tree.xpath("//SyncStatus/master")
|
|
31
|
+
if leader_elements:
|
|
32
|
+
leader_element = leader_elements[0]
|
|
33
|
+
leader_ip = leader_element.text
|
|
34
|
+
leader_port = leader_element.attrib["port"]
|
|
35
|
+
leader = PairedPlayer(ip=leader_ip, port=int(leader_port))
|
|
36
|
+
|
|
37
|
+
followers: list[PairedPlayer] | None = None
|
|
38
|
+
follower_elements = tree.xpath("//SyncStatus/slave")
|
|
39
|
+
if follower_elements:
|
|
40
|
+
followers = [PairedPlayer(ip=x.attrib["id"], port=int(x.attrib["port"])) for x in follower_elements]
|
|
41
|
+
|
|
42
|
+
sync_status_elements = tree.xpath("//SyncStatus")
|
|
43
|
+
assert len(sync_status_elements) == 1, "SyncStatus element not found or multiple found"
|
|
44
|
+
sync_status_element = sync_status_elements[0]
|
|
45
|
+
|
|
46
|
+
sync_status = SyncStatus(
|
|
47
|
+
etag=sync_status_element.attrib["etag"],
|
|
48
|
+
id=sync_status_element.attrib["id"],
|
|
49
|
+
mac=sync_status_element.attrib["mac"],
|
|
50
|
+
name=sync_status_element.attrib["name"],
|
|
51
|
+
image=sync_status_element.attrib["icon"],
|
|
52
|
+
initialized=sync_status_element.attrib.get("initialized") == "true",
|
|
53
|
+
group=sync_status_element.attrib.get("group"),
|
|
54
|
+
leader=leader,
|
|
55
|
+
followers=followers,
|
|
56
|
+
zone=sync_status_element.attrib.get("zone"),
|
|
57
|
+
zone_leader=sync_status_element.attrib.get("zoneMaster") == "true",
|
|
58
|
+
zone_follower=sync_status_element.attrib.get("zoneMaster") == "true",
|
|
59
|
+
brand=sync_status_element.attrib["brand"],
|
|
60
|
+
model=sync_status_element.attrib["model"],
|
|
61
|
+
model_name=sync_status_element.attrib["modelName"],
|
|
62
|
+
mute_volume_db=float(sync_status_element.attrib["muteDb"]) if "muteDb" in sync_status_element.attrib else None,
|
|
63
|
+
mute_volume=int(sync_status_element.attrib["muteVolume"]) if "muteVolume" in sync_status_element.attrib else None,
|
|
64
|
+
volume_db=float(sync_status_element.attrib["db"]),
|
|
65
|
+
volume=int(sync_status_element.attrib["volume"]),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return sync_status
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@_wrap_in_unxpected_response_error
|
|
72
|
+
def parse_status(response: bytes) -> Status:
|
|
73
|
+
"""
|
|
74
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected.
|
|
75
|
+
"""
|
|
76
|
+
# pylint: disable=c-extension-no-member
|
|
77
|
+
tree = etree.fromstring(response)
|
|
78
|
+
status_elements = tree.xpath("//status")
|
|
79
|
+
|
|
80
|
+
assert len(status_elements) == 1, "Status element not found or multiple found"
|
|
81
|
+
status_element = status_elements[0]
|
|
82
|
+
|
|
83
|
+
name = status_element.findtext("name")
|
|
84
|
+
if name is None:
|
|
85
|
+
name = status_element.findtext("title1")
|
|
86
|
+
artist = status_element.findtext("artist")
|
|
87
|
+
if artist is None:
|
|
88
|
+
artist = status_element.findtext("title2")
|
|
89
|
+
album = status_element.findtext("album")
|
|
90
|
+
if album is None:
|
|
91
|
+
album = status_element.findtext("title3")
|
|
92
|
+
|
|
93
|
+
status = Status(
|
|
94
|
+
etag=status_element.attrib["etag"],
|
|
95
|
+
input_id=status_element.findtext("inputId"),
|
|
96
|
+
service=status_element.findtext("service"),
|
|
97
|
+
state=status_element.findtext("state"),
|
|
98
|
+
shuffle=status_element.findtext("shuffle") == "1",
|
|
99
|
+
album=album,
|
|
100
|
+
artist=artist,
|
|
101
|
+
name=name,
|
|
102
|
+
image=status_element.findtext("image"),
|
|
103
|
+
volume=int(status_element.findtext("volume")),
|
|
104
|
+
volume_db=float(status_element.findtext("db")),
|
|
105
|
+
mute=status_element.findtext("mute") == "1",
|
|
106
|
+
mute_volume=int(status_element.findtext("muteVolume")) if status_element.findtext("muteVolume") else None,
|
|
107
|
+
mute_volume_db=float(status_element.findtext("muteDb")) if status_element.findtext("muteDb") else None,
|
|
108
|
+
seconds=float(status_element.findtext("secs")) if status_element.findtext("secs") else None,
|
|
109
|
+
total_seconds=float(status_element.findtext("totlen")) if status_element.findtext("totlen") else None,
|
|
110
|
+
can_seek=status_element.findtext("canSeek") == "1",
|
|
111
|
+
sleep=int(status_element.findtext("sleep")) if status_element.findtext("sleep") else 0,
|
|
112
|
+
group_name=status_element.findtext("groupName"),
|
|
113
|
+
group_volume=int(status_element.findtext("groupVolume")) if status_element.findtext("groupVolume") else None,
|
|
114
|
+
indexing=status_element.findtext("indexing") == "1",
|
|
115
|
+
stream_url=status_element.findtext("streamUrl"),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return status
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@_wrap_in_unxpected_response_error
|
|
122
|
+
def parse_volume(response: bytes) -> Volume:
|
|
123
|
+
"""
|
|
124
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected.
|
|
125
|
+
"""
|
|
126
|
+
# pylint: disable=c-extension-no-member
|
|
127
|
+
tree = etree.fromstring(response)
|
|
128
|
+
volume_elements = tree.xpath("//volume")
|
|
129
|
+
|
|
130
|
+
assert len(volume_elements) == 1, "Volume element not found or multiple found"
|
|
131
|
+
volume_element = volume_elements[0]
|
|
132
|
+
|
|
133
|
+
volume = Volume(
|
|
134
|
+
volume=int(volume_element.text),
|
|
135
|
+
db=float(volume_element.attrib["db"]),
|
|
136
|
+
mute=volume_element.attrib.get("mute") == "1",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return volume
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@_wrap_in_unxpected_response_error
|
|
143
|
+
def parse_play_queue(response: bytes) -> PlayQueue:
|
|
144
|
+
"""
|
|
145
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected.
|
|
146
|
+
"""
|
|
147
|
+
# pylint: disable=c-extension-no-member
|
|
148
|
+
tree = etree.fromstring(response)
|
|
149
|
+
playlist_elements = tree.xpath("//playlist")
|
|
150
|
+
|
|
151
|
+
assert len(playlist_elements) == 1, "Playlist element not found or multiple found"
|
|
152
|
+
playlist_element = playlist_elements[0]
|
|
153
|
+
|
|
154
|
+
play_queue = PlayQueue(
|
|
155
|
+
id=playlist_element.attrib["id"],
|
|
156
|
+
modified=playlist_element.attrib.get("modified") == "1",
|
|
157
|
+
length=int(playlist_element.attrib["length"]),
|
|
158
|
+
shuffle=playlist_element.attrib.get("shuffle") == "1",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return play_queue
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@_wrap_in_unxpected_response_error
|
|
165
|
+
def parse_presets(response: bytes) -> list[Preset]:
|
|
166
|
+
"""
|
|
167
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected.
|
|
168
|
+
"""
|
|
169
|
+
# pylint: disable=c-extension-no-member
|
|
170
|
+
tree = etree.fromstring(response)
|
|
171
|
+
preset_elements = tree.xpath("//presets/preset")
|
|
172
|
+
|
|
173
|
+
presets = [
|
|
174
|
+
Preset(
|
|
175
|
+
name=x.attrib["name"],
|
|
176
|
+
id=int(x.attrib["id"]),
|
|
177
|
+
url=x.attrib["url"],
|
|
178
|
+
image=x.attrib.get("image"),
|
|
179
|
+
volume=int(x.attrib.get("volume")) if x.attrib.get("volume") else None,
|
|
180
|
+
)
|
|
181
|
+
for x in preset_elements
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
return presets
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@_wrap_in_unxpected_response_error
|
|
188
|
+
def parse_state(response: bytes) -> str:
|
|
189
|
+
"""
|
|
190
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected.
|
|
191
|
+
"""
|
|
192
|
+
# pylint: disable=c-extension-no-member
|
|
193
|
+
tree = etree.fromstring(response)
|
|
194
|
+
state_elements = tree.xpath("//state")
|
|
195
|
+
|
|
196
|
+
assert len(state_elements) == 1, "State element not found or multiple found"
|
|
197
|
+
state_element = state_elements[0]
|
|
198
|
+
|
|
199
|
+
return state_element.text
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@_wrap_in_unxpected_response_error
|
|
203
|
+
def parse_sleep(response: bytes) -> int:
|
|
204
|
+
"""
|
|
205
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected.
|
|
206
|
+
"""
|
|
207
|
+
# pylint: disable=c-extension-no-member
|
|
208
|
+
tree = etree.fromstring(response)
|
|
209
|
+
sleep_elements = tree.xpath("//sleep")
|
|
210
|
+
|
|
211
|
+
assert len(sleep_elements) == 1, "Sleep element not found or multiple found"
|
|
212
|
+
sleep_element = sleep_elements[0]
|
|
213
|
+
|
|
214
|
+
return int(sleep_element.text) if sleep_element.text else 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@_wrap_in_unxpected_response_error
|
|
218
|
+
def parse_inputs(response: bytes) -> list[Input]:
|
|
219
|
+
"""
|
|
220
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected.
|
|
221
|
+
"""
|
|
222
|
+
# pylint: disable=c-extension-no-member
|
|
223
|
+
tree = etree.fromstring(response)
|
|
224
|
+
input_elements = tree.xpath("//radiotime/item")
|
|
225
|
+
|
|
226
|
+
inputs = [
|
|
227
|
+
Input(
|
|
228
|
+
id=x.attrib.get("id"),
|
|
229
|
+
text=x.attrib["text"],
|
|
230
|
+
image=x.attrib["image"],
|
|
231
|
+
url=unquote(x.attrib["URL"]),
|
|
232
|
+
)
|
|
233
|
+
for x in input_elements
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
return inputs
|
pyblu/player.py
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import aiohttp
|
|
2
|
+
|
|
3
|
+
from pyblu.entities import Status, Volume, SyncStatus, PairedPlayer, PlayQueue, Preset, Input
|
|
4
|
+
from pyblu.parse import (
|
|
5
|
+
parse_add_follower,
|
|
6
|
+
parse_inputs,
|
|
7
|
+
parse_sleep,
|
|
8
|
+
parse_state,
|
|
9
|
+
parse_sync_status,
|
|
10
|
+
parse_status,
|
|
11
|
+
parse_volume,
|
|
12
|
+
parse_play_queue,
|
|
13
|
+
parse_presets,
|
|
14
|
+
)
|
|
15
|
+
from pyblu.errors import _wrap_in_unreachable_error
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Player:
|
|
19
|
+
def __init__(self, host: str, port: int = 11000, session: aiohttp.ClientSession | None = None, default_timeout: float = 5.0):
|
|
20
|
+
"""Client for a BluOS player. Uses the HTTP API of the BluOS players to control it.
|
|
21
|
+
|
|
22
|
+
The passed sessions will not be closed when the player is closed and has to be closed by the caller.
|
|
23
|
+
If no session is passed, a new session will be created and closed when the player is closed.
|
|
24
|
+
|
|
25
|
+
*Player* is an async context manager and can be used with *async with*.
|
|
26
|
+
|
|
27
|
+
:param host: The hostname or IP address of the player.
|
|
28
|
+
:param port: The port of the player. Default is 11000.
|
|
29
|
+
:param session: An optional aiohttp.ClientSession to use for requests.
|
|
30
|
+
:param default_timeout: The default timeout in seconds for requests. Can be overridden in each request.
|
|
31
|
+
|
|
32
|
+
:return: A new Player.
|
|
33
|
+
"""
|
|
34
|
+
self.base_url = f"http://{host}:{port}"
|
|
35
|
+
self._default_timeout = default_timeout
|
|
36
|
+
if session:
|
|
37
|
+
self._session_owned = False
|
|
38
|
+
self._session = session
|
|
39
|
+
else:
|
|
40
|
+
self._session_owned = True
|
|
41
|
+
self._session = aiohttp.ClientSession()
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def default_timeout(self) -> float:
|
|
45
|
+
return self._default_timeout
|
|
46
|
+
|
|
47
|
+
async def close(self):
|
|
48
|
+
if self._session_owned:
|
|
49
|
+
await self._session.close()
|
|
50
|
+
|
|
51
|
+
async def __aenter__(self):
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
async def __aexit__(self, *args):
|
|
55
|
+
await self.close()
|
|
56
|
+
|
|
57
|
+
@_wrap_in_unreachable_error
|
|
58
|
+
async def status(self, etag: str | None = None, poll_timeout: int = 30, timeout: float | None = None) -> Status:
|
|
59
|
+
"""Get the current status of the player.
|
|
60
|
+
|
|
61
|
+
This endpoint supports long polling. If **etag** is set, the server will wait until the status changes or the timeout is reached.
|
|
62
|
+
**etag** has to be the last etag received from the server.
|
|
63
|
+
|
|
64
|
+
**poll_timeout** has to be smaller than **timeout**. The **default_timout** and the default value for **poll_timeout** do not fulfill this requirement.
|
|
65
|
+
This means that **timeout** has to be set when using long polling in most cases.
|
|
66
|
+
|
|
67
|
+
:param etag: The last etag received from the server. Triggers long polling if set.
|
|
68
|
+
:param poll_timeout: The timeout in seconds for long polling. Has to be smaller than timeout.
|
|
69
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout. Has to be larger than poll_timeout.
|
|
70
|
+
|
|
71
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
72
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
73
|
+
|
|
74
|
+
:return: The current status of the player. Only selected fields are returned.
|
|
75
|
+
"""
|
|
76
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
77
|
+
|
|
78
|
+
params: dict[str, str] = {}
|
|
79
|
+
if etag is not None:
|
|
80
|
+
if poll_timeout >= used_timeout:
|
|
81
|
+
raise ValueError("poll_timeout has to be smaller than timeout")
|
|
82
|
+
params["etag"] = etag
|
|
83
|
+
params["timeout"] = str(poll_timeout)
|
|
84
|
+
|
|
85
|
+
async with self._session.get(f"{self.base_url}/Status", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
86
|
+
response.raise_for_status()
|
|
87
|
+
response_data = await response.read()
|
|
88
|
+
|
|
89
|
+
status = parse_status(response_data)
|
|
90
|
+
|
|
91
|
+
return status
|
|
92
|
+
|
|
93
|
+
@_wrap_in_unreachable_error
|
|
94
|
+
async def sync_status(self, etag: str | None = None, poll_timeout: int = 30, timeout: float | None = None) -> SyncStatus:
|
|
95
|
+
"""Get the SyncStatus of the player.
|
|
96
|
+
|
|
97
|
+
This endpoint supports long polling. If **etag** is set, the server will wait until the status changes or the timeout is reached.
|
|
98
|
+
**etag** has to be the last etag received from the server.
|
|
99
|
+
|
|
100
|
+
**poll_timeout** has to be smaller than **timeout**. The **default_timout** and the default value for **poll_timeout** do not fulfill this requirement.
|
|
101
|
+
This means that **timeout** has to be set when using long polling in most cases.
|
|
102
|
+
|
|
103
|
+
:param etag: The last etag received from the server. Triggers long polling if set.
|
|
104
|
+
:param poll_timeout: The timeout in seconds for long polling. Has to be smaller than timeout.
|
|
105
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout. Has to be larger than poll_timeout.
|
|
106
|
+
|
|
107
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
108
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
109
|
+
|
|
110
|
+
:return: The SyncStatus of the player.
|
|
111
|
+
"""
|
|
112
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
113
|
+
|
|
114
|
+
params: dict[str, str] = {}
|
|
115
|
+
if etag is not None:
|
|
116
|
+
if poll_timeout >= used_timeout:
|
|
117
|
+
raise ValueError("poll_timeout has to be smaller than timeout")
|
|
118
|
+
params["etag"] = etag
|
|
119
|
+
params["timeout"] = str(poll_timeout)
|
|
120
|
+
|
|
121
|
+
async with self._session.get(f"{self.base_url}/SyncStatus", params=params) as response:
|
|
122
|
+
response.raise_for_status()
|
|
123
|
+
response_data = await response.read()
|
|
124
|
+
|
|
125
|
+
sync_status = parse_sync_status(response_data)
|
|
126
|
+
|
|
127
|
+
return sync_status
|
|
128
|
+
|
|
129
|
+
@_wrap_in_unreachable_error
|
|
130
|
+
async def volume(self, level: int | None = None, mute: bool | None = None, tell_followers: bool | None = None, timeout: float | None = None) -> Volume:
|
|
131
|
+
"""Get or set the volume of the player.
|
|
132
|
+
Call without parameters to get the current volume. Call with parameters to set the volume.
|
|
133
|
+
|
|
134
|
+
:param level: The volume level to set. Range is 0-100.
|
|
135
|
+
:param mute: Whether to mute the player.
|
|
136
|
+
:param tell_followers: Whether to tell grouped speakers to change their volume as well.
|
|
137
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
138
|
+
|
|
139
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
140
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
141
|
+
|
|
142
|
+
:return: The current volume of the player.
|
|
143
|
+
"""
|
|
144
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
145
|
+
|
|
146
|
+
params: dict[str, str] = {}
|
|
147
|
+
if level is not None:
|
|
148
|
+
params["level"] = str(level)
|
|
149
|
+
if mute is not None:
|
|
150
|
+
params["mute"] = "1" if mute else "0"
|
|
151
|
+
if tell_followers is not None:
|
|
152
|
+
params["tell_slaves"] = "1" if tell_followers else "0"
|
|
153
|
+
|
|
154
|
+
async with self._session.get(f"{self.base_url}/Volume", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
155
|
+
response.raise_for_status()
|
|
156
|
+
response_data = await response.read()
|
|
157
|
+
|
|
158
|
+
volume = parse_volume(response_data)
|
|
159
|
+
return volume
|
|
160
|
+
|
|
161
|
+
@_wrap_in_unreachable_error
|
|
162
|
+
async def play(self, seek: int | None = None, timeout: float | None = None) -> str:
|
|
163
|
+
"""Start playing the current track. Can also be used to seek within the current track.
|
|
164
|
+
Works only when paused, not when stopped.
|
|
165
|
+
|
|
166
|
+
:param seek: The position in seconds to seek to.
|
|
167
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
168
|
+
|
|
169
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
170
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
171
|
+
|
|
172
|
+
:return: The playback state after command execution.
|
|
173
|
+
"""
|
|
174
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
175
|
+
|
|
176
|
+
params = {}
|
|
177
|
+
if seek is not None:
|
|
178
|
+
params["seek"] = seek
|
|
179
|
+
|
|
180
|
+
async with self._session.get(f"{self.base_url}/Play", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
181
|
+
response.raise_for_status()
|
|
182
|
+
response_data = await response.read()
|
|
183
|
+
|
|
184
|
+
return parse_state(response_data)
|
|
185
|
+
|
|
186
|
+
@_wrap_in_unreachable_error
|
|
187
|
+
async def play_url(self, url: str, timeout: float | None = None) -> str:
|
|
188
|
+
"""Start playing a track from a URL. Can also be used to select inputs. See *inputs* for available inputs.
|
|
189
|
+
|
|
190
|
+
:param url: The URL of the track to play.
|
|
191
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
192
|
+
|
|
193
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
194
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
195
|
+
|
|
196
|
+
:return: The playback state after command execution.
|
|
197
|
+
"""
|
|
198
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
199
|
+
|
|
200
|
+
params = {
|
|
201
|
+
"url": url,
|
|
202
|
+
}
|
|
203
|
+
async with self._session.get(f"{self.base_url}/Play", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
204
|
+
response.raise_for_status()
|
|
205
|
+
response_data = await response.read()
|
|
206
|
+
|
|
207
|
+
return parse_state(response_data)
|
|
208
|
+
|
|
209
|
+
@_wrap_in_unreachable_error
|
|
210
|
+
async def pause(self, toggle: bool | None = None, timeout: float | None = None) -> str:
|
|
211
|
+
"""Pause the current track. **toggle** can be used to toggle between playing and pause.
|
|
212
|
+
|
|
213
|
+
:param toggle: Toggle between playing and pause.
|
|
214
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
215
|
+
|
|
216
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
217
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
218
|
+
|
|
219
|
+
:return: The playback state after command execution.
|
|
220
|
+
"""
|
|
221
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
222
|
+
|
|
223
|
+
params = {}
|
|
224
|
+
if toggle is not None:
|
|
225
|
+
params["toggle"] = "1"
|
|
226
|
+
|
|
227
|
+
async with self._session.get(f"{self.base_url}/Pause", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
228
|
+
response.raise_for_status()
|
|
229
|
+
response_data = await response.read()
|
|
230
|
+
|
|
231
|
+
return parse_state(response_data)
|
|
232
|
+
|
|
233
|
+
@_wrap_in_unreachable_error
|
|
234
|
+
async def stop(self, timeout: float | None = None) -> str:
|
|
235
|
+
"""Stop the current track. Stopped playback cannot be resumed.
|
|
236
|
+
|
|
237
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
238
|
+
|
|
239
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
240
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
241
|
+
|
|
242
|
+
:return: The playback state after command execution.
|
|
243
|
+
"""
|
|
244
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
245
|
+
|
|
246
|
+
async with self._session.get(f"{self.base_url}/Stop", timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
247
|
+
response.raise_for_status()
|
|
248
|
+
response_data = await response.read()
|
|
249
|
+
|
|
250
|
+
return parse_state(response_data)
|
|
251
|
+
|
|
252
|
+
@_wrap_in_unreachable_error
|
|
253
|
+
async def skip(self, timeout: float | None = None) -> None:
|
|
254
|
+
"""Skip to the next track.
|
|
255
|
+
|
|
256
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
257
|
+
|
|
258
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
259
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
260
|
+
"""
|
|
261
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
262
|
+
async with self._session.get(f"{self.base_url}/Skip", timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
263
|
+
response.raise_for_status()
|
|
264
|
+
|
|
265
|
+
@_wrap_in_unreachable_error
|
|
266
|
+
async def back(self, timeout: float | None = None) -> None:
|
|
267
|
+
"""Go back to the previous track.
|
|
268
|
+
|
|
269
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
270
|
+
|
|
271
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
272
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
273
|
+
"""
|
|
274
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
275
|
+
|
|
276
|
+
async with self._session.get(f"{self.base_url}/Back", timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
277
|
+
response.raise_for_status()
|
|
278
|
+
|
|
279
|
+
@_wrap_in_unreachable_error
|
|
280
|
+
async def add_follower(self, ip: str, port: int = 11000, timeout: float | None = None) -> list[PairedPlayer]:
|
|
281
|
+
"""Add a secondary player to the current player as a follower.
|
|
282
|
+
If it fails the player won't be in the returned list.
|
|
283
|
+
|
|
284
|
+
:param ip: The IP address of the player to add.
|
|
285
|
+
:param port: The port of the player to add. Default is 11000.
|
|
286
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
287
|
+
|
|
288
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
289
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
290
|
+
|
|
291
|
+
:return: The list of followers of the player.
|
|
292
|
+
"""
|
|
293
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
294
|
+
|
|
295
|
+
params: dict[str, str | int] = {
|
|
296
|
+
"slave": ip,
|
|
297
|
+
"port": port,
|
|
298
|
+
}
|
|
299
|
+
async with self._session.get(f"{self.base_url}/AddSlave", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
300
|
+
response.raise_for_status()
|
|
301
|
+
response_data = await response.read()
|
|
302
|
+
|
|
303
|
+
followers_after_request = parse_add_follower(response_data)
|
|
304
|
+
|
|
305
|
+
return followers_after_request
|
|
306
|
+
|
|
307
|
+
@_wrap_in_unreachable_error
|
|
308
|
+
async def add_followers(self, followers: list[PairedPlayer], timeout: float | None = None) -> list[PairedPlayer]:
|
|
309
|
+
"""Add a list of following players to the current player.
|
|
310
|
+
If it fails the player won't be in the returned list.
|
|
311
|
+
|
|
312
|
+
Same as *add_follower* but with a list of players. Makes only one request to player.
|
|
313
|
+
|
|
314
|
+
:param followers: The list of players to add.
|
|
315
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
316
|
+
|
|
317
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
318
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
319
|
+
|
|
320
|
+
:return: The list of followers of the player.
|
|
321
|
+
"""
|
|
322
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
323
|
+
|
|
324
|
+
params = {
|
|
325
|
+
"slaves": ",".join(x.ip for x in followers),
|
|
326
|
+
"ports": ",".join(str(x.port) for x in followers),
|
|
327
|
+
}
|
|
328
|
+
async with self._session.get(f"{self.base_url}/AddSlave", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
329
|
+
response.raise_for_status()
|
|
330
|
+
response_data = await response.read()
|
|
331
|
+
|
|
332
|
+
followers_after_request = parse_add_follower(response_data)
|
|
333
|
+
|
|
334
|
+
return followers_after_request
|
|
335
|
+
|
|
336
|
+
@_wrap_in_unreachable_error
|
|
337
|
+
async def remove_follower(self, ip: str, port: int = 11000, timeout: float | None = None) -> SyncStatus:
|
|
338
|
+
"""Remove a following player from the group.
|
|
339
|
+
|
|
340
|
+
:param ip: The IP address of the player to remove.
|
|
341
|
+
:param port: The port of the player to remove. Default is 11000.
|
|
342
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
343
|
+
|
|
344
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
345
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
346
|
+
|
|
347
|
+
:return: The SyncStatus of the player.
|
|
348
|
+
"""
|
|
349
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
350
|
+
|
|
351
|
+
params: dict[str, str | int] = {
|
|
352
|
+
"slave": ip,
|
|
353
|
+
"port": port,
|
|
354
|
+
}
|
|
355
|
+
async with self._session.get(f"{self.base_url}/RemoveSlave", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
356
|
+
response.raise_for_status()
|
|
357
|
+
response_data = await response.read()
|
|
358
|
+
|
|
359
|
+
sync_status = parse_sync_status(response_data)
|
|
360
|
+
|
|
361
|
+
return sync_status
|
|
362
|
+
|
|
363
|
+
@_wrap_in_unreachable_error
|
|
364
|
+
async def remove_followers(self, followers: list[PairedPlayer], timeout: float | None = None) -> SyncStatus:
|
|
365
|
+
"""Remove a list of following players from the group.
|
|
366
|
+
|
|
367
|
+
Same as *remove_follower* but with a list of players. Makes only one request to player.
|
|
368
|
+
|
|
369
|
+
:param followers: The list of players to remove.
|
|
370
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
371
|
+
|
|
372
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
373
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
374
|
+
|
|
375
|
+
:return: The SyncStatus of the player.
|
|
376
|
+
"""
|
|
377
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
378
|
+
|
|
379
|
+
params = {
|
|
380
|
+
"slaves": ",".join(x.ip for x in followers),
|
|
381
|
+
"ports": ",".join(str(x.port) for x in followers),
|
|
382
|
+
}
|
|
383
|
+
async with self._session.get(f"{self.base_url}/RemoveSlave", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
384
|
+
response.raise_for_status()
|
|
385
|
+
response_data = await response.read()
|
|
386
|
+
|
|
387
|
+
sync_status = parse_sync_status(response_data)
|
|
388
|
+
|
|
389
|
+
return sync_status
|
|
390
|
+
|
|
391
|
+
@_wrap_in_unreachable_error
|
|
392
|
+
async def shuffle(self, shuffle: bool, timeout: float | None = None) -> PlayQueue:
|
|
393
|
+
"""Set shuffle on current play queue.
|
|
394
|
+
|
|
395
|
+
:param shuffle: Whether to shuffle the playlist.
|
|
396
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
397
|
+
|
|
398
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
399
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
400
|
+
|
|
401
|
+
:return: The current play queue.
|
|
402
|
+
"""
|
|
403
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
404
|
+
|
|
405
|
+
params = {
|
|
406
|
+
"shuffle": "1" if shuffle else "0",
|
|
407
|
+
}
|
|
408
|
+
async with self._session.get(f"{self.base_url}/Shuffle", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
409
|
+
response.raise_for_status()
|
|
410
|
+
response_data = await response.read()
|
|
411
|
+
|
|
412
|
+
play_queue = parse_play_queue(response_data)
|
|
413
|
+
|
|
414
|
+
return play_queue
|
|
415
|
+
|
|
416
|
+
@_wrap_in_unreachable_error
|
|
417
|
+
async def clear(self, timeout: float | None = None) -> PlayQueue:
|
|
418
|
+
"""Clear the play queue.
|
|
419
|
+
|
|
420
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
421
|
+
|
|
422
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
423
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
424
|
+
|
|
425
|
+
:return: The current play queue.
|
|
426
|
+
"""
|
|
427
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
428
|
+
|
|
429
|
+
async with self._session.get(f"{self.base_url}/Clear", timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
430
|
+
response.raise_for_status()
|
|
431
|
+
response_data = await response.read()
|
|
432
|
+
|
|
433
|
+
play_queue = parse_play_queue(response_data)
|
|
434
|
+
|
|
435
|
+
return play_queue
|
|
436
|
+
|
|
437
|
+
@_wrap_in_unreachable_error
|
|
438
|
+
async def sleep_timer(self, timeout: float | None = None) -> int:
|
|
439
|
+
"""Set sleep timer. Time steps are 15, 30, 45, 60, 90 minutes. Each call goes to next step.
|
|
440
|
+
Resets to 0 if called when 90 minutes are set.
|
|
441
|
+
|
|
442
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
443
|
+
|
|
444
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
445
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
446
|
+
|
|
447
|
+
:return: The current sleep timer in minutes. 0 if no sleep timer is set.
|
|
448
|
+
"""
|
|
449
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
450
|
+
|
|
451
|
+
async with self._session.get(f"{self.base_url}/Sleep", timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
452
|
+
response.raise_for_status()
|
|
453
|
+
response_data = await response.read()
|
|
454
|
+
|
|
455
|
+
return parse_sleep(response_data)
|
|
456
|
+
|
|
457
|
+
@_wrap_in_unreachable_error
|
|
458
|
+
async def presets(self, timeout: float | None = None) -> list[Preset]:
|
|
459
|
+
"""Get the list of presets of the player.
|
|
460
|
+
|
|
461
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
462
|
+
|
|
463
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
464
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
465
|
+
|
|
466
|
+
:return: The list of presets of the player.
|
|
467
|
+
"""
|
|
468
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
469
|
+
|
|
470
|
+
async with self._session.get(f"{self.base_url}/Presets", timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
471
|
+
response.raise_for_status()
|
|
472
|
+
response_data = await response.read()
|
|
473
|
+
|
|
474
|
+
presets = parse_presets(response_data)
|
|
475
|
+
|
|
476
|
+
return presets
|
|
477
|
+
|
|
478
|
+
@_wrap_in_unreachable_error
|
|
479
|
+
async def load_preset(self, preset_id: int, timeout: float | None = None) -> None:
|
|
480
|
+
"""Load a preset by ID.
|
|
481
|
+
|
|
482
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
483
|
+
:param preset_id: The ID of the preset to load.
|
|
484
|
+
|
|
485
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
486
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
487
|
+
"""
|
|
488
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
489
|
+
|
|
490
|
+
params = {
|
|
491
|
+
"id": preset_id,
|
|
492
|
+
}
|
|
493
|
+
async with self._session.get(f"{self.base_url}/Preset", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
494
|
+
response.raise_for_status()
|
|
495
|
+
|
|
496
|
+
@_wrap_in_unreachable_error
|
|
497
|
+
async def inputs(self, timeout: float | None = None) -> list[Input]:
|
|
498
|
+
"""List all available inputs.
|
|
499
|
+
|
|
500
|
+
:param timeout: The timeout in seconds for the request. This overrides the default timeout.
|
|
501
|
+
|
|
502
|
+
:raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
|
|
503
|
+
:raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
|
|
504
|
+
|
|
505
|
+
:return: The list of inputs of the player.
|
|
506
|
+
"""
|
|
507
|
+
used_timeout = timeout if timeout is not None else self._default_timeout
|
|
508
|
+
|
|
509
|
+
params = {"service": "Capture"}
|
|
510
|
+
async with self._session.get(f"{self.base_url}/RadioBrowse", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
|
|
511
|
+
response.raise_for_status()
|
|
512
|
+
response_data = await response.read()
|
|
513
|
+
|
|
514
|
+
return parse_inputs(response_data)
|
pyblu/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyblu
|
|
3
|
+
Version: 2.0.2
|
|
4
|
+
Project-URL: repository, https://github.com/LouisChrist/pyblu
|
|
5
|
+
Author-email: Louis Christ <mail@louischrist.de>
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: aiohttp>=3.10.5
|
|
8
|
+
Requires-Dist: lxml>=5.3.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# pyblu
|
|
12
|
+
|
|
13
|
+
[](https://pypi.org/project/pyblu/)
|
|
14
|
+
[](https://pypi.org/project/pyblu/)
|
|
15
|
+
[](https://github.com/LouisChrist/pyblu/blob/main/LICENSE)
|
|
16
|
+
|
|
17
|
+
This is a Python library for interfacing with BluOS players.
|
|
18
|
+
It uses the
|
|
19
|
+
[BluOS API](https://bluesound-deutschland.de/wp-content/uploads/2022/01/Custom-Integration-API-v1.0_March-2021.pdf)
|
|
20
|
+
to control and query the status of BluOS players.
|
|
21
|
+
Authentication is not required.
|
|
22
|
+
|
|
23
|
+
Documentation is available at [here](https://louischrist.github.io/pyblu/)
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from pyblu import Player
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def main():
|
|
30
|
+
async with Player("<host>") as player:
|
|
31
|
+
status = await player.status()
|
|
32
|
+
print(status)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install pyblu
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyblu/__init__.py,sha256=iHHwwn8WzibjysOGi_MM_CbILIp-TrCs-Ruq0QHh0gA,275
|
|
2
|
+
pyblu/entities.py,sha256=_G-VfJIWe_vqpsNIJtEQegSJNMRSVuR-AZBiPVxTxLM,4696
|
|
3
|
+
pyblu/errors.py,sha256=PyMHq5J49enCyx662-MQ0S8g0BHRzOhIqq947n8N7ts,1600
|
|
4
|
+
pyblu/parse.py,sha256=5ODdXeOIWcKGaLe6PnikXIcQ75iuggyid-bd1PqB7sE,8626
|
|
5
|
+
pyblu/player.py,sha256=8cosYiR8KZGG2CpJhJwXYTcrnyLY77bVVUUZtnsLjFc,24323
|
|
6
|
+
pyblu/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
pyblu-2.0.2.dist-info/METADATA,sha256=qzLmsLmY8o-zMg869POq9gGgZ5SY3IPZe0l5gNooM2g,1154
|
|
8
|
+
pyblu-2.0.2.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
|
|
9
|
+
pyblu-2.0.2.dist-info/licenses/LICENSE,sha256=-IzHfTBfUCjfyfsDXZgMBKx_IbyajmDw_XbvE5eQJ90,1069
|
|
10
|
+
pyblu-2.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Louis Christ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|