Major cleanup and add toml configuration

This commit is contained in:
2026-04-13 12:01:51 -07:00
parent 9dbd17eb49
commit e43d177afe
21 changed files with 716 additions and 1321 deletions
+21 -163
View File
@@ -3,9 +3,12 @@ import type {
BeatLeaderLeaderboard,
BeatLeaderLeaderboardsByHashResponse,
BeatLeaderScore,
BeatLeaderScoresResponse,
FriendMode,
} from "./types.ts";
import { mirrorOverlayLog } from "./overlay-server-log.ts";
interface BeatLeaderLeaderboardScoresResponse {
scores?: BeatLeaderScore[];
}
const BASE_URL = "https://api.beatleader.com";
const PAGE_SIZE = 100;
@@ -16,14 +19,6 @@ const PAGE_SIZE = 100;
*/
const MAX_LEADERBOARD_SCORE_PAGES = 2000;
const USE_RUNTIME_PROXY = typeof document !== "undefined";
export type FriendMode = "mutual" | "following" | "followers";
/** Browser overlay only: BeatLeader request/result tracing (Deno tests use no `document`). */
function blDiag(phase: string, detail: Record<string, unknown>) {
if (!USE_RUNTIME_PROXY) return;
console.log(`[BS+ overlay] beatleader:${phase}`, detail);
mirrorOverlayLog("beatleader", phase, detail);
}
function beatleaderUrl(path: string): string {
if (USE_RUNTIME_PROXY) {
@@ -38,79 +33,33 @@ interface BeatLeaderPlayerLookup {
export async function fetchBLLeaderboardsByHash(hash: string): Promise<BeatLeaderLeaderboard[]> {
const path = `/leaderboards/hash/${encodeURIComponent(hash)}`;
blDiag("leaderboardsByHash", { path, hash });
try {
const res = await fetch(beatleaderUrl(path));
if (!res.ok) {
blDiag("leaderboardsByHash", { path, hash, reason: "http-not-ok", status: res.status, statusText: res.statusText });
return [];
}
if (!res.ok) return [];
const data = await res.json() as BeatLeaderLeaderboardsByHashResponse | BeatLeaderLeaderboard[];
const leaderboards = Array.isArray(data)
return Array.isArray(data)
? data
: Array.isArray(data.leaderboards)
? data.leaderboards
: [];
blDiag("leaderboardsByHash", { path, hash, count: leaderboards.length });
return leaderboards;
} catch (err) {
blDiag("leaderboardsByHash", { path, hash, reason: "fetch-error", error: String(err) });
} catch {
return [];
}
}
async function resolveBeatLeaderPlayerId(playerId: string): Promise<string> {
const path = `/player/${encodeURIComponent(playerId)}`;
blDiag("resolvePlayer", { path, playerId });
try {
const res = await fetch(beatleaderUrl(path));
if (!res.ok) {
blDiag("resolvePlayer", { path, playerId, reason: "http-not-ok", status: res.status, usingId: playerId });
return playerId;
}
if (!res.ok) return playerId;
const data = await res.json() as BeatLeaderPlayerLookup;
const canonicalId = data.id;
const out = canonicalId == null ? playerId : String(canonicalId);
blDiag("resolvePlayer", { path, playerId, canonicalId: out, changed: out !== playerId });
return out;
} catch (err) {
blDiag("resolvePlayer", { path, playerId, reason: "fetch-error", error: String(err), usingId: playerId });
return canonicalId == null ? playerId : String(canonicalId);
} catch {
return playerId;
}
}
export async function fetchAllLeaderboardScoresByHash(
hash: string,
diff: string,
mode: string,
maxPages = 20,
): Promise<BeatLeaderScore[]> {
const scores: BeatLeaderScore[] = [];
for (let page = 1; page <= maxPages; page += 1) {
const qs = new URLSearchParams({ page: String(page), count: String(PAGE_SIZE) });
const url = beatleaderUrl(
`/v5/scores/${encodeURIComponent(hash)}/${encodeURIComponent(diff)}/${encodeURIComponent(mode)}?${qs}`,
);
let res: Response;
try {
res = await fetch(url);
} catch {
break;
}
if (!res.ok) break;
const payload = await res.json() as BeatLeaderScoresResponse | BeatLeaderScore[];
const batch = Array.isArray(payload) ? payload : payload.data ?? [];
if (batch.length === 0) break;
scores.push(...batch);
if (batch.length < PAGE_SIZE) break;
}
return scores;
}
interface BeatLeaderLeaderboardScoresResponse {
scores?: BeatLeaderScore[];
}
async function fetchLeaderboardScoresById(
leaderboardId: string,
maxPages = MAX_LEADERBOARD_SCORE_PAGES,
@@ -118,12 +67,8 @@ async function fetchLeaderboardScoresById(
const scores: BeatLeaderScore[] = [];
const pageSize = PAGE_SIZE;
let page = 1;
let hitPageCap = false;
for (;;) {
if (page > maxPages) {
hitPageCap = true;
break;
}
if (page > maxPages) break;
const qs = new URLSearchParams({
leaderboardContext: "general",
page: String(page),
@@ -136,36 +81,17 @@ async function fetchLeaderboardScoresById(
let res: Response;
try {
res = await fetch(url);
} catch (err) {
blDiag("leaderboardScores", { leaderboardId, page, path, reason: "fetch-error", error: String(err) });
break;
}
if (!res.ok) {
blDiag("leaderboardScores", {
leaderboardId,
page,
path,
reason: "http-not-ok",
status: res.status,
statusText: res.statusText,
});
} catch {
break;
}
if (!res.ok) break;
const payload = await res.json() as BeatLeaderLeaderboardScoresResponse;
const batch = Array.isArray(payload.scores) ? payload.scores : [];
if (page === 1) {
blDiag("leaderboardScores", { leaderboardId, page, path, firstPageBatchSize: batch.length, pageSize });
}
if (batch.length === 0) break;
scores.push(...batch);
if (batch.length < pageSize) break;
page += 1;
}
blDiag("leaderboardScoresTotal", {
leaderboardId,
totalScores: scores.length,
...(hitPageCap ? { warning: "hit-max-pages-cap", maxPages } : {}),
});
return scores;
}
@@ -174,17 +100,13 @@ export async function fetchAllMapScoresByHash(
leaderboards: BeatLeaderLeaderboard[],
maxPagesPerLeaderboard = MAX_LEADERBOARD_SCORE_PAGES,
): Promise<BeatLeaderScore[]> {
const ids = leaderboards.map((lb) => (lb.id == null ? "" : String(lb.id))).filter(Boolean);
blDiag("fetchAllMapScoresByHash", { hash, leaderboardCount: leaderboards.length, leaderboardIds: ids });
const requests = leaderboards.map((lb) => {
const leaderboardId = lb.id == null ? null : String(lb.id);
if (!leaderboardId) return Promise.resolve<BeatLeaderScore[]>([]);
return fetchLeaderboardScoresById(leaderboardId, maxPagesPerLeaderboard);
});
const batches = await Promise.all(requests);
const flat = batches.flat();
blDiag("fetchAllMapScoresByHash", { hash, totalScores: flat.length });
return flat;
return batches.flat();
}
async function fetchFollowersPage(
@@ -202,26 +124,10 @@ async function fetchFollowersPage(
const url = beatleaderUrl(path);
try {
const response = await fetch(url);
if (!response.ok) {
blDiag("followersPage", {
playerId,
type,
page,
path,
reason: "http-not-ok",
status: response.status,
statusText: response.statusText,
});
return [];
}
if (!response.ok) return [];
const data = await response.json() as BeatLeaderFollower[];
const rows = Array.isArray(data) ? data : [];
if (page === 1) {
blDiag("followersPage", { playerId, type, page, path, count: rows.length });
}
return rows;
} catch (err) {
blDiag("followersPage", { playerId, type, page, path, reason: "fetch-error", error: String(err) });
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
@@ -241,10 +147,6 @@ async function fetchAllFollowers(
return all;
}
export async function fetchMutualFriendIds(playerId: string, maxPages = 100): Promise<Set<string>> {
return fetchFriendIds(playerId, "mutual", maxPages);
}
function normalizeFollowerEntry(entry: BeatLeaderFollower): BeatLeaderFollower {
return {
...entry,
@@ -255,64 +157,20 @@ function normalizeFollowerEntry(entry: BeatLeaderFollower): BeatLeaderFollower {
/** Friend list for the given mode, with `avatar` / `name` from BeatLeader follower payloads. */
export async function fetchFriends(playerId: string, mode: FriendMode, maxPages = 100): Promise<BeatLeaderFollower[]> {
const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId);
blDiag("fetchFriendsStart", { playerId, canonicalPlayerId, mode, maxPages });
const [following, followers] = await Promise.all([
fetchAllFollowers(canonicalPlayerId, "Following", maxPages),
fetchAllFollowers(canonicalPlayerId, "Followers", maxPages),
]);
blDiag("fetchFriendsLists", {
canonicalPlayerId,
mode,
followingCount: following.length,
followersCount: followers.length,
});
const followingIds = new Set(following.map((entry) => String(entry.id)));
if (mode === "following") {
const out = following.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
blDiag("fetchFriendsResult", { mode, count: out.length });
return out;
return following.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
}
if (mode === "followers") {
const out = followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
blDiag("fetchFriendsResult", { mode, count: out.length });
return out;
return followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
}
const out = followers
return followers
.filter((entry) => followingIds.has(String(entry.id)))
.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
blDiag("fetchFriendsResult", {
mode: "mutual",
count: out.length,
reasonIfEmpty: out.length === 0
? (following.length === 0 || followers.length === 0
? "missing-following-or-followers-list"
: "no-intersection")
: undefined,
});
return out;
}
export async function fetchFriendIds(playerId: string, mode: FriendMode, maxPages = 100): Promise<Set<string>> {
const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId);
const [following, followers] = await Promise.all([
fetchAllFollowers(canonicalPlayerId, "Following", maxPages),
fetchAllFollowers(canonicalPlayerId, "Followers", maxPages),
]);
const followingIds = new Set(following.map((entry) => String(entry.id)));
const followerIds = new Set(followers.map((entry) => String(entry.id)));
if (mode === "following") return followingIds;
if (mode === "followers") return followerIds;
const mutuals = new Set<string>();
for (const id of followerIds) {
if (followingIds.has(id)) {
mutuals.add(id);
}
}
return mutuals;
}
export async function fetchMutualFriends(playerId: string, maxPages = 100): Promise<BeatLeaderFollower[]> {
return fetchFriends(playerId, "mutual", maxPages);
}
export function normalizeAccuracy(value: number | null | undefined): number | null {
+196 -358
View File
@@ -5,18 +5,21 @@ import type {
BeatSaberPlusEvent,
ChatRequestEntry,
ChatRequestPayload,
FriendMode,
MapInfo,
OverlaySettings,
Score,
} from "./types.ts";
import { OVERLAY_SETTINGS_INITIAL } from "./types.ts";
import type { BeatSaverMap } from "./beatsaver.ts";
import { fetchBeatSaverMapById, fetchBeatSaverMeta } from "./beatsaver.ts";
import {
fetchAllMapScoresByHash,
fetchBLLeaderboardsByHash,
fetchFriends,
type FriendMode,
normalizeAccuracy,
} from "./beatleader.ts";
import { mirrorOverlayLog } from "./overlay-server-log.ts";
import { mergeOverlayConfigResponse, type OverlayConfigApiBody } from "./overlay-config.ts";
function must<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
@@ -28,41 +31,11 @@ function parseJson<T>(raw: string): T {
return JSON.parse(raw) as T;
}
interface Settings {
cover: boolean;
mapInfo: boolean;
time: boolean;
score: boolean;
friends: boolean;
friendMode: FriendMode;
bsr: boolean;
debug: boolean;
mockBsr: string;
debugPlayerId: string;
right: boolean;
bottom: boolean;
scale: number;
fade: number;
}
type Settings = OverlaySettings;
const settings: Settings = {
cover: true,
mapInfo: true,
time: true,
score: true,
friends: true,
friendMode: "mutual",
bsr: false,
debug: false,
mockBsr: "4f4e4",
debugPlayerId: "76561199407393962",
right: false,
bottom: true,
scale: 1,
fade: 300,
};
const settings: Settings = structuredClone(OVERLAY_SETTINGS_INITIAL);
const defaults = structuredClone(settings);
const defaults = structuredClone(OVERLAY_SETTINGS_INITIAL);
const style = document.createElement("style");
function loadSettings() {
@@ -114,12 +87,9 @@ const beatSaberPlus = {
break;
case "handshake":
currentPlayerPlatformId = data.playerPlatformId || "";
console.log("[BS+ overlay] BS+ handshake", { playerPlatformId: currentPlayerPlatformId || "(empty)" });
updateDebugHud();
void refreshMapFriendScores();
break;
default:
console.log("message", e.data);
break;
}
},
@@ -127,11 +97,10 @@ const beatSaberPlus = {
const provider = beatSaberPlus;
const retryMs = 10000;
let retries = 0;
let currentPlayerPlatformId = "";
function getEffectivePlayerId() {
const configured = settings.debugPlayerId.trim();
const configured = settings.beatLeaderId.trim();
const raw = configured || currentPlayerPlatformId;
if (!raw) return "";
const steamIdCandidate = raw.match(/\d{17,20}/)?.[0];
@@ -147,75 +116,100 @@ let friendsRelationCacheKey = "";
let friendsRelationCache: BeatLeaderFollower[] | null = null;
let friendScoreRequestId = 0;
let mapInfoRequestId = 0;
let lastMapLevelId = "";
let lastBsPlusBsrKey = "";
let lastCharDiffStr = "";
let lastBeatSaverIdDisplay = "—";
let lastBeatSaverNote = "—";
let lastFriendScoresDebug = "—";
/** Hex hash from BS+ `level_id` (before BeatSaver version hash). */
let rawLevelHash = "";
let lastBeatLeaderLeaderboardIds = "—";
function formatErr(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
const debugHud = {
levelId: must<HTMLElement>("debugHudLevelId"),
rawHash: must<HTMLElement>("debugHudRawHash"),
hash: must<HTMLElement>("debugHudHash"),
bsPlusBsr: must<HTMLElement>("debugHudBsPlusBsr"),
beatSaverId: must<HTMLElement>("debugHudBeatSaverId"),
beatSaverNote: must<HTMLElement>("debugHudBeatSaverNote"),
charDiff: must<HTMLElement>("debugHudCharDiff"),
handshake: must<HTMLElement>("debugHudHandshake"),
blId: must<HTMLElement>("debugHudBlId"),
blLeaderboards: must<HTMLElement>("debugHudBlLeaderboards"),
friends: must<HTMLElement>("debugHudFriends"),
} as const;
function updateDebugHud() {
if (!settings.debug) return;
debugHud.levelId.textContent = lastMapLevelId || "—";
debugHud.rawHash.textContent = rawLevelHash || "—";
debugHud.hash.textContent = currentMapHash || "—";
debugHud.bsPlusBsr.textContent = lastBsPlusBsrKey || "—";
debugHud.beatSaverId.textContent = lastBeatSaverIdDisplay;
debugHud.beatSaverNote.textContent = lastBeatSaverNote;
debugHud.charDiff.textContent = lastCharDiffStr || "—";
debugHud.handshake.textContent = currentPlayerPlatformId || "—";
debugHud.blId.textContent = getEffectivePlayerId() || "—";
debugHud.blLeaderboards.textContent = lastBeatLeaderLeaderboardIds;
debugHud.friends.textContent = lastFriendScoresDebug;
}
function beatLeaderboardId(lb: BeatLeaderLeaderboard): string {
const id = lb.id ?? lb.leaderboardId;
return id == null ? "" : String(id);
}
function resolvedHashFromBeatSaverMap(map: NonNullable<Awaited<ReturnType<typeof fetchBeatSaverMeta>>>, fallback: string): string {
function resolvedHashFromBeatSaverMap(map: BeatSaverMap, fallback: string): string {
const v = map.versions?.[0]?.hash;
if (typeof v === "string" && v.length > 0) return v.toLowerCase().trim();
return fallback;
}
/** BeatLeader indexes maps by 40-char hex hash; BeatSaver accepts the same hash or a short map key (`/maps/id/…`). */
function looksLikeBeatSaverHash(s: string): boolean {
return /^[0-9a-f]{40}$/i.test(s.trim());
}
async function fetchBeatSaverMapForDebug(id: string): Promise<BeatSaverMap | null> {
const t = id.trim();
if (!t) return null;
if (looksLikeBeatSaverHash(t)) return fetchBeatSaverMeta(t.toLowerCase());
return fetchBeatSaverMapById(t);
}
async function applyDebugSong() {
const raw = settings.debugSongId.trim();
if (!raw) return;
const reqId = ++mapInfoRequestId;
document.body.classList.add("loading");
try {
const map = await fetchBeatSaverMapForDebug(raw);
if (reqId !== mapInfoRequestId) return;
if (!map?.id) {
rawLevelHash = "";
currentMapHash = "";
cover.src = "images/unknown.svg";
title.textContent = "BeatSaver not found";
subTitle.textContent = raw;
artist.textContent = "";
mapper.textContent = "";
difficulty.textContent = "";
characteristic.src = "images/characteristic/Standard.svg";
difficultyLabel.textContent = "";
type.textContent = "";
bsrKey.textContent = "";
return;
}
const fallbackHash = looksLikeBeatSaverHash(raw) ? raw.toLowerCase().trim() : "";
const resolved = resolvedHashFromBeatSaverMap(map, fallbackHash);
rawLevelHash = resolved || fallbackHash;
currentMapHash = resolved || fallbackHash;
const v0 = map.versions?.[0];
const coverUrl = v0?.coverURL?.trim();
cover.src = coverUrl || "images/unknown.svg";
title.textContent = map.metadata?.songName ?? map.name ?? "";
subTitle.textContent = map.metadata?.songSubName ?? "";
artist.textContent = map.metadata?.songAuthorName ?? "";
mapper.textContent = map.metadata?.levelAuthorName ?? "";
const firstDiff = v0?.diffs?.[0];
difficulty.textContent = firstDiff?.difficulty?.replace("Plus", " +") ?? "—";
characteristic.src = firstDiff
? `images/characteristic/${firstDiff.characteristic}.svg`
: "images/characteristic/Standard.svg";
difficultyLabel.textContent = firstDiff?.label ?? "";
type.textContent = "Custom";
bsrKey.textContent = map.id;
timeMultiplier = 1;
duration = 180;
if (reqId === mapInfoRequestId) {
setTime(0);
}
} catch {
if (reqId !== mapInfoRequestId) return;
rawLevelHash = "";
currentMapHash = "";
cover.src = "images/unknown.svg";
title.textContent = "BeatSaver request failed";
subTitle.textContent = raw;
} finally {
if (reqId === mapInfoRequestId) document.body.classList.remove("loading");
if (reqId === mapInfoRequestId) void refreshMapFriendScores();
}
}
function connect() {
console.log(`Connecting to ${provider.url} (attempt ${retries++})`);
const ws = new WebSocket(provider.url);
ws.onopen = onOpen;
ws.onmessage = provider.onMessage;
ws.onclose = onClose;
}
function onOpen() {
console.log("Connection open.");
retries = 0;
}
function onClose(e: CloseEvent) {
console.log(`Connection closed. code: ${e.code}, reason: ${e.reason}, clean: ${e.wasClean}`);
function onClose(_e: CloseEvent) {
setTimeout(connect, retryMs);
}
@@ -237,28 +231,15 @@ let timeMultiplier = 1;
let duration = 0;
async function updateMapInfo(data: MapInfo) {
if (settings.debugSongId.trim()) {
void applyDebugSong();
return;
}
const reqId = ++mapInfoRequestId;
const custom = data.level_id.startsWith("custom_level_");
const wip = custom && data.level_id.endsWith("WIP");
rawLevelHash = custom ? data.level_id.substring(13, 53).toLowerCase() : "";
currentMapHash = rawLevelHash;
lastMapLevelId = data.level_id;
lastBsPlusBsrKey = data.BSRKey || "";
lastCharDiffStr = `${data.characteristic} / ${data.difficulty}`;
lastBeatLeaderLeaderboardIds = "—";
console.log("[BS+ overlay] map: new song", {
level_id: data.level_id,
hashLevelId: rawLevelHash || "(none)",
custom,
wip,
bsPlusBsrKey: data.BSRKey || "(empty, not used for APIs)",
characteristic: data.characteristic,
difficulty: data.difficulty,
});
if (settings.debug) {
console.log("[BS+ overlay] map: detail", { requestId: reqId, name: data.name, artist: data.artist });
}
cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg";
title.textContent = data.name || "";
@@ -269,45 +250,20 @@ async function updateMapInfo(data: MapInfo) {
characteristic.src = `images/characteristic/${data.characteristic}.svg`;
difficultyLabel.textContent = ""; // BS+ does not provide label
type.textContent = !custom ? "OST" : wip ? "WIP" : "";
// Display: BeatSaver map id when resolved; never rely on BS+ BSRKey for lookups.
bsrKey.textContent = custom && !wip ? "…" : custom ? rawLevelHash || "???" : "???";
timeMultiplier = data.timeMultiplier || 1;
duration = data.duration / 1000;
lastBeatSaverIdDisplay = "—";
lastBeatSaverNote = custom && !wip ? "loading…" : wip ? "WIP (no BeatSaver)" : !custom ? "OST (no hash)" : "—";
updateDebugHud();
// Fetch BeatSaver first for custom maps; BeatLeader uses version hash (or level_id hash fallback), not BS+ BSRKey.
if (custom && !wip) {
document.body.classList.add("loading");
try {
console.log("[BS+ overlay] map: BeatSaver lookup by hash (from level_id)", rawLevelHash);
const map = await fetchBeatSaverMeta(rawLevelHash);
if (reqId !== mapInfoRequestId) return;
if (!map?.id) {
lastBeatSaverIdDisplay = "—";
lastBeatSaverNote = "BeatSaver: no map (check hash / proxy)";
currentMapHash = rawLevelHash;
console.warn("[BS+ overlay] map: BeatSaver miss — BeatLeader will use level_id hash", {
hashLevelId: rawLevelHash,
});
} else {
lastBeatSaverIdDisplay = map.id;
lastBeatSaverNote = "ok";
const resolved = resolvedHashFromBeatSaverMap(map, rawLevelHash);
if (resolved !== rawLevelHash) {
console.log("[BS+ overlay] map: using BeatSaver version hash for BeatLeader", {
hashLevelId: rawLevelHash,
hashBeatLeader: resolved,
beatSaverId: map.id,
});
}
currentMapHash = resolved;
console.log("[BS+ overlay] map: BeatSaver ok", {
beatSaverId: map.id,
hashBeatLeader: currentMapHash,
});
bsrKey.textContent = map.id;
mapper.textContent = map.metadata?.levelAuthorName || "";
const diff = map.versions?.[0]?.diffs?.find(
@@ -315,18 +271,12 @@ async function updateMapInfo(data: MapInfo) {
);
if (diff?.label) difficultyLabel.textContent = diff.label;
}
} catch (err) {
} catch {
if (reqId !== mapInfoRequestId) return;
lastBeatSaverIdDisplay = "—";
lastBeatSaverNote = `error: ${formatErr(err)}`;
currentMapHash = rawLevelHash;
console.error("[BS+ overlay] map: BeatSaver fetch failed — BeatLeader will use level_id hash", err);
} finally {
document.body.classList.remove("loading");
if (reqId === mapInfoRequestId) {
updateDebugHud();
void refreshMapFriendScores();
}
if (reqId === mapInfoRequestId) void refreshMapFriendScores();
}
} else {
if (custom && wip) {
@@ -335,7 +285,6 @@ async function updateMapInfo(data: MapInfo) {
bsrKey.textContent = "???";
}
difficultyLabel.textContent = "";
updateDebugHud();
void refreshMapFriendScores();
}
}
@@ -429,13 +378,6 @@ function renderFriendScores(items: Array<{ name: string; acc: number; avatar: st
}
}
/** Friend-score flow: always log to the browser/OBS console when the friends panel is enabled (not gated on debug). */
function friendsDiag(message: string, detail: Record<string, unknown> = {}) {
if (!settings.friends) return;
console.log(`[BS+ overlay] friends:${message}`, detail);
mirrorOverlayLog("friends", message, detail);
}
function friendsRelationListKey(playerId: string): string {
return `${playerId}\0${settings.friendMode}`;
}
@@ -443,55 +385,30 @@ function friendsRelationListKey(playerId: string): string {
async function refreshMapFriendScores() {
const hash = currentMapHash;
if (!settings.friends) {
lastFriendScoresDebug = "off";
lastBeatLeaderLeaderboardIds = "—";
updateDebugHud();
clearFriendScores("Disabled in settings");
return;
}
if (!hash) {
friendsDiag("skip", { reason: "no-map-hash", hint: "Need custom map level_id hash or resolved BeatSaver hash" });
lastFriendScoresDebug = "no hash";
lastBeatLeaderLeaderboardIds = "—";
updateDebugHud();
clearFriendScores("No map loaded");
return;
}
const playerId = getEffectivePlayerId();
if (!playerId) {
friendsDiag("skip", {
reason: "no-player-id",
hint: "Wait for BS+ handshake (playerPlatformId) or set debug BeatLeader id in settings",
});
lastFriendScoresDebug = "no BeatLeader player id";
lastBeatLeaderLeaderboardIds = "—";
updateDebugHud();
clearFriendScores("Waiting for BeatLeader player id");
return;
}
friendScoresPanel.classList.add("is-loading");
friendScoresEmpty.textContent = "Loading mutual friend scores...";
lastFriendScoresDebug = "loading…";
updateDebugHud();
const requestId = ++friendScoreRequestId;
friendsDiag("start", {
requestId,
hash,
playerId,
friendMode: settings.friendMode,
debugPlayerOverride: Boolean(settings.debug && settings.debugPlayerId.trim()),
});
try {
const relKey = friendsRelationListKey(playerId);
const friendsPromise: Promise<BeatLeaderFollower[]> = (async () => {
if (friendsRelationCache !== null && relKey === friendsRelationCacheKey) {
friendsDiag("friends-list-cache", { hit: true, friendCount: friendsRelationCache.length });
return friendsRelationCache;
}
const fetched = await fetchFriends(playerId, settings.friendMode);
friendsRelationCacheKey = relKey;
friendsRelationCache = fetched;
friendsDiag("friends-list-cache", { hit: false, friendCount: fetched.length });
return fetched;
})();
const [leaderboards, friends] = await Promise.all([
@@ -499,37 +416,13 @@ async function refreshMapFriendScores() {
friendsPromise,
]);
if (requestId !== friendScoreRequestId) return;
const leaderboardIds = leaderboards.map(beatLeaderboardId).filter(Boolean);
lastBeatLeaderLeaderboardIds = leaderboardIds.length ? leaderboardIds.join(", ") : "none";
updateDebugHud();
friendsDiag("parallel-fetch-done", {
hash,
leaderboardCount: leaderboards.length,
leaderboardIds,
friendCount: friends.length,
});
if (leaderboards.length === 0) {
friendsDiag("empty-ui", {
reason: "no-beatleader-leaderboards-for-hash",
hash,
hint: "BeatLeader has no leaderboards for this map hash (wrong hash, unranked, or API/proxy error — see beatleader:leaderboardsByHash logs)",
});
lastFriendScoresDebug = "0 leaderboards for hash";
updateDebugHud();
clearFriendScores("No BeatLeader leaderboards found");
return;
}
const friendById = new Map(friends.map((f) => [f.id, f]));
const mutualFriendIds = new Set(friends.map((f) => f.id));
if (mutualFriendIds.size === 0) {
friendsDiag("empty-ui", {
reason: "no-friends-for-mode",
friendMode: settings.friendMode,
hash,
hint: "Following/followers lists empty or mutual mode has no intersection — see beatleader:fetchFriendsResult",
});
lastFriendScoresDebug = `0 friends (${settings.friendMode})`;
updateDebugHud();
const relationLabel = settings.friendMode === "following"
? "No followed BeatLeader players"
: settings.friendMode === "followers"
@@ -540,7 +433,6 @@ async function refreshMapFriendScores() {
}
const scores = await fetchAllMapScoresByHash(hash, leaderboards);
if (requestId !== friendScoreRequestId) return;
friendsDiag("scores-aggregated", { hash, rawScoreRows: scores.length, friendIdsInRelation: mutualFriendIds.size });
const bestByPlayer = new Map<string, { name: string; acc: number; avatar: string | null }>();
for (const score of scores) {
const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
@@ -564,141 +456,27 @@ async function refreshMapFriendScores() {
}
}
const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc);
if (sorted.length === 0 && scores.length > 0) {
friendsDiag("empty-ui", {
reason: "no-friend-scores-on-leaderboards",
hash,
rawScoreRows: scores.length,
friendIdsInRelation: mutualFriendIds.size,
hint: "Leaderboard scores exist but none match friend ids (playerId on scores vs BeatLeader friend ids)",
});
} else if (sorted.length === 0 && scores.length === 0) {
friendsDiag("empty-ui", {
reason: "no-scores-on-map-leaderboards",
hash,
leaderboardIds,
hint: "No ranked rows returned for these leaderboards — see beatleader:leaderboardScores logs",
});
} else {
friendsDiag("done", { rows: sorted.length, hash });
}
lastFriendScoresDebug = `${leaderboards.length} LB, ${friends.length} friends, ${scores.length} scores → ${sorted.length} rows`;
updateDebugHud();
renderFriendScores(sorted);
} catch (err) {
} catch {
if (requestId !== friendScoreRequestId) return;
friendsDiag("error", { message: formatErr(err), hash, playerId });
lastFriendScoresDebug = `error: ${formatErr(err)}`;
lastBeatLeaderLeaderboardIds = "—";
updateDebugHud();
clearFriendScores("Failed loading BeatLeader scores");
}
}
async function applyMockMapFromBsr() {
const key = settings.mockBsr.trim();
if (!settings.debug || !key) return;
const map = await fetchBeatSaverMapById(key);
if (!map) {
console.warn("[BS+ overlay] map: mock BSR lookup returned nothing", { key });
return;
window.onhashchange = () => {
loadSettings();
const debugEl = document.getElementById("debugSongIdInput") as HTMLInputElement | null;
if (debugEl) debugEl.value = settings.debugSongId;
if (settings.debugSongId.trim()) void applyDebugSong();
else {
mapInfoRequestId += 1;
currentMapHash = "";
rawLevelHash = "";
void refreshMapFriendScores();
}
const hash = map.versions?.[0]?.hash?.toLowerCase?.() || "";
rawLevelHash = hash;
currentMapHash = hash;
lastBeatLeaderLeaderboardIds = "—";
title.textContent = map.metadata?.songName || map.name || title.textContent || "";
subTitle.textContent = map.metadata?.songSubName || "";
artist.textContent = map.metadata?.songAuthorName || "";
mapper.textContent = map.metadata?.levelAuthorName || "";
bsrKey.textContent = map.id || key;
type.textContent = "MOCK";
const coverUrl = map.versions?.[0]?.coverURL;
if (coverUrl) cover.src = coverUrl;
lastMapLevelId = `mock:${key}`;
lastBsPlusBsrKey = "";
lastCharDiffStr = "";
lastBeatSaverIdDisplay = map.id || "—";
lastBeatSaverNote = "mock BSR";
updateDebugHud();
console.log("[BS+ overlay] map: mock from BSR key", { key, hash: currentMapHash, mapId: map.id });
void refreshMapFriendScores();
}
window.onhashchange = loadSettings;
loadSettings();
document.head.appendChild(style);
updateDebugHud();
// Settings UI
for (const key of ["cover", "mapInfo", "time", "score", "friends", "bsr", "debug"] as const) {
const input = must<HTMLInputElement>(`${key}Input`);
input.checked = settings[key];
input.oninput = () => {
settings[key] = input.checked;
saveSettings();
if (key === "friends") void refreshMapFriendScores();
if (key === "debug") {
updateDebugHud();
void loadRequestQueue();
void applyMockMapFromBsr();
void refreshMapFriendScores();
}
};
}
const friendModeInput = must<HTMLSelectElement>("friendModeInput");
friendModeInput.value = settings.friendMode;
friendModeInput.onchange = () => {
settings.friendMode = friendModeInput.value as FriendMode;
saveSettings();
void refreshMapFriendScores();
};
const mockBsrInput = must<HTMLInputElement>("mockBsrInput");
mockBsrInput.value = settings.mockBsr;
mockBsrInput.oninput = () => {
settings.mockBsr = mockBsrInput.value.trim();
saveSettings();
void applyMockMapFromBsr();
};
const beatLeaderPlayerInput = must<HTMLInputElement>("beatLeaderPlayerInput");
beatLeaderPlayerInput.value = settings.debugPlayerId;
beatLeaderPlayerInput.oninput = () => {
settings.debugPlayerId = beatLeaderPlayerInput.value.trim();
saveSettings();
void refreshMapFriendScores();
};
void applyMockMapFromBsr();
const scale = must<HTMLInputElement>("scaleInput");
scale.valueAsNumber = settings.scale * 100;
scale.oninput = () => {
settings.scale = scale.valueAsNumber / 100;
saveSettings();
};
const position = must<HTMLSelectElement>("positionInput");
position.value = JSON.stringify([settings.right, settings.bottom]);
position.onchange = () => {
[settings.right, settings.bottom] = parseJson<[boolean, boolean]>(position.value);
saveSettings();
};
const fade = must<HTMLInputElement>("fadeInput");
fade.valueAsNumber = settings.fade;
fade.oninput = () => {
settings.fade = fade.valueAsNumber;
saveSettings();
};
document.documentElement.onclick = () => document.body.classList.toggle("preview");
must<HTMLElement>("settings").onclick = (e: MouseEvent) => e.stopPropagation();
// Song request queue (JSON from same origin as page; poll). See docs/testing.md for ?requests=
// Song request queue (JSON from same origin as page; poll)
const MAX_REQUESTS = 10;
const REQUEST_POLL_MS = 5000;
@@ -708,22 +486,8 @@ const requestEmptyEl = must<HTMLElement>("requestEmpty");
const requestTitleCache = new Map<string, string>();
const requestTitleMisses = new Set<string>();
function useRequestHistorySim() {
return settings.debug || new URLSearchParams(location.search).get("debug") === "1";
}
function requestJsonFilenames() {
const explicit = new URLSearchParams(location.search).get("requests");
if (explicit) return [explicit];
if (useRequestHistorySim()) return ["ChatRequest.json", "database.json"];
return ["ChatRequest.json"];
}
function loadJsonNextToPage(fileName: string) {
const base = new URL(fileName, location.href);
if (base.protocol !== "http:" && base.protocol !== "https:") {
throw new Error("not-http");
}
function loadChatRequestJson() {
const base = new URL("ChatRequest.json", location.href);
const busted = new URL(base.href);
busted.searchParams.set("t", String(Date.now()));
return fetch(busted.href, { cache: "no-store" }).then((res) => {
@@ -732,18 +496,6 @@ function loadJsonNextToPage(fileName: string) {
});
}
async function loadRequestPayload() {
let lastErr: unknown;
for (const name of requestJsonFilenames()) {
try {
return await loadJsonNextToPage(name);
} catch (e) {
lastErr = e;
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
}
function requesterLine(item: ChatRequestEntry) {
const parts = [item.npr, item.rqn].filter(Boolean);
return parts.length ? parts.join(" ") : item.rqn || "";
@@ -797,18 +549,104 @@ function renderRequestList(items: ChatRequestEntry[]) {
async function loadRequestQueue() {
try {
const data = await loadRequestPayload();
const data = await loadChatRequestJson();
requestEmptyEl.textContent = "No pending requests";
requestOverlayEl.classList.remove("request-load-failed");
const raw = useRequestHistorySim() ? data.history ?? [] : data.queue ?? [];
const items = raw.slice(0, MAX_REQUESTS);
const items = (data.queue ?? []).slice(0, MAX_REQUESTS);
renderRequestList(items);
} catch {
requestEmptyEl.textContent = "whupsy, database file missing";
requestEmptyEl.textContent = "Request queue unavailable";
requestOverlayEl.classList.add("request-load-failed");
renderRequestList([]);
}
}
void loadRequestQueue();
window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS);
async function bootstrap() {
Object.assign(defaults, OVERLAY_SETTINGS_INITIAL);
try {
const url = new URL("/api/overlay-config", location.href);
const res = await fetch(url, { cache: "no-store" });
if (res.ok) {
const data = await res.json() as OverlayConfigApiBody;
mergeOverlayConfigResponse(defaults, data);
}
} catch {
// keep OVERLAY_SETTINGS_INITIAL (e.g. file:// or static hosting)
}
loadSettings();
document.head.appendChild(style);
if (settings.debugSongId.trim()) void applyDebugSong();
else void refreshMapFriendScores();
// Settings UI
for (const key of ["cover", "mapInfo", "time", "score", "friends", "bsr"] as const) {
const input = must<HTMLInputElement>(`${key}Input`);
input.checked = settings[key];
input.oninput = () => {
settings[key] = input.checked;
saveSettings();
if (key === "friends") void refreshMapFriendScores();
};
}
const friendModeInput = must<HTMLSelectElement>("friendModeInput");
friendModeInput.value = settings.friendMode;
friendModeInput.onchange = () => {
settings.friendMode = friendModeInput.value as FriendMode;
saveSettings();
void refreshMapFriendScores();
};
const beatLeaderPlayerInput = must<HTMLInputElement>("beatLeaderPlayerInput");
beatLeaderPlayerInput.value = settings.beatLeaderId;
beatLeaderPlayerInput.oninput = () => {
settings.beatLeaderId = beatLeaderPlayerInput.value.trim();
saveSettings();
void refreshMapFriendScores();
};
const scale = must<HTMLInputElement>("scaleInput");
scale.valueAsNumber = settings.scale * 100;
scale.oninput = () => {
settings.scale = scale.valueAsNumber / 100;
saveSettings();
};
const position = must<HTMLSelectElement>("positionInput");
position.value = JSON.stringify([settings.right, settings.bottom]);
position.onchange = () => {
[settings.right, settings.bottom] = parseJson<[boolean, boolean]>(position.value);
saveSettings();
};
const fade = must<HTMLInputElement>("fadeInput");
fade.valueAsNumber = settings.fade;
fade.oninput = () => {
settings.fade = fade.valueAsNumber;
saveSettings();
};
const debugSongIdInput = must<HTMLInputElement>("debugSongIdInput");
debugSongIdInput.value = settings.debugSongId;
debugSongIdInput.oninput = () => {
settings.debugSongId = debugSongIdInput.value;
saveSettings();
if (!settings.debugSongId.trim()) {
mapInfoRequestId += 1;
currentMapHash = "";
rawLevelHash = "";
void refreshMapFriendScores();
} else {
void applyDebugSong();
}
};
document.documentElement.onclick = () => document.body.classList.toggle("preview");
must<HTMLElement>("settings").onclick = (e: MouseEvent) => e.stopPropagation();
void loadRequestQueue();
window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS);
}
void bootstrap();
+7 -12
View File
@@ -1,10 +1,8 @@
import { assert, assertEquals } from "jsr:@std/assert";
import {
fetchAllMapScoresByHash,
fetchAllLeaderboardScoresByHash,
fetchBLLeaderboardsByHash,
fetchMutualFriends,
fetchMutualFriendIds,
fetchFriends,
normalizeAccuracy,
} from "./beatleader.ts";
import { fetchBeatSaverMapById } from "./beatsaver.ts";
@@ -52,19 +50,16 @@ Deno.test({
"ExpertPlus should map to a different leaderboard id than another difficulty",
);
const [expertPlusScores, allMapScores, mutualIds, mutualFriends] = await Promise.all([
fetchAllLeaderboardScoresByHash(hash, "ExpertPlus", MODE, 8),
const mutualFriends = await fetchFriends(PLAYER_ID, "mutual", 100);
const mutualIds = new Set(mutualFriends.map((f) => f.id));
assert(mutualIds.size > 0, "Expected at least one mutual friend");
const [expertPlusScores, allMapScores] = await Promise.all([
fetchAllMapScoresByHash(hash, [expertPlus], 120),
fetchAllMapScoresByHash(hash, leaderboards, 120),
fetchMutualFriendIds(PLAYER_ID, 100),
fetchMutualFriends(PLAYER_ID, 100),
]);
assert(expertPlusScores.length > 0, "Expected some ExpertPlus scores");
assert(allMapScores.length > 0, "Expected map to have scores across leaderboards");
assert(mutualIds.size > 0, "Expected at least one mutual friend");
console.log(
"Mutual friends:",
mutualFriends.map((friend) => `${friend.name || friend.alias || "unknown"} (${friend.id})`).join(", "),
);
const mutualScores = allMapScores.filter((score) => {
const playerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
+2 -7
View File
@@ -1,5 +1,5 @@
import { assert } from "jsr:@std/assert";
import { fetchMutualFriends } from "./beatleader.ts";
import { fetchFriends } from "./beatleader.ts";
const PLAYER_ID = "76561199407393962";
@@ -8,12 +8,7 @@ Deno.test({
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const mutuals = await fetchMutualFriends(PLAYER_ID, 100);
const mutuals = await fetchFriends(PLAYER_ID, "mutual", 100);
assert(mutuals.length > 0, `Expected mutual friends for player ${PLAYER_ID}`);
console.log("Mutual friends:");
for (const friend of mutuals) {
const label = friend.name || friend.alias || "(no name)";
console.log(`- ${label} (${friend.id})`);
}
},
});
+108
View File
@@ -0,0 +1,108 @@
import type { FriendMode, OverlaySettings } from "./types.ts";
/** Keys accepted in overlay.toml (snake_case). */
export interface OverlayToml {
chat_request_database?: string;
cover?: boolean;
map_info?: boolean;
time?: boolean;
score?: boolean;
friends?: boolean;
friend_mode?: string;
bsr?: boolean;
beatleader_player_id?: string;
right?: boolean;
bottom?: boolean;
scale?: number;
fade?: number;
}
export interface OverlayConfigApiBody {
defaults: Partial<OverlaySettings>;
}
const FRIEND_MODES = new Set<FriendMode>(["mutual", "following", "followers"]);
/** Merge `/api/overlay-config` into the object used as `defaults` before applying the URL hash. */
export function mergeOverlayConfigResponse(
target: OverlaySettings,
body: { defaults?: Partial<OverlaySettings> },
): void {
const d = body.defaults;
if (!d) return;
const out = target as unknown as Record<string, unknown>;
for (const key of Object.keys(d) as (keyof OverlaySettings)[]) {
const v = d[key];
if (v !== undefined) out[key as string] = v;
}
}
function asBool(v: unknown): boolean | undefined {
if (typeof v === "boolean") return v;
return undefined;
}
function asNum(v: unknown): number | undefined {
if (typeof v === "number" && Number.isFinite(v)) return v;
return undefined;
}
/**
* Maps parsed TOML (snake_case) to overlay defaults. Collects warnings for invalid values.
*/
export function overlayTomlToDefaults(toml: OverlayToml): {
chatRequestDatabase: string | undefined;
defaults: Partial<OverlaySettings>;
beatleaderPlayerIdConfigured: boolean;
warnings: string[];
} {
const warnings: string[] = [];
const defaults: Partial<OverlaySettings> = {};
const db = typeof toml.chat_request_database === "string" ? toml.chat_request_database.trim() : "";
const chatRequestDatabase = db || undefined;
const bCover = asBool(toml.cover);
if (bCover !== undefined) defaults.cover = bCover;
const bMap = asBool(toml.map_info);
if (bMap !== undefined) defaults.mapInfo = bMap;
const bTime = asBool(toml.time);
if (bTime !== undefined) defaults.time = bTime;
const bScore = asBool(toml.score);
if (bScore !== undefined) defaults.score = bScore;
const bFriends = asBool(toml.friends);
if (bFriends !== undefined) defaults.friends = bFriends;
const bBsr = asBool(toml.bsr);
if (bBsr !== undefined) defaults.bsr = bBsr;
const bRight = asBool(toml.right);
if (bRight !== undefined) defaults.right = bRight;
const bBottom = asBool(toml.bottom);
if (bBottom !== undefined) defaults.bottom = bBottom;
if (toml.friend_mode !== undefined) {
const raw = String(toml.friend_mode).trim().toLowerCase();
if (FRIEND_MODES.has(raw as FriendMode)) defaults.friendMode = raw as FriendMode;
else {
warnings.push(
`overlay.toml: invalid friend_mode "${toml.friend_mode}" (expected mutual, following, or followers)`,
);
}
}
const scale = asNum(toml.scale);
if (scale !== undefined) defaults.scale = scale;
const fade = asNum(toml.fade);
if (fade !== undefined) defaults.fade = fade;
let beatleaderPlayerIdConfigured = false;
if (typeof toml.beatleader_player_id === "string") {
const id = toml.beatleader_player_id.trim();
if (id) {
defaults.beatLeaderId = id;
beatleaderPlayerIdConfigured = true;
}
}
return { chatRequestDatabase, defaults, beatleaderPlayerIdConfigured, warnings };
}
-15
View File
@@ -1,15 +0,0 @@
/**
* Mirrors overlay diagnostics to the Deno static server terminal (`POST /api/overlay-log`).
* Browser console still receives the same messages via `console.log`.
*/
export function mirrorOverlayLog(scope: string, phase: string, detail: Record<string, unknown>): void {
if (typeof location === "undefined") return;
if (location.protocol !== "http:" && location.protocol !== "https:") return;
const body = JSON.stringify({ scope, phase, detail });
void fetch("/api/overlay-log", {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body,
keepalive: true,
}).catch(() => {});
}
+36 -5
View File
@@ -1,5 +1,41 @@
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
export type FriendMode = "mutual" | "following" | "followers";
/** Overlay UI + BeatLeader id (URL hash overrides defaults from overlay.toml / `/api/overlay-config`). */
export interface OverlaySettings {
cover: boolean;
mapInfo: boolean;
time: boolean;
score: boolean;
friends: boolean;
friendMode: FriendMode;
bsr: boolean;
beatLeaderId: string;
right: boolean;
bottom: boolean;
scale: number;
fade: number;
/** Frontend-only: BeatSaver map key or 40-char hash; when set, map UI + BeatLeader use this instead of BS+ WebSocket map info. */
debugSongId: string;
}
export const OVERLAY_SETTINGS_INITIAL: Readonly<OverlaySettings> = {
cover: true,
mapInfo: true,
time: true,
score: true,
friends: true,
friendMode: "mutual",
bsr: false,
beatLeaderId: "",
right: false,
bottom: true,
scale: 1,
fade: 300,
debugSongId: "",
};
export interface HandshakeEvent {
_type: "handshake";
protocolVersion: number;
@@ -81,7 +117,6 @@ export interface ChatRequestEntry {
export interface ChatRequestPayload {
queue: ChatRequestEntry[];
history: ChatRequestEntry[];
}
export interface BeatLeaderDifficulty {
@@ -118,10 +153,6 @@ export interface BeatLeaderScore {
player?: BeatLeaderPlayer | string | null;
}
export interface BeatLeaderScoresResponse {
data?: BeatLeaderScore[];
}
export interface BeatLeaderFollower {
id: string;
alias?: string | null;
+50 -44
View File
@@ -1,36 +1,54 @@
import { join } from "jsr:@std/path";
import { serveDir } from "jsr:@std/http/file-server";
import { parse } from "jsr:@std/toml";
import type { OverlaySettings } from "../client/types.ts";
import type { OverlayConfigApiBody } from "../client/overlay-config.ts";
import { overlayTomlToDefaults, type OverlayToml } from "../client/overlay-config.ts";
const scriptDir = import.meta.dirname ?? ".";
const root = join(scriptDir, "..", "..");
const port = Number(Deno.env.get("PORT") ?? "8080");
function trimPathLine(line: string): string {
let s = line.trim();
if (!s || s.startsWith("#")) return "";
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
s = s.slice(1, -1);
}
return s.trim();
}
/** Optional one-line file in repo root: absolute path to `ChatRequest/Database.json`. */
function readOptionalPathFile(): string | undefined {
function readOverlayConfig(): {
chatRequestDatabase: string | undefined;
apiDefaults: Partial<OverlaySettings>;
} {
const path = join(root, "overlay.toml");
let raw: string;
try {
const pathFile = join(root, "chat-request-database.path");
const raw = Deno.readTextFileSync(pathFile);
for (const line of raw.split(/\r?\n/)) {
const p = trimPathLine(line);
if (p) return p;
}
raw = Deno.readTextFileSync(path);
} catch {
// missing or unreadable
console.warn(
"overlay.toml not found; using built-in defaults. Copy overlay.toml.example and set at least chat_request_database and beatleader_player_id.",
);
return { chatRequestDatabase: undefined, apiDefaults: {} };
}
return undefined;
let parsed: OverlayToml;
try {
parsed = parse(raw) as OverlayToml;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`overlay.toml: parse error: ${msg}`);
console.warn(
"beatleader_player_id is not set; fix overlay.toml or set the BeatLeader id in the URL hash for friend scores.",
);
return { chatRequestDatabase: undefined, apiDefaults: {} };
}
const { chatRequestDatabase, defaults, beatleaderPlayerIdConfigured, warnings } = overlayTomlToDefaults(
parsed,
);
for (const w of warnings) console.warn(w);
if (!beatleaderPlayerIdConfigured) {
console.warn(
"beatleader_player_id is not set; friend scores need a BeatLeader id in overlay.toml or in the URL hash.",
);
}
return { chatRequestDatabase, apiDefaults: defaults };
}
const chatRequestDatabase =
Deno.env.get("CHAT_REQUEST_DATABASE")?.trim() || readOptionalPathFile();
const { chatRequestDatabase, apiDefaults } = readOverlayConfig();
function isSafeProxyPath(path: string): boolean {
if (!path) return false;
@@ -68,24 +86,16 @@ async function proxyApiRequest(req: Request, upstreamBase: string): Promise<Resp
}
}
function isChatRequestFilename(pathname: string): boolean {
function isChatRequestPath(pathname: string): boolean {
const base = pathname.split("/").pop() ?? "";
return base === "ChatRequest.json" || base === "database.json";
return base === "ChatRequest.json";
}
Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/api/overlay-log") {
try {
const raw = await req.text();
const parsed = JSON.parse(raw) as { scope?: string; phase?: string; detail?: unknown };
const scope = typeof parsed.scope === "string" ? parsed.scope : "?";
const phase = typeof parsed.phase === "string" ? parsed.phase : "?";
console.log(`[overlay] ${scope}:${phase}`, parsed.detail ?? "");
} catch {
console.log("[overlay] overlay-log: invalid JSON body");
}
return new Response(null, { status: 204 });
if (req.method === "GET" && url.pathname === "/api/overlay-config") {
const body: OverlayConfigApiBody = { defaults: apiDefaults };
return Response.json(body, { headers: { "cache-control": "no-store" } });
}
if (req.method === "GET" && url.pathname === "/api/beatleader") {
return proxyApiRequest(req, "https://api.beatleader.com");
@@ -93,7 +103,7 @@ Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => {
if (req.method === "GET" && url.pathname === "/api/beatsaver") {
return proxyApiRequest(req, "https://api.beatsaver.com");
}
if (req.method === "GET" && chatRequestDatabase && isChatRequestFilename(url.pathname)) {
if (req.method === "GET" && chatRequestDatabase && isChatRequestPath(url.pathname)) {
try {
let text = await Deno.readTextFile(chatRequestDatabase);
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
@@ -105,24 +115,20 @@ Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => {
});
} catch (e) {
if (e instanceof Deno.errors.NotFound) {
console.error(`CHAT_REQUEST_DATABASE not found: ${chatRequestDatabase}`);
console.error(`chat_request_database not found: ${chatRequestDatabase}`);
return new Response(null, { status: 404 });
}
const msg = e instanceof Error ? e.message : String(e);
console.error(`CHAT_REQUEST_DATABASE read error (${chatRequestDatabase}): ${msg}`);
return new Response(`Failed to read CHAT_REQUEST_DATABASE: ${msg}\n`, { status: 500 });
console.error(`chat_request_database read error (${chatRequestDatabase}): ${msg}`);
return new Response(`Failed to read chat_request_database: ${msg}\n`, { status: 500 });
}
}
return serveDir(req, { fsRoot: root, showDirListing: false });
});
console.log(`Overlay: http://127.0.0.1:${port}/index.html`);
console.log("Friend/BeatLeader diagnostics from the page also print here (browser console has the same lines).");
if (chatRequestDatabase) {
console.log(`Chat request database file: ${chatRequestDatabase}`);
} else {
if (!chatRequestDatabase) {
console.warn(
"No database path: set CHAT_REQUEST_DATABASE or create chat-request-database.path (see README). " +
"Otherwise /ChatRequest.json is only served from this folder if the file exists.",
"No chat_request_database in overlay.toml — place ChatRequest.json in the repo folder or set chat_request_database (see overlay.toml.example).",
);
}