105 lines
3.1 KiB
TypeScript

/** BeatSaver public API — map key / hash resolution for campaign `songid` + `hash`. */
const BEATSAVER_BASE = "https://api.beatsaver.com";
export type BeatSaverMapMeta = {
id: string;
name?: string;
/** Lowercase SHA-1 for latest / selected version */
hash: string;
songName?: string;
songSubName?: string;
levelAuthorName?: string;
uploaderName?: string;
coverURL?: string;
};
function asRecord(v: unknown): Record<string, unknown> | null {
return v != null && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: null;
}
async function fetchJson(url: string): Promise<unknown> {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`BeatSaver request failed ${res.status} ${url}`);
}
return res.json();
}
function pickLatestVersionHashes(
versions: unknown,
): { hash: string; coverURL?: string } | null {
if (!Array.isArray(versions) || versions.length === 0) return null;
const last = versions[versions.length - 1];
const vr = asRecord(last);
if (!vr) return null;
const h = vr["hash"];
const coverURL = vr["coverURL"];
return {
hash: typeof h === "string" ? h : "",
coverURL: typeof coverURL === "string" ? coverURL : undefined,
};
}
export function mapBeatSaverResponseToMeta(
data: unknown,
): BeatSaverMapMeta | null {
const r = asRecord(data);
if (!r) return null;
const meta = asRecord(r["metadata"]);
const uid = asRecord(r["uploader"]);
const vhash = pickLatestVersionHashes(r["versions"]);
const id = r["id"];
if (!vhash?.hash || typeof id !== "string") return null;
return {
id,
name: typeof r["name"] === "string" ? r["name"] : undefined,
hash: vhash.hash,
songName: meta?.["songName"] as string | undefined,
songSubName: meta?.["songSubName"] as string | undefined,
levelAuthorName: meta?.["levelAuthorName"] as string | undefined,
uploaderName: uid?.["name"] as string | undefined,
coverURL: vhash.coverURL,
};
}
export async function fetchBeatSaverByKey(
key: string,
): Promise<BeatSaverMapMeta | null> {
const trimmed = key.trim().toLowerCase();
if (!trimmed) return null;
const data = await fetchJson(
`${BEATSAVER_BASE}/maps/id/${encodeURIComponent(trimmed)}`,
);
return mapBeatSaverResponseToMeta(data);
}
export async function fetchBeatSaverByHash(
hash: string,
): Promise<BeatSaverMapMeta | null> {
const h = hash.trim().toLowerCase();
if (!h) return null;
const data = await fetchJson(
`${BEATSAVER_BASE}/maps/hash/${encodeURIComponent(h)}`,
);
return mapBeatSaverResponseToMeta(data);
}
/** Strip Twitch-style `!bsr` prefix when pasting a map key (e.g. `!bsr 43a47` → `43a47`). */
export function normalizeBeatSaverLookupInput(input: string): string {
return input.trim().replace(/^!bsr\s*/i, "").trim();
}
export async function resolveBeatSaverMeta(
input: string,
): Promise<BeatSaverMapMeta | null> {
const s = normalizeBeatSaverLookupInput(input);
if (!s) return null;
if (/^[a-f0-9]{40}$/i.test(s)) {
return await fetchBeatSaverByHash(s);
}
return await fetchBeatSaverByKey(s);
}