/
/
/
1"""
2Async client for a local go-librespot daemon.
3
4go-librespot exposes a small HTTP+WebSocket API (enabled via its ``server`` config
5block). This client wraps the REST control endpoints (resume/pause/next/prev/seek/
6volume/play) and the ``/events`` WebSocket stream that pushes player state changes.
7See https://github.com/devgianlu/go-librespot/blob/master/API.md for the protocol.
8"""
9
10from __future__ import annotations
11
12import asyncio
13from collections.abc import Awaitable, Callable
14from http import HTTPStatus
15from typing import TYPE_CHECKING, Any
16
17from aiohttp import ClientError, ClientTimeout, WSMsgType
18
19if TYPE_CHECKING:
20 import logging
21
22 from music_assistant.mass import MusicAssistant
23
24# Called with (event_type, event_data) for every WebSocket event.
25EventCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
26
27
28class GoLibrespotClient:
29 """Thin async wrapper around a go-librespot daemon's REST + WebSocket API."""
30
31 def __init__(self, mass: MusicAssistant, base_url: str, logger: logging.Logger) -> None:
32 """
33 Initialize the client.
34
35 :param mass: The MusicAssistant instance (for its shared HTTP session).
36 :param base_url: Base URL of the daemon's API server, e.g. ``http://127.0.0.1:3678``.
37 :param logger: Logger to use for diagnostics.
38 """
39 self.mass = mass
40 self.base_url = base_url.rstrip("/")
41 self.logger = logger
42
43 async def wait_until_ready(self, timeout: float = 30.0) -> bool:
44 """
45 Poll the daemon's root endpoint until the API server answers.
46
47 :param timeout: Maximum seconds to wait for the API to come up.
48 :return: True once the API responds, False if the timeout elapses.
49 """
50 deadline = self.mass.loop.time() + timeout
51 while self.mass.loop.time() < deadline:
52 try:
53 async with self.mass.http_session.get(
54 f"{self.base_url}/", timeout=ClientTimeout(total=2)
55 ) as resp:
56 if resp.status == HTTPStatus.OK:
57 return True
58 except ClientError, TimeoutError, OSError:
59 pass
60 await asyncio.sleep(0.25)
61 return False
62
63 async def get_status(self) -> dict[str, Any] | None:
64 """
65 Fetch the current player status.
66
67 :return: The status payload, or None when there is no active session
68 (the daemon answers ``204 No Content`` in that case).
69 """
70 return await self._request("GET", "/status")
71
72 async def resume(self) -> None:
73 """Resume playback on the active session."""
74 await self._request("POST", "/player/resume")
75
76 async def pause(self) -> None:
77 """Pause playback on the active session."""
78 await self._request("POST", "/player/pause")
79
80 async def stop(self) -> None:
81 """Stop playback and disconnect the session (release active device status)."""
82 await self._request("POST", "/player/stop")
83
84 async def next(self) -> None:
85 """Skip to the next track."""
86 await self._request("POST", "/player/next")
87
88 async def prev(self) -> None:
89 """Skip to the previous track (or rewind the current one)."""
90 await self._request("POST", "/player/prev")
91
92 async def seek(self, position_ms: int) -> None:
93 """
94 Seek to an absolute position in the current track.
95
96 :param position_ms: Target position in milliseconds.
97 """
98 await self._request("POST", "/player/seek", {"position": max(0, position_ms)})
99
100 async def set_volume(self, volume: int) -> None:
101 """
102 Set the absolute playback volume.
103
104 :param volume: Volume on go-librespot's scale (0..volume_steps).
105 """
106 await self._request("POST", "/player/volume", {"volume": max(0, volume)})
107
108 async def play(self, uri: str, *, skip_to_uri: str | None = None, paused: bool = False) -> None:
109 """
110 Start playing a Spotify URI/context, making this device the active one.
111
112 go-librespot activates this device unconditionally for a play request, so
113 this doubles as a "take playback back to us" call when another device is
114 currently active.
115
116 :param uri: Spotify URI (track, album, playlist, ...) â typically a context.
117 :param skip_to_uri: Optional track URI within the context to start at.
118 :param paused: When True, load the content paused instead of playing.
119 """
120 body: dict[str, Any] = {"uri": uri, "paused": paused}
121 if skip_to_uri:
122 body["skip_to_uri"] = skip_to_uri
123 await self._request("POST", "/player/play", body)
124
125 async def listen_events(self, on_event: EventCallback) -> None:
126 """
127 Connect to the ``/events`` WebSocket and dispatch events until it closes.
128
129 Returns normally when the connection is closed by either side; raises on
130 connection errors so the caller can implement a reconnect loop.
131
132 :param on_event: Coroutine called with ``(event_type, event_data)`` per event.
133 """
134 ws_url = f"{self.base_url.replace('http', 'ws', 1)}/events"
135 async with self.mass.http_session.ws_connect(ws_url, heartbeat=30) as ws:
136 self.logger.debug("Connected to go-librespot events websocket")
137 async for msg in ws:
138 if msg.type == WSMsgType.TEXT:
139 try:
140 payload = msg.json()
141 except ValueError:
142 self.logger.debug("Ignoring non-JSON websocket message: %s", msg.data)
143 continue
144 if event_type := payload.get("type"):
145 await on_event(event_type, payload.get("data") or {})
146 elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED):
147 break
148 elif msg.type == WSMsgType.ERROR:
149 raise ws.exception() or ClientError("websocket error")
150
151 async def _request(
152 self, method: str, path: str, json_body: dict[str, Any] | None = None
153 ) -> dict[str, Any] | None:
154 """
155 Issue a request to the daemon's REST API.
156
157 :param method: HTTP method.
158 :param path: API path (e.g. ``/player/resume``).
159 :param json_body: Optional JSON request body.
160 :return: Parsed JSON response, or None when the daemon has no active session
161 (``204 No Content``) or returns no body.
162 :raises ClientError: When the daemon answers with an error status.
163 """
164 async with self.mass.http_session.request(
165 method, f"{self.base_url}{path}", json=json_body, timeout=ClientTimeout(total=10)
166 ) as resp:
167 # 204 = the daemon has no active Spotify session; surface as "no data"
168 # rather than an error so callers can treat it as a no-op.
169 if resp.status == HTTPStatus.NO_CONTENT:
170 self.logger.debug("go-librespot has no active session for %s %s", method, path)
171 return None
172 resp.raise_for_status()
173 if resp.content_type == "application/json":
174 data: dict[str, Any] = await resp.json()
175 return data
176 return None
177