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:
2026-07-12 08:07:25 -07:00
committed by Hop
parent 45656d74f1
commit 38b140905f
33 changed files with 13341 additions and 0 deletions
+31
View File
@@ -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();
});
});
+65
View File
@@ -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();
}