Files
musicmouse/web/src/api/client.ts

142 lines
5.9 KiB
TypeScript

/** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */
import type {
Album,
HaConfig,
HaEntityState,
LircConfig,
PlayerState,
RemoteMapping,
RemoteSlotInput,
Settings,
TippenCurriculum,
TippenProgress,
TippenRunInput,
TippenRunResult,
TippenSettings,
TrackDetail,
} from "./types";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`/api${path}`, {
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
...init,
});
if (!response.ok) {
throw new Error(`${init?.method ?? "GET"} ${path} failed: ${response.status}`);
}
return response.status === 204 ? (undefined as T) : ((await response.json()) as T);
}
function post(path: string, body?: unknown): Promise<void> {
return request<void>(path, {
method: "POST",
body: body === undefined ? undefined : JSON.stringify(body),
});
}
/** GET, treating a 404 as an expected "not configured" rather than an error - unlike
* `request()`, which throws on it. Anything else that isn't ok still throws. */
async function fetchOrNullOn404<T>(path: string): Promise<T | null> {
const response = await fetch(`/api${path}`);
if (response.status === 404) return null;
if (!response.ok) throw new Error(`GET ${path} failed: ${response.status}`);
return (await response.json()) as T;
}
/** GET, treating *any* non-ok response as "nothing to show" rather than an error - for
* callers that poll and would rather fall back quietly than crash the poll loop. */
async function fetchOrNullOnError<T>(path: string): Promise<T | null> {
const response = await fetch(`/api${path}`);
if (!response.ok) return null;
return (await response.json()) as T;
}
/** `null` means the room-control page isn't configured, not an error. */
const fetchHaConfig = (): Promise<HaConfig | null> => fetchOrNullOn404<HaConfig>("/ha");
/** `null` means the IR remote isn't configured, not an error. */
const fetchLircConfig = (): Promise<LircConfig | null> => fetchOrNullOn404<LircConfig>("/lirc");
/** `null` means the typing game isn't configured, not an error - the tab still shows,
* just with a "not set up" placeholder instead of a lesson map. */
const fetchTippenCurriculum = (): Promise<TippenCurriculum | null> =>
fetchOrNullOn404<TippenCurriculum>("/tippen/curriculum");
/** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable
* right now" (the backend answers the latter with a 502) - the room page treats a
* device with no state the same way either way, rather than crashing on a poll. */
const fetchHaState = (entityId: string): Promise<HaEntityState | null> =>
fetchOrNullOnError<HaEntityState>(`/ha/states/${entityId}`);
async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEntityState>> {
const results = await Promise.all(entityIds.map(fetchHaState));
const byId: Record<string, HaEntityState> = {};
results.forEach((state, index) => {
if (state) byId[entityIds[index]!] = state;
});
return byId;
}
/** `null` means "not analyzed" (the backend's expected 404 for this), not an error -
* the ambient background just falls back to its un-analyzed baseline for that track. */
const fetchTrackDetail = (albumId: string, trackIndex: number): Promise<TrackDetail | null> =>
fetchOrNullOnError<TrackDetail>(`/tracks/${albumId}/${trackIndex}/analysis`);
export const api = {
library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums),
state: () => request<PlayerState>("/state"),
refreshLibrary: () => post("/library/refresh"),
play: (albumId: string, trackIndex = 0) =>
post("/play", { album_id: albumId, track_index: trackIndex }),
resume: () => post("/resume"),
pause: () => post("/pause"),
next: () => post("/next"),
previous: () => post("/previous"),
seek: (position: number) => post("/seek", { position }),
setVolume: (percent: number) => post("/volume", { percent }),
nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }),
settings: () => request<Settings>("/settings"),
saveSettings: (settings: Settings) =>
request<Settings>("/settings", { method: "PUT", body: JSON.stringify(settings) }),
haConfig: fetchHaConfig,
haStates: fetchHaStates,
haCallService: (domain: string, service: string, body: Record<string, unknown>) =>
post(`/ha/services/${domain}/${service}`, body),
trackDetail: fetchTrackDetail,
lircConfig: fetchLircConfig,
remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),
tippenCurriculum: fetchTippenCurriculum,
tippenProgress: () => request<TippenProgress>("/tippen/progress"),
saveTippenSettings: (settings: TippenSettings) =>
request<TippenSettings>("/tippen/settings", { method: "PUT", body: JSON.stringify(settings) }),
recordTippenRun: (body: TippenRunInput) =>
request<TippenRunResult>("/tippen/runs", { method: "POST", body: JSON.stringify(body) }),
};
/** Cover URL. `thumb` is the small file for cards and rows; only the play view needs
* `full`. With a `version` (`Album.cover_v`) the URL names one exact set of bytes and the
* server marks it immutable, so the browser never asks for it twice; without one it
* revalidates on every use. */
export function coverUrl(
albumId: string,
{ size = "full", version }: { size?: "thumb" | "full"; version?: number } = {},
): string {
const params: string[] = [];
if (size === "thumb") params.push("size=thumb");
if (version) params.push(`v=${version}`);
return `/api/albums/${albumId}/cover${params.length ? `?${params.join("&")}` : ""}`;
}
/** Largest `Cover` `size` (CSS px) that is served the thumbnail; the play view's 340 gets
* the full file. Keep in step with `THUMB_COVER_PX` in the backend's `library/cache.py`. */
export const THUMB_MAX_SIZE = 220;