/
/
/
1"""
2Setup flow for the NetEase Cloud Music provider.
3
4Authentication uses NetEase's QR login. The flow first collects the address of the
5local NeteaseCloudMusicApi-compatible backend, then shows a QR code the user scans
6with the NetEase Cloud Music app and confirms. Whenever the scan window elapses the
7QR code is minted afresh and the progress step is re-emitted in place, so the shown
8code never silently goes stale. The resulting login cookie and resolved user id are
9persisted as setup_data; the QR poll key and page URL that used to be smuggled through
10hidden config values now live only as locals for the duration of the flow.
11"""
12
13from __future__ import annotations
14
15import asyncio
16import time
17from typing import TYPE_CHECKING
18
19from music_assistant_models.config_entries import ConfigEntry
20from music_assistant_models.enums import ConfigEntryType
21from music_assistant_models.errors import (
22 InvalidDataError,
23 LoginFailed,
24 ResourceTemporarilyUnavailable,
25)
26
27from music_assistant.models.setup_flow import SetupFlowError, StepExpiredError
28
29from . import NcmApiClient, _extract_code, _extract_cookie, _extract_data, _resolve_uid
30from .constants import CONF_API_BASE_URL, CONF_COOKIE, CONF_UID, DEFAULT_API_BASE_URL
31
32if TYPE_CHECKING:
33 from music_assistant_models.config_entries import ConfigValueType
34
35 from music_assistant.models.setup_flow import SetupSession
36
37# scan window (seconds) a shown QR code is valid for before it is refreshed in place
38_QR_EXPIRES_IN = 120.0
39# delay (seconds) between poll attempts against the QR check endpoint
40_QR_POLL_INTERVAL = 1.5
41# NetEase QR check status codes
42_QR_CODE_EXPIRED = 800
43_QR_CODE_WAITING_SCAN = 801
44_QR_CODE_WAITING_CONFIRM = 802
45_QR_CODE_CONFIRMED = 803
46
47
48async def run_setup(session: SetupSession) -> None:
49 """
50 Run the NetEase QR login flow: pick the backend, scan a QR, store the cookie.
51
52 :param session: The setup session driving the flow.
53 """
54 prefill = (
55 session.context.setup_data.get(CONF_API_BASE_URL)
56 or session.context.values.get(CONF_API_BASE_URL)
57 or DEFAULT_API_BASE_URL
58 )
59 errors: dict[str, str] | None = None
60 while True:
61 values = await session.form(
62 [
63 ConfigEntry(
64 key=CONF_API_BASE_URL,
65 type=ConfigEntryType.STRING,
66 required=True,
67 default_value=DEFAULT_API_BASE_URL,
68 value=str(prefill),
69 ),
70 ],
71 step_id="user",
72 errors=errors,
73 )
74 api_base_url = str(values[CONF_API_BASE_URL]).strip()
75 prefill = api_base_url
76 client = NcmApiClient(session.mass.http_session, api_base_url)
77 try:
78 cookie, uid = await _run_qr_login(session, client)
79 except (InvalidDataError, LoginFailed, ResourceTemporarilyUnavailable) as err:
80 # most likely a wrong/unreachable backend URL: let the user correct it
81 errors = {"base": getattr(err, "translation_key", None) or str(err)}
82 continue
83 collected: dict[str, ConfigValueType] = {
84 CONF_API_BASE_URL: api_base_url,
85 CONF_COOKIE: cookie,
86 CONF_UID: uid,
87 }
88 try:
89 await session.finish(collected)
90 return
91 except SetupFlowError as err:
92 errors = {"base": err.translation_key or str(err)}
93
94
95async def _run_qr_login(session: SetupSession, client: NcmApiClient) -> tuple[str, str]:
96 """
97 Show a QR code and wait for the user to scan and confirm the login.
98
99 Mints a fresh QR code and re-emits the progress step whenever the scan window
100 expires, so the displayed code stays valid until the user completes the login.
101
102 :param session: The setup session driving the flow.
103 :param client: API client bound to the chosen backend base URL.
104 """
105 while True:
106 qr_key, qr_image = await _create_qr(client)
107 try:
108 return await session.progress_until(
109 _poll_qr_login(client, qr_key),
110 step_id="scan_qr",
111 image=qr_image,
112 expires_in=_QR_EXPIRES_IN,
113 )
114 except StepExpiredError:
115 # the scan window elapsed (deadline) or the backend reported the code
116 # expired: mint a fresh QR and re-emit the progress step in place
117 continue
118
119
120async def _create_qr(client: NcmApiClient) -> tuple[str, str]:
121 """
122 Create a fresh QR login session and return its poll key and data-URI image.
123
124 :param client: API client bound to the chosen backend base URL.
125 """
126 key_payload = await client.get("/login/qr/key", params={"timestamp": int(time.time() * 1000)})
127 key_data = _extract_data(key_payload)
128 qr_key = str(key_data.get("unikey") or key_data.get("key") or "").strip()
129 if not qr_key:
130 raise LoginFailed("Failed to generate NetEase QR key")
131 qr_payload = await client.get(
132 "/login/qr/create",
133 params={"key": qr_key, "qrimg": "true", "timestamp": int(time.time() * 1000)},
134 )
135 qr_data = _extract_data(qr_payload)
136 # the backend already returns qrimg as a data:image/png;base64,... string; the
137 # progress step takes exactly that, so it is emitted verbatim (no re-encoding)
138 qr_image = str(qr_data.get("qrimg") or "").strip()
139 if not qr_image:
140 raise LoginFailed("NetEase QR create did not return a QR image")
141 return qr_key, qr_image
142
143
144async def _poll_qr_login(client: NcmApiClient, qr_key: str) -> tuple[str, str]:
145 """
146 Poll the QR check endpoint until the login is confirmed, returning (cookie, uid).
147
148 Raises StepExpiredError when the backend reports the code expired, so the caller
149 refreshes the QR alongside the progress-step deadline.
150
151 :param client: API client bound to the chosen backend base URL.
152 :param qr_key: The QR poll token from _create_qr.
153 """
154 while True:
155 payload = await client.get(
156 "/login/qr/check",
157 params={"key": qr_key, "timestamp": int(time.time() * 1000)},
158 allow_codes={
159 _QR_CODE_EXPIRED,
160 _QR_CODE_WAITING_SCAN,
161 _QR_CODE_WAITING_CONFIRM,
162 _QR_CODE_CONFIRMED,
163 },
164 )
165 code = _extract_code(payload)
166 if code == _QR_CODE_CONFIRMED:
167 cookie = _extract_cookie(payload)
168 if not cookie:
169 raise LoginFailed("QR login succeeded but API response did not include cookie")
170 uid = await _resolve_uid(client, cookie)
171 return cookie, uid
172 if code == _QR_CODE_EXPIRED:
173 raise StepExpiredError
174 # 801 (awaiting scan) / 802 (scanned, awaiting confirm) / anything else: keep polling
175 await asyncio.sleep(_QR_POLL_INTERVAL)
176