diff --git a/web/src/app/api/config/route.test.ts b/web/src/app/api/config/route.test.ts index e491f3a..2ad1b84 100644 --- a/web/src/app/api/config/route.test.ts +++ b/web/src/app/api/config/route.test.ts @@ -101,3 +101,32 @@ describe("config DELETE", () => { expect(res.status).toBe(200); }); }); + +describe("config API — Last.fm", () => { + it("stores the API key encrypted and username plain", async () => { + await PUT(putReq({ lastfmApiKey: "abc123", lastfmUsername: "jonathan" })); + + const key = await prisma.config.findUnique({ where: { key: "lastfm.api_key" } }); + const username = await prisma.config.findUnique({ where: { key: "lastfm.username" } }); + expect(key!.secret).toBe(true); + expect(key!.value).not.toContain("abc123"); // encrypted envelope + expect(username!.secret).toBe(false); + expect(username!.value).toBe("jonathan"); + }); + + it("GET masks the API key and reports the username", async () => { + await PUT(putReq({ lastfmApiKey: "abc123", lastfmUsername: "jonathan" })); + const body = await (await GET()).json(); + expect(body.lastfmUsername).toBe("jonathan"); + expect(body.lastfmApiKeySet).toBe(true); + expect(JSON.stringify(body)).not.toContain("abc123"); + }); + + it("DELETE clears the API key", async () => { + await PUT(putReq({ lastfmApiKey: "abc123" })); + const res = await DELETE(delReq("lastfmApiKey")); + expect(res.status).toBe(200); + expect(await prisma.config.findUnique({ where: { key: "lastfm.api_key" } })).toBeNull(); + expect((await (await GET()).json()).lastfmApiKeySet).toBe(false); + }); +}); diff --git a/web/src/app/api/config/route.ts b/web/src/app/api/config/route.ts index 65a531b..98923bc 100644 --- a/web/src/app/api/config/route.ts +++ b/web/src/app/api/config/route.ts @@ -9,6 +9,8 @@ const FIELDS: Record = { qobuzToken: { key: "qobuz.auth_token", secret: true }, slskdUrl: { key: "slskd.url", secret: false }, slskdApiKey: { key: "slskd.api_key", secret: true }, + lastfmApiKey: { key: "lastfm.api_key", secret: true }, + lastfmUsername: { key: "lastfm.username", secret: false }, }; export async function PUT(request: Request) { @@ -40,9 +42,11 @@ export async function GET() { qobuzEmail: byKey.get("qobuz.email")?.value ?? "", qobuzUserId: byKey.get("qobuz.user_id")?.value ?? "", slskdUrl: byKey.get("slskd.url")?.value ?? "", + lastfmUsername: byKey.get("lastfm.username")?.value ?? "", qobuzPasswordSet: byKey.has("qobuz.password"), qobuzTokenSet: byKey.has("qobuz.auth_token"), slskdApiKeySet: byKey.has("slskd.api_key"), + lastfmApiKeySet: byKey.has("lastfm.api_key"), }); } diff --git a/web/src/app/settings/settings-form.tsx b/web/src/app/settings/settings-form.tsx index ded34ec..400a0cf 100644 --- a/web/src/app/settings/settings-form.tsx +++ b/web/src/app/settings/settings-form.tsx @@ -7,13 +7,15 @@ type Loaded = { qobuzEmail: string; qobuzUserId: string; slskdUrl: string; + lastfmUsername: string; qobuzPasswordSet: boolean; qobuzTokenSet: boolean; slskdApiKeySet: boolean; + lastfmApiKeySet: boolean; }; -type Tab = "Qobuz" | "Soulseek" | "Library" | "Monitor" | "Discovery"; -const TABS: Tab[] = ["Qobuz", "Soulseek", "Library", "Monitor", "Discovery"]; +type Tab = "Qobuz" | "Soulseek" | "Last.fm" | "Library" | "Monitor" | "Discovery"; +const TABS: Tab[] = ["Qobuz", "Soulseek", "Last.fm", "Library", "Monitor", "Discovery"]; const DISCOVERY_FIELDS: [string, string][] = [ ["discover.intervalHours", "Interval (hours)"], @@ -38,9 +40,12 @@ export function SettingsForm() { const [qobuzToken, setQobuzToken] = useState(""); const [slskdUrl, setSlskdUrl] = useState(""); const [slskdApiKey, setSlskdApiKey] = useState(""); + const [lastfmUsername, setLastfmUsername] = useState(""); + const [lastfmApiKey, setLastfmApiKey] = useState(""); const [pwSet, setPwSet] = useState(false); const [tokenSet, setTokenSet] = useState(false); const [keySet, setKeySet] = useState(false); + const [lastfmKeySet, setLastfmKeySet] = useState(false); const [saved, setSaved] = useState(false); const [scanResult, setScanResult] = useState(null); const [scanRequested, setScanRequested] = useState(false); @@ -80,9 +85,11 @@ export function SettingsForm() { setQobuzEmail(c.qobuzEmail); setQobuzUserId(c.qobuzUserId); setSlskdUrl(c.slskdUrl); + setLastfmUsername(c.lastfmUsername); setPwSet(c.qobuzPasswordSet); setTokenSet(c.qobuzTokenSet); setKeySet(c.slskdApiKeySet); + setLastfmKeySet(c.lastfmApiKeySet); }); }, []); @@ -103,16 +110,27 @@ export function SettingsForm() { await fetch("/api/config", { method: "PUT", headers: { "content-type": "application/json" }, - body: JSON.stringify({ qobuzEmail, qobuzPassword, qobuzUserId, qobuzToken, slskdUrl, slskdApiKey }), + body: JSON.stringify({ + qobuzEmail, + qobuzPassword, + qobuzUserId, + qobuzToken, + slskdUrl, + slskdApiKey, + lastfmApiKey, + lastfmUsername, + }), }); setQobuzPassword(""); setQobuzToken(""); setSlskdApiKey(""); + setLastfmApiKey(""); setSaved(true); setTimeout(() => setSaved(false), 1500); setPwSet(pwSet || qobuzPassword !== ""); setTokenSet(tokenSet || qobuzToken !== ""); setKeySet(keySet || slskdApiKey !== ""); + setLastfmKeySet(lastfmKeySet || lastfmApiKey !== ""); } function setCfg(key: string, value: string) { @@ -147,16 +165,17 @@ export function SettingsForm() { const secretHint = (set: boolean) => (set ? · set : null); - async function clearField(field: "qobuzPassword" | "qobuzToken" | "slskdApiKey") { + async function clearField(field: "qobuzPassword" | "qobuzToken" | "slskdApiKey" | "lastfmApiKey") { await fetch(`/api/config?field=${field}`, { method: "DELETE" }); if (field === "qobuzPassword") { setPwSet(false); setQobuzPassword(""); } if (field === "qobuzToken") { setTokenSet(false); setQobuzToken(""); } if (field === "slskdApiKey") { setKeySet(false); setSlskdApiKey(""); } + if (field === "lastfmApiKey") { setLastfmKeySet(false); setLastfmApiKey(""); } } return (
- +
{TABS.map((t) => ( @@ -241,6 +260,34 @@ export function SettingsForm() { ) : null} + {tab === "Last.fm" ? ( +
+
Last.fm
+
+ + +
+
+ + {saved ? Saved : null} +
+
+ ) : null} + {tab === "Library" ? (