diff --git a/web/src/app/api/config/route.test.ts b/web/src/app/api/config/route.test.ts index eb85b4f..22bad83 100644 --- a/web/src/app/api/config/route.test.ts +++ b/web/src/app/api/config/route.test.ts @@ -36,6 +36,22 @@ describe("config API", () => { expect(JSON.stringify(body)).not.toContain("hunter2"); }); + it("stores the Qobuz user id (plain) and auth token (encrypted), masked on GET", async () => { + await PUT(putReq({ qobuzUserId: "123456", qobuzToken: "secrettoken" })); + + const uid = await prisma.config.findUnique({ where: { key: "qobuz.user_id" } }); + const tok = await prisma.config.findUnique({ where: { key: "qobuz.auth_token" } }); + expect(uid!.value).toBe("123456"); + expect(uid!.secret).toBe(false); + expect(tok!.secret).toBe(true); + expect(tok!.value).not.toContain("secrettoken"); // encrypted envelope + + const body = await (await GET()).json(); + expect(body.qobuzUserId).toBe("123456"); + expect(body.qobuzTokenSet).toBe(true); + expect(JSON.stringify(body)).not.toContain("secrettoken"); + }); + it("empty string leaves a field unchanged", async () => { await PUT(putReq({ qobuzPassword: "first" })); await PUT(putReq({ qobuzPassword: "" })); diff --git a/web/src/app/api/config/route.ts b/web/src/app/api/config/route.ts index 23078fd..add8476 100644 --- a/web/src/app/api/config/route.ts +++ b/web/src/app/api/config/route.ts @@ -5,6 +5,8 @@ import { encryptSecret } from "@/lib/crypto"; const FIELDS: Record = { qobuzEmail: { key: "qobuz.email", secret: false }, qobuzPassword: { key: "qobuz.password", secret: true }, + qobuzUserId: { key: "qobuz.user_id", secret: false }, + qobuzToken: { key: "qobuz.auth_token", secret: true }, slskdUrl: { key: "slskd.url", secret: false }, slskdApiKey: { key: "slskd.api_key", secret: true }, }; @@ -36,8 +38,10 @@ export async function GET() { const byKey = new Map(rows.map((r) => [r.key, r])); return Response.json({ qobuzEmail: byKey.get("qobuz.email")?.value ?? "", + qobuzUserId: byKey.get("qobuz.user_id")?.value ?? "", slskdUrl: byKey.get("slskd.url")?.value ?? "", qobuzPasswordSet: byKey.has("qobuz.password"), + qobuzTokenSet: byKey.has("qobuz.auth_token"), slskdApiKeySet: byKey.has("slskd.api_key"), }); } diff --git a/web/src/app/settings/settings-form.tsx b/web/src/app/settings/settings-form.tsx index 6a80f4d..0df611d 100644 --- a/web/src/app/settings/settings-form.tsx +++ b/web/src/app/settings/settings-form.tsx @@ -2,14 +2,24 @@ import { useEffect, useState } from "react"; -type Loaded = { qobuzEmail: string; slskdUrl: string; qobuzPasswordSet: boolean; slskdApiKeySet: boolean }; +type Loaded = { + qobuzEmail: string; + qobuzUserId: string; + slskdUrl: string; + qobuzPasswordSet: boolean; + qobuzTokenSet: boolean; + slskdApiKeySet: boolean; +}; export function SettingsForm() { const [qobuzEmail, setQobuzEmail] = useState(""); const [qobuzPassword, setQobuzPassword] = useState(""); + const [qobuzUserId, setQobuzUserId] = useState(""); + const [qobuzToken, setQobuzToken] = useState(""); const [slskdUrl, setSlskdUrl] = useState(""); const [slskdApiKey, setSlskdApiKey] = useState(""); const [pwSet, setPwSet] = useState(false); + const [tokenSet, setTokenSet] = useState(false); const [keySet, setKeySet] = useState(false); const [saved, setSaved] = useState(false); @@ -18,8 +28,10 @@ export function SettingsForm() { .then((r) => r.json()) .then((c: Loaded) => { setQobuzEmail(c.qobuzEmail); + setQobuzUserId(c.qobuzUserId); setSlskdUrl(c.slskdUrl); setPwSet(c.qobuzPasswordSet); + setTokenSet(c.qobuzTokenSet); setKeySet(c.slskdApiKeySet); }); }, []); @@ -29,21 +41,34 @@ export function SettingsForm() { await fetch("/api/config", { method: "PUT", headers: { "content-type": "application/json" }, - body: JSON.stringify({ qobuzEmail, qobuzPassword, slskdUrl, slskdApiKey }), + body: JSON.stringify({ qobuzEmail, qobuzPassword, qobuzUserId, qobuzToken, slskdUrl, slskdApiKey }), }); setQobuzPassword(""); + setQobuzToken(""); setSlskdApiKey(""); setSaved(true); setPwSet(pwSet || qobuzPassword !== ""); + setTokenSet(tokenSet || qobuzToken !== ""); setKeySet(keySet || slskdApiKey !== ""); } return (
- - - - +
+ Qobuz — email/password + + +
+
+ Qobuz — auth token (more reliable; overrides email/password) + + +
+
+ Soulseek (slskd) + + +
{saved ? Saved. : null}
diff --git a/worker/lyra_worker/adapters/_streamrip.py b/worker/lyra_worker/adapters/_streamrip.py index 3192ca0..a1b3cf9 100644 --- a/worker/lyra_worker/adapters/_streamrip.py +++ b/worker/lyra_worker/adapters/_streamrip.py @@ -19,18 +19,28 @@ class StreamripClient: def __init__(self, config: dict): self._email = config.get("qobuz.email", "") self._password = config.get("qobuz.password", "") + self._user_id = config.get("qobuz.user_id", "") + self._token = config.get("qobuz.auth_token", "") def is_configured(self) -> bool: - return bool(self._email and self._password) + # either a user_id + auth token, or an email + password + return bool((self._user_id and self._token) or (self._email and self._password)) def _make_config(self, download_folder: str | None = None): from streamrip.config import Config cfg = Config.defaults() - cfg.session.qobuz.use_auth_token = False - cfg.session.qobuz.email_or_userid = self._email - cfg.session.qobuz.password_or_token = _hashed_password(self._password) - cfg.session.qobuz.quality = 3 + q = cfg.session.qobuz + if self._user_id and self._token: + # token auth: more reliable than email/password, which Qobuz often rejects + q.use_auth_token = True + q.email_or_userid = self._user_id + q.password_or_token = self._token + else: + q.use_auth_token = False + q.email_or_userid = self._email + q.password_or_token = _hashed_password(self._password) + q.quality = 3 if download_folder is not None: cfg.session.downloads.folder = download_folder return cfg diff --git a/worker/tests/test_streamrip_auth.py b/worker/tests/test_streamrip_auth.py index 11a9a3b..9ee34df 100644 --- a/worker/tests/test_streamrip_auth.py +++ b/worker/tests/test_streamrip_auth.py @@ -11,5 +11,12 @@ def test_hashed_password_is_md5_hex(): def test_is_configured(): + # email + password assert StreamripClient({"qobuz.email": "a@b.c", "qobuz.password": "p"}).is_configured() is True + # user_id + auth token (the reliable path) + assert StreamripClient({"qobuz.user_id": "123", "qobuz.auth_token": "tok"}).is_configured() is True + # nothing configured assert StreamripClient({}).is_configured() is False + # partial (token without user_id, password without email) -> not configured + assert StreamripClient({"qobuz.auth_token": "tok"}).is_configured() is False + assert StreamripClient({"qobuz.password": "p"}).is_configured() is False