feat: Qobuz auth-token (user_id + token) login as a reliable alternative to email/password
Qobuz's email/password API login returns 401 even with valid credentials (a known Qobuz limitation, unaffected by streamrip version). Add settings fields for a Qobuz user ID + auth token (token stored encrypted); when both are present StreamripClient uses use_auth_token=True, else falls back to email/password. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,22 @@ describe("config API", () => {
|
|||||||
expect(JSON.stringify(body)).not.toContain("hunter2");
|
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 () => {
|
it("empty string leaves a field unchanged", async () => {
|
||||||
await PUT(putReq({ qobuzPassword: "first" }));
|
await PUT(putReq({ qobuzPassword: "first" }));
|
||||||
await PUT(putReq({ qobuzPassword: "" }));
|
await PUT(putReq({ qobuzPassword: "" }));
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { encryptSecret } from "@/lib/crypto";
|
|||||||
const FIELDS: Record<string, { key: string; secret: boolean }> = {
|
const FIELDS: Record<string, { key: string; secret: boolean }> = {
|
||||||
qobuzEmail: { key: "qobuz.email", secret: false },
|
qobuzEmail: { key: "qobuz.email", secret: false },
|
||||||
qobuzPassword: { key: "qobuz.password", secret: true },
|
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 },
|
slskdUrl: { key: "slskd.url", secret: false },
|
||||||
slskdApiKey: { key: "slskd.api_key", secret: true },
|
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]));
|
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||||
return Response.json({
|
return Response.json({
|
||||||
qobuzEmail: byKey.get("qobuz.email")?.value ?? "",
|
qobuzEmail: byKey.get("qobuz.email")?.value ?? "",
|
||||||
|
qobuzUserId: byKey.get("qobuz.user_id")?.value ?? "",
|
||||||
slskdUrl: byKey.get("slskd.url")?.value ?? "",
|
slskdUrl: byKey.get("slskd.url")?.value ?? "",
|
||||||
qobuzPasswordSet: byKey.has("qobuz.password"),
|
qobuzPasswordSet: byKey.has("qobuz.password"),
|
||||||
|
qobuzTokenSet: byKey.has("qobuz.auth_token"),
|
||||||
slskdApiKeySet: byKey.has("slskd.api_key"),
|
slskdApiKeySet: byKey.has("slskd.api_key"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,24 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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() {
|
export function SettingsForm() {
|
||||||
const [qobuzEmail, setQobuzEmail] = useState("");
|
const [qobuzEmail, setQobuzEmail] = useState("");
|
||||||
const [qobuzPassword, setQobuzPassword] = useState("");
|
const [qobuzPassword, setQobuzPassword] = useState("");
|
||||||
|
const [qobuzUserId, setQobuzUserId] = useState("");
|
||||||
|
const [qobuzToken, setQobuzToken] = useState("");
|
||||||
const [slskdUrl, setSlskdUrl] = useState("");
|
const [slskdUrl, setSlskdUrl] = useState("");
|
||||||
const [slskdApiKey, setSlskdApiKey] = useState("");
|
const [slskdApiKey, setSlskdApiKey] = useState("");
|
||||||
const [pwSet, setPwSet] = useState(false);
|
const [pwSet, setPwSet] = useState(false);
|
||||||
|
const [tokenSet, setTokenSet] = useState(false);
|
||||||
const [keySet, setKeySet] = useState(false);
|
const [keySet, setKeySet] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
@@ -18,8 +28,10 @@ export function SettingsForm() {
|
|||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((c: Loaded) => {
|
.then((c: Loaded) => {
|
||||||
setQobuzEmail(c.qobuzEmail);
|
setQobuzEmail(c.qobuzEmail);
|
||||||
|
setQobuzUserId(c.qobuzUserId);
|
||||||
setSlskdUrl(c.slskdUrl);
|
setSlskdUrl(c.slskdUrl);
|
||||||
setPwSet(c.qobuzPasswordSet);
|
setPwSet(c.qobuzPasswordSet);
|
||||||
|
setTokenSet(c.qobuzTokenSet);
|
||||||
setKeySet(c.slskdApiKeySet);
|
setKeySet(c.slskdApiKeySet);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -29,21 +41,34 @@ export function SettingsForm() {
|
|||||||
await fetch("/api/config", {
|
await fetch("/api/config", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ qobuzEmail, qobuzPassword, slskdUrl, slskdApiKey }),
|
body: JSON.stringify({ qobuzEmail, qobuzPassword, qobuzUserId, qobuzToken, slskdUrl, slskdApiKey }),
|
||||||
});
|
});
|
||||||
setQobuzPassword("");
|
setQobuzPassword("");
|
||||||
|
setQobuzToken("");
|
||||||
setSlskdApiKey("");
|
setSlskdApiKey("");
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setPwSet(pwSet || qobuzPassword !== "");
|
setPwSet(pwSet || qobuzPassword !== "");
|
||||||
|
setTokenSet(tokenSet || qobuzToken !== "");
|
||||||
setKeySet(keySet || slskdApiKey !== "");
|
setKeySet(keySet || slskdApiKey !== "");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<label>Qobuz email <input aria-label="qobuz email" value={qobuzEmail} onChange={(e) => setQobuzEmail(e.target.value)} /></label>
|
<fieldset>
|
||||||
<label>Qobuz password <input aria-label="qobuz password" type="password" placeholder={pwSet ? "•••• (set)" : ""} value={qobuzPassword} onChange={(e) => setQobuzPassword(e.target.value)} /></label>
|
<legend>Qobuz — email/password</legend>
|
||||||
<label>slskd URL <input aria-label="slskd url" value={slskdUrl} onChange={(e) => setSlskdUrl(e.target.value)} /></label>
|
<label>Qobuz email <input aria-label="qobuz email" value={qobuzEmail} onChange={(e) => setQobuzEmail(e.target.value)} /></label>
|
||||||
<label>slskd API key <input aria-label="slskd api key" type="password" placeholder={keySet ? "•••• (set)" : ""} value={slskdApiKey} onChange={(e) => setSlskdApiKey(e.target.value)} /></label>
|
<label>Qobuz password <input aria-label="qobuz password" type="password" placeholder={pwSet ? "•••• (set)" : ""} value={qobuzPassword} onChange={(e) => setQobuzPassword(e.target.value)} /></label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Qobuz — auth token (more reliable; overrides email/password)</legend>
|
||||||
|
<label>Qobuz user ID <input aria-label="qobuz user id" value={qobuzUserId} onChange={(e) => setQobuzUserId(e.target.value)} /></label>
|
||||||
|
<label>Qobuz auth token <input aria-label="qobuz auth token" type="password" placeholder={tokenSet ? "•••• (set)" : ""} value={qobuzToken} onChange={(e) => setQobuzToken(e.target.value)} /></label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Soulseek (slskd)</legend>
|
||||||
|
<label>slskd URL <input aria-label="slskd url" value={slskdUrl} onChange={(e) => setSlskdUrl(e.target.value)} /></label>
|
||||||
|
<label>slskd API key <input aria-label="slskd api key" type="password" placeholder={keySet ? "•••• (set)" : ""} value={slskdApiKey} onChange={(e) => setSlskdApiKey(e.target.value)} /></label>
|
||||||
|
</fieldset>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
{saved ? <span> Saved.</span> : null}
|
{saved ? <span> Saved.</span> : null}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -19,18 +19,28 @@ class StreamripClient:
|
|||||||
def __init__(self, config: dict):
|
def __init__(self, config: dict):
|
||||||
self._email = config.get("qobuz.email", "")
|
self._email = config.get("qobuz.email", "")
|
||||||
self._password = config.get("qobuz.password", "")
|
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:
|
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):
|
def _make_config(self, download_folder: str | None = None):
|
||||||
from streamrip.config import Config
|
from streamrip.config import Config
|
||||||
|
|
||||||
cfg = Config.defaults()
|
cfg = Config.defaults()
|
||||||
cfg.session.qobuz.use_auth_token = False
|
q = cfg.session.qobuz
|
||||||
cfg.session.qobuz.email_or_userid = self._email
|
if self._user_id and self._token:
|
||||||
cfg.session.qobuz.password_or_token = _hashed_password(self._password)
|
# token auth: more reliable than email/password, which Qobuz often rejects
|
||||||
cfg.session.qobuz.quality = 3
|
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:
|
if download_folder is not None:
|
||||||
cfg.session.downloads.folder = download_folder
|
cfg.session.downloads.folder = download_folder
|
||||||
return cfg
|
return cfg
|
||||||
|
|||||||
@@ -11,5 +11,12 @@ def test_hashed_password_is_md5_hex():
|
|||||||
|
|
||||||
|
|
||||||
def test_is_configured():
|
def test_is_configured():
|
||||||
|
# email + password
|
||||||
assert StreamripClient({"qobuz.email": "a@b.c", "qobuz.password": "p"}).is_configured() is True
|
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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user