/
/
/
1"""Helpers/utils for the Spotify musicprovider."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7import os
8import platform
9import tempfile
10import time
11from typing import TYPE_CHECKING, Any
12
13from aiohttp import web
14from music_assistant_models.errors import LoginFailed
15
16from music_assistant.helpers.json import json_loads
17from music_assistant.helpers.process import AsyncProcess, check_output
18
19from .constants import CHECK_AUTH_TIMEOUT, CREDENTIALS_FILE
20
21LOGGER = logging.getLogger(__name__)
22
23LOOPBACK_RESPONSE_HTML = """
24<html>
25<body onload="window.close();">
26 Playback approved, you may now close this window and return to Music Assistant.
27</body>
28</html>
29"""
30
31if TYPE_CHECKING:
32 import aiohttp
33
34
35async def get_librespot_binary() -> str:
36 """Find the correct librespot binary belonging to the platform."""
37
38 async def check_librespot(librespot_path: str) -> str | None:
39 try:
40 returncode, output = await check_output(librespot_path, "--version")
41 if returncode == 0 and b"librespot" in output:
42 return librespot_path
43 return None
44 except OSError:
45 return None
46
47 base_path = os.path.join(os.path.dirname(__file__), "bin")
48 system = platform.system().lower().replace("darwin", "macos")
49 architecture = platform.machine().lower()
50
51 if librespot_binary := await check_librespot(
52 os.path.join(base_path, f"librespot-{system}-{architecture}")
53 ):
54 return librespot_binary
55
56 msg = f"Unable to locate Librespot for {system}/{architecture}"
57 raise RuntimeError(msg)
58
59
60async def librespot_credentials_via_pairing(librespot_bin: str, device_name: str) -> str:
61 """
62 Advertise a Spotify Connect device and return the credential librespot stores once paired.
63
64 Blocks until the user selects the device in the official Spotify app; the caller is expected
65 to bound the wait (the setup flow's step deadline cancels it).
66
67 :param librespot_bin: Path to the librespot binary.
68 :param device_name: Device name to advertise to the Spotify app.
69 """
70 with tempfile.TemporaryDirectory() as cache_dir:
71 args = [
72 librespot_bin,
73 "--cache",
74 cache_dir,
75 "--disable-audio-cache",
76 "--backend",
77 "pipe",
78 "--name",
79 device_name,
80 ]
81 # stdout carries decoded audio once the user hits play; discard it so the pairing
82 # daemon never blocks on a pipe nobody reads
83 async with AsyncProcess(
84 args, stdout=asyncio.subprocess.DEVNULL, stderr=True, name="librespot-pairing"
85 ) as librespot_proc:
86 # librespot advertises over mDNS, which fails silently in host-network-less
87 # containers; without its log the user would just watch the step time out
88 librespot_proc.attach_stderr_reader(
89 asyncio.create_task(_log_pairing_output(librespot_proc))
90 )
91 return await _await_credentials_file(cache_dir)
92
93
94async def librespot_credentials_via_token(librespot_bin: str, access_token: str) -> str:
95 """
96 Exchange a keymaster access token for librespot's reusable stored credential.
97
98 :param librespot_bin: Path to the librespot binary.
99 :param access_token: Spotify access token minted with the keymaster client id.
100 :raises LoginFailed: When librespot could not turn the token into a stored credential.
101 """
102 with tempfile.TemporaryDirectory() as cache_dir:
103 returncode, output = await check_output(
104 librespot_bin,
105 "--cache",
106 cache_dir,
107 "--check-auth",
108 "--access-token",
109 access_token,
110 timeout=CHECK_AUTH_TIMEOUT,
111 )
112 if returncode != 0:
113 raise LoginFailed(
114 f"Librespot rejected the playback authorization: {output.decode().strip()}"
115 )
116 credentials_file = os.path.join(cache_dir, CREDENTIALS_FILE)
117 if not os.path.exists(credentials_file):
118 raise LoginFailed("Librespot did not store a playback credential")
119 return await asyncio.to_thread(_read_credentials_file, credentials_file)
120
121
122async def await_loopback_authorization(port: int, path: str) -> dict[str, str]:
123 """
124 Serve the loopback redirect target and return the OAuth params the browser arrives with.
125
126 Only reachable when the browser runs on the same host as Music Assistant; callers are
127 expected to offer a manual fallback for everyone else.
128
129 :param port: Loopback port to listen on.
130 :param path: Request path the redirect URI points at.
131 :raises OSError: When the port cannot be bound.
132 """
133 received: asyncio.Future[dict[str, str]] = asyncio.get_running_loop().create_future()
134
135 async def handle(request: web.Request) -> web.Response:
136 if not received.done():
137 received.set_result(dict(request.query))
138 return web.Response(text=LOOPBACK_RESPONSE_HTML, content_type="text/html")
139
140 app = web.Application()
141 app.router.add_get(path, handle)
142 runner = web.AppRunner(app)
143 await runner.setup()
144 try:
145 await web.TCPSite(runner, "127.0.0.1", port).start()
146 return await received
147 finally:
148 await runner.cleanup()
149
150
151async def get_spotify_token(
152 http_session: aiohttp.ClientSession,
153 client_id: str,
154 refresh_token: str,
155 session_name: str = "spotify",
156) -> dict[str, Any]:
157 """
158 Refresh Spotify access token using refresh token.
159
160 :param http_session: aiohttp client session.
161 :param client_id: Spotify client ID.
162 :param refresh_token: Spotify refresh token.
163 :param session_name: Name for logging purposes.
164 :return: Auth info dict with access_token, refresh_token, expires_at.
165 :raises LoginFailed: If token refresh fails.
166 """
167 params = {
168 "grant_type": "refresh_token",
169 "refresh_token": refresh_token,
170 "client_id": client_id,
171 }
172 err = "Unknown error"
173 for _ in range(2):
174 async with http_session.post(
175 "https://accounts.spotify.com/api/token", data=params
176 ) as response:
177 if response.status != 200:
178 err = await response.text()
179 # invalid_grant means the refresh token is revoked or expired (Spotify
180 # enforces a 6-month lifetime); retrying won't recover it, so fail now and
181 # let the caller clear the stored token and prompt re-authentication.
182 if "invalid_grant" in err or "revoked" in err:
183 raise LoginFailed(
184 f"Refresh token no longer valid for {session_name}: {err}",
185 translation_key="refresh_token_invalid",
186 translation_owner="provider.spotify",
187 )
188 # the token failed to refresh, we allow one retry
189 await asyncio.sleep(2)
190 continue
191 # if we reached this point, the token has been successfully refreshed
192 auth_info: dict[str, Any] = await response.json()
193 auth_info["expires_at"] = int(auth_info["expires_in"] + time.time())
194 # Spotify only returns a refresh_token when it rotates one; when the response
195 # omits it, keep using the existing token (per Spotify's refresh-token docs).
196 auth_info.setdefault("refresh_token", refresh_token)
197 return auth_info
198
199 raise LoginFailed(f"Failed to refresh {session_name} access token: {err}")
200
201
202async def _log_pairing_output(librespot_proc: AsyncProcess) -> None:
203 """Log the pairing daemon's output so a failure to advertise is diagnosable."""
204 async for line in librespot_proc.iter_stderr():
205 if "ERROR" in line or "WARN" in line:
206 LOGGER.warning("[librespot-pairing] %s", line)
207 else:
208 LOGGER.debug("[librespot-pairing] %s", line)
209
210
211async def _await_credentials_file(cache_dir: str) -> str:
212 """Poll librespot's cache directory until it holds a complete credential file."""
213 credentials_file = os.path.join(cache_dir, CREDENTIALS_FILE)
214 while True:
215 if os.path.exists(credentials_file):
216 try:
217 return await asyncio.to_thread(_read_credentials_file, credentials_file)
218 except OSError, ValueError:
219 # the file was caught mid-write; fall through and retry
220 pass
221 await asyncio.sleep(1)
222
223
224def _read_credentials_file(credentials_file: str) -> str:
225 """Read and validate librespot's credential file, returning its raw contents."""
226 with open(credentials_file, encoding="utf-8") as fileobj:
227 contents = fileobj.read()
228 if not json_loads(contents).get("auth_data"):
229 msg = "Incomplete librespot credential file"
230 raise ValueError(msg)
231 return contents
232