Major cleanup and add toml configuration
This commit is contained in:
+21
-163
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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).",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user