diff --git a/web/src/composables/useSpotifySettings.test.ts b/web/src/composables/useSpotifySettings.test.ts new file mode 100644 index 0000000..28b1b1c --- /dev/null +++ b/web/src/composables/useSpotifySettings.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { + buildSpotifyPayload, + parseSpotifyRedirect, + statusSummary, + SPOTIFY_DISCLAIMER, + type SpotifyConfigForm, + type SpotifyStatus, +} from "./useSpotifySettings.js"; + +function form(overrides: Partial = {}): SpotifyConfigForm { + return { + enabled: true, + backend: "auto", + clientId: "abc", + clientSecret: "", + deviceName: "TS-Bot", + bitrate: 320, + ...overrides, + }; +} + +describe("buildSpotifyPayload", () => { + it("always includes enabled/backend/clientId/deviceName/bitrate under a spotify key", () => { + const { spotify } = buildSpotifyPayload(form()); + expect(spotify).toMatchObject({ + enabled: true, + backend: "auto", + clientId: "abc", + deviceName: "TS-Bot", + bitrate: 320, + }); + }); + + it("omits clientSecret when blank (means unchanged, never wipes)", () => { + const { spotify } = buildSpotifyPayload(form({ clientSecret: "" })); + expect("clientSecret" in spotify).toBe(false); + }); + + it("includes clientSecret only when non-blank", () => { + const { spotify } = buildSpotifyPayload(form({ clientSecret: "s3cr3t" })); + expect(spotify.clientSecret).toBe("s3cr3t"); + }); + + it("carries a non-default backend through unchanged", () => { + const { spotify } = buildSpotifyPayload(form({ backend: "librespot" })); + expect(spotify.backend).toBe("librespot"); + }); +}); + +describe("parseSpotifyRedirect", () => { + it("maps ?spotify=success to 'success'", () => { + expect(parseSpotifyRedirect("?spotify=success")).toBe("success"); + }); + + it("maps ?spotify=error to 'error'", () => { + expect(parseSpotifyRedirect("?spotify=error")).toBe("error"); + }); + + it("returns null for unrelated params", () => { + expect(parseSpotifyRedirect("?x=1")).toBeNull(); + }); + + it("returns null for an empty search string", () => { + expect(parseSpotifyRedirect("")).toBeNull(); + }); + + it("returns null for an unexpected spotify value", () => { + expect(parseSpotifyRedirect("?spotify=maybe")).toBeNull(); + }); +}); + +describe("statusSummary", () => { + const ok: SpotifyStatus = { authorized: true, backend: "go-librespot", deviceName: "d", binaryAvailable: true }; + + it("is 'off' tone when disabled, regardless of status", () => { + expect(statusSummary(null, false).tone).toBe("off"); + expect(statusSummary(ok, false).tone).toBe("off"); + }); + + it("is 'warn' tone when enabled but status is unknown (null)", () => { + expect(statusSummary(null, true).tone).toBe("warn"); + }); + + it("is 'warn' tone when the binary is unavailable", () => { + const s: SpotifyStatus = { authorized: false, backend: "auto", deviceName: "d", binaryAvailable: false }; + expect(statusSummary(s, true).tone).toBe("warn"); + }); + + it("is 'warn' tone when the binary exists but not authorized", () => { + const s: SpotifyStatus = { authorized: false, backend: "auto", deviceName: "d", binaryAvailable: true }; + expect(statusSummary(s, true).tone).toBe("warn"); + }); + + it("is 'ok' tone when enabled, authorized and the binary is available", () => { + const summary = statusSummary(ok, true); + expect(summary.tone).toBe("ok"); + expect(summary.label).toContain("go-librespot"); + }); +}); + +describe("SPOTIFY_DISCLAIMER", () => { + it("mentions the Premium + grey-area risk copy", () => { + expect(SPOTIFY_DISCLAIMER).toContain("Premium"); + expect(SPOTIFY_DISCLAIMER.length).toBeGreaterThan(20); + }); +}); diff --git a/web/src/composables/useSpotifySettings.ts b/web/src/composables/useSpotifySettings.ts new file mode 100644 index 0000000..1c297cb --- /dev/null +++ b/web/src/composables/useSpotifySettings.ts @@ -0,0 +1,48 @@ +export interface SpotifyConfigForm { + enabled: boolean; + backend: "auto" | "go-librespot" | "librespot"; + clientId: string; + clientSecret: string; // blank means "unchanged" + deviceName: string; + bitrate: number; +} + +export interface SpotifyStatus { + authorized: boolean; + backend: string; + deviceName: string; + binaryAvailable: boolean; +} + +// 实验性 · 灰色地带 · 需要 Premium · 使用你自己的开发者应用凭据 +export const SPOTIFY_DISCLAIMER = + "实验性功能:通过 librespot 播放 Spotify 需要 Spotify Premium 账号,并使用你自己注册的 Spotify 开发者应用凭据。" + + "该方式处于 Spotify 服务条款的灰色地带,风险自负;默认关闭,不会内置任何共享凭据。"; + +export function buildSpotifyPayload(f: SpotifyConfigForm): { spotify: Record } { + const spotify: Record = { + enabled: f.enabled, + backend: f.backend, + clientId: f.clientId, + deviceName: f.deviceName, + bitrate: f.bitrate, + }; + if (f.clientSecret && f.clientSecret.length > 0) spotify.clientSecret = f.clientSecret; + return { spotify }; +} + +export function parseSpotifyRedirect(search: string): "success" | "error" | null { + const v = new URLSearchParams(search).get("spotify"); + return v === "success" || v === "error" ? v : null; +} + +export function statusSummary( + s: SpotifyStatus | null, + enabled: boolean, +): { label: string; tone: "ok" | "warn" | "off" } { + if (!enabled) return { label: "已关闭", tone: "off" }; + if (!s) return { label: "未知", tone: "warn" }; + if (!s.binaryAvailable) return { label: "未检测到 librespot 可执行文件", tone: "warn" }; + if (!s.authorized) return { label: "未授权(点击“连接 Spotify”登录)", tone: "warn" }; + return { label: `已就绪 · 后端 ${s.backend}`, tone: "ok" }; +} diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index efad80d..5c8557f 100755 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -449,6 +449,106 @@ + +
+

Spotify 播放(实验性)

+

{{ SPOTIFY_DISCLAIMER }}

+ + +
+

音质设置

@@ -812,7 +912,7 @@