Build Resonant v0.1 as a polished single-user Tauri/OpenSubsonic music player with demo playback, responsive library browsing, search, favorites, queue, server setup, tests, and docs
Hop-State: A_06FNDQKBYQT60ENR92VY6KG Hop-Proposal: R_06FNDQJ64YSD232PJ7AT47R Hop-Task: T_06FNDK6YRGJ4TEATJEC6EQR Hop-Attempt: AT_06FNDK6YRMRZ1VHNB189B48
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildApiUrl } from "./subsonic";
|
||||
|
||||
describe("buildApiUrl", () => {
|
||||
it("builds a token-authenticated OpenSubsonic URL", () => {
|
||||
const url = buildApiUrl(
|
||||
{ url: "https://music.example.test", username: "listener", password: "secret" },
|
||||
"getAlbumList2",
|
||||
{ type: "recent", size: 12 },
|
||||
"fixedsalt",
|
||||
);
|
||||
|
||||
expect(url.pathname).toBe("/rest/getAlbumList2.view");
|
||||
expect(url.searchParams.get("u")).toBe("listener");
|
||||
expect(url.searchParams.get("s")).toBe("fixedsalt");
|
||||
expect(url.searchParams.get("t")).toMatch(/^[a-f0-9]{32}$/);
|
||||
expect(url.searchParams.get("type")).toBe("recent");
|
||||
expect(url.searchParams.get("size")).toBe("12");
|
||||
expect(url.search).not.toContain("secret");
|
||||
});
|
||||
|
||||
it("normalizes trailing slashes and endpoint suffixes", () => {
|
||||
vi.stubGlobal("crypto", { getRandomValues: (array: Uint8Array) => array.fill(7) });
|
||||
const url = buildApiUrl(
|
||||
{ url: "https://music.example.test/base/", username: "u", password: "p" },
|
||||
"/ping.view",
|
||||
);
|
||||
expect(url.pathname).toBe("/base/rest/ping.view");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import SparkMD5 from "spark-md5";
|
||||
import type { ServerConfig } from "../types";
|
||||
|
||||
const CLIENT_NAME = "resonant";
|
||||
const API_VERSION = "1.16.1";
|
||||
|
||||
export class SubsonicError extends Error {
|
||||
constructor(message: string, public readonly code?: number) {
|
||||
super(message);
|
||||
this.name = "SubsonicError";
|
||||
}
|
||||
}
|
||||
|
||||
export function createSalt(bytes = 6): string {
|
||||
const values = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(values);
|
||||
return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function buildApiUrl(
|
||||
config: ServerConfig,
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean | undefined> = {},
|
||||
salt = createSalt(),
|
||||
): URL {
|
||||
const base = config.url.endsWith("/") ? config.url : `${config.url}/`;
|
||||
const cleanEndpoint = endpoint.replace(/^\/+|\.view$/g, "");
|
||||
const url = new URL(`rest/${cleanEndpoint}.view`, base);
|
||||
const query = new URLSearchParams({
|
||||
u: config.username,
|
||||
t: SparkMD5.hash(`${config.password}${salt}`),
|
||||
s: salt,
|
||||
v: API_VERSION,
|
||||
c: CLIENT_NAME,
|
||||
f: "json",
|
||||
});
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) query.set(key, String(value));
|
||||
});
|
||||
url.search = query.toString();
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function requestSubsonic<T>(
|
||||
config: ServerConfig,
|
||||
endpoint: string,
|
||||
params?: Record<string, string | number | boolean | undefined>,
|
||||
): Promise<T> {
|
||||
const response = await fetch(buildApiUrl(config, endpoint, params), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) throw new SubsonicError(`Server returned ${response.status}.`);
|
||||
|
||||
const payload = await response.json();
|
||||
const envelope = payload["subsonic-response"];
|
||||
if (!envelope || envelope.status !== "ok") {
|
||||
throw new SubsonicError(envelope?.error?.message ?? "The server rejected the request.", envelope?.error?.code);
|
||||
}
|
||||
return envelope as T;
|
||||
}
|
||||
|
||||
export function streamUrl(config: ServerConfig, trackId: string): string {
|
||||
return buildApiUrl(config, "stream", { id: trackId }).toString();
|
||||
}
|
||||
Reference in New Issue
Block a user