105 lines
3.1 KiB
TypeScript
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);
|
|
}
|