Show mutual scores from friends
This commit is contained in:
parent
f10ced9384
commit
d73c1ac495
14
AGENTS.md
14
AGENTS.md
@ -23,3 +23,17 @@ Do **not** add or maintain code paths for opening the overlay as **`file://`**.
|
|||||||
## Out of scope here
|
## Out of scope here
|
||||||
|
|
||||||
Beat Saber Plus itself (game mod) exposes the socket; this repo is only the HTML/CSS/TS client.
|
Beat Saber Plus itself (game mod) exposes the socket; this repo is only the HTML/CSS/TS client.
|
||||||
|
|
||||||
|
## Recent implementation notes (2026-04)
|
||||||
|
|
||||||
|
- **Live API calls must be runtime-proxied**: browser/OBS hits CORS on BeatLeader/BeatSaver. Keep live requests same-origin via `src/server/serve.ts`:
|
||||||
|
- `/api/beatleader?path=/...`
|
||||||
|
- `/api/beatsaver?path=/...`
|
||||||
|
- **Map correlation is hash-based**: resolve BeatSaver map/hash first, then BeatLeader leaderboards via `/leaderboards/hash/{hash}`.
|
||||||
|
- **Score source for friend matching**: use `/leaderboard/{leaderboardId}` scores for stable `playerId` fields; do not rely on v5 score payload shape for player IDs.
|
||||||
|
- **Mutual friends definition**: intersection of `/player/{id}/followers?type=Following` and `type=Followers` (paged by `page` + `count` only).
|
||||||
|
- **Overlay feature added**: `#friendScores` panel shows best accuracy per mutual friend for current map, sorted DESC.
|
||||||
|
- **Debug defaults**:
|
||||||
|
- mock map key: `4f4e4`
|
||||||
|
- debug BeatLeader player id: `76561199407393962`
|
||||||
|
- both debug inputs must have no effect when debug is disabled.
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"build": "deno bundle --platform=browser --check src/client/index.ts -o index.js",
|
"build": "deno bundle --platform=browser --check src/client/index.ts -o index.js",
|
||||||
"serve": "deno task build && deno run --allow-net --allow-read --allow-env src/server/serve.ts",
|
"serve": "deno task build && deno run --allow-net --allow-read --allow-env src/server/serve.ts",
|
||||||
"dev": "deno bundle --platform=browser --watch --check src/client/index.ts -o index.js"
|
"dev": "deno bundle --platform=browser --watch --check src/client/index.ts -o index.js",
|
||||||
|
"test:live": "deno test --allow-net src/client/live-api.test.ts",
|
||||||
|
"test:friends": "deno test --allow-net src/client/live-friends.test.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
deno.lock
generated
7
deno.lock
generated
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"specifiers": {
|
"specifiers": {
|
||||||
|
"jsr:@std/assert@*": "1.0.19",
|
||||||
"jsr:@std/cli@^1.0.28": "1.0.28",
|
"jsr:@std/cli@^1.0.28": "1.0.28",
|
||||||
"jsr:@std/encoding@^1.0.10": "1.0.10",
|
"jsr:@std/encoding@^1.0.10": "1.0.10",
|
||||||
"jsr:@std/fmt@^1.0.9": "1.0.9",
|
"jsr:@std/fmt@^1.0.9": "1.0.9",
|
||||||
@ -15,6 +16,12 @@
|
|||||||
"jsr:@std/streams@^1.0.17": "1.0.17"
|
"jsr:@std/streams@^1.0.17": "1.0.17"
|
||||||
},
|
},
|
||||||
"jsr": {
|
"jsr": {
|
||||||
|
"@std/assert@1.0.19": {
|
||||||
|
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/internal"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@std/cli@1.0.28": {
|
"@std/cli@1.0.28": {
|
||||||
"integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a"
|
"integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a"
|
||||||
},
|
},
|
||||||
|
|||||||
59
index.css
59
index.css
@ -293,12 +293,63 @@ span:empty {
|
|||||||
content: "FC";
|
content: "FC";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mutual friend scores */
|
||||||
|
|
||||||
|
#friendScores {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#friendScoresHeader {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
#friendScoresList {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 2rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#friendScoresList:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#friendScoresEmpty {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
#friendScores.has-items #friendScoresEmpty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#friendScores.is-loading #friendScoresEmpty {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-score-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-acc {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
/* Settings */
|
/* Settings */
|
||||||
|
|
||||||
body:not(.cover) #coverImg,
|
body:not(.cover) #coverImg,
|
||||||
body:not(.mapInfo) #mapInfo,
|
body:not(.mapInfo) #mapInfo,
|
||||||
body:not(.time) #time,
|
body:not(.time) #time,
|
||||||
body:not(.score) #score,
|
body:not(.score) #score,
|
||||||
|
body:not(.friends) #friendScores,
|
||||||
body:not(.bsr) #bsrKey {
|
body:not(.bsr) #bsrKey {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@ -338,3 +389,11 @@ body.bottom #time {
|
|||||||
float: right;
|
float: right;
|
||||||
margin-left: 1em;
|
margin-left: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body:not(.debug) #mockBsrSetting {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.debug) #debugPlayerSetting {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@ -37,6 +37,11 @@
|
|||||||
<span id="accuracy">96.9</span>
|
<span id="accuracy">96.9</span>
|
||||||
<span id="mistakes">7</span>
|
<span id="mistakes">7</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="friendScores" aria-live="polite">
|
||||||
|
<div id="friendScoresHeader">Mutual friends on BeatLeader</div>
|
||||||
|
<ol id="friendScoresList"></ol>
|
||||||
|
<div id="friendScoresEmpty">No map loaded</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="requestOverlay" aria-live="polite">
|
<div id="requestOverlay" aria-live="polite">
|
||||||
@ -60,6 +65,7 @@
|
|||||||
<label>Show map info: <input id="mapInfoInput" type="checkbox"></label>
|
<label>Show map info: <input id="mapInfoInput" type="checkbox"></label>
|
||||||
<label>Show time: <input id="timeInput" type="checkbox"></label>
|
<label>Show time: <input id="timeInput" type="checkbox"></label>
|
||||||
<label>Show score: <input id="scoreInput" type="checkbox"></label>
|
<label>Show score: <input id="scoreInput" type="checkbox"></label>
|
||||||
|
<label>Show friend scores: <input id="friendsInput" type="checkbox"></label>
|
||||||
<label>Show BSR / map id: <input id="bsrInput" type="checkbox"></label>
|
<label>Show BSR / map id: <input id="bsrInput" type="checkbox"></label>
|
||||||
<label>Position: <select id="positionInput">
|
<label>Position: <select id="positionInput">
|
||||||
<option value="[false,false]">Top left</option>
|
<option value="[false,false]">Top left</option>
|
||||||
@ -70,6 +76,8 @@
|
|||||||
<label>Scale (%): <input id="scaleInput" type="number" min="10" max="1000" step="5"></label>
|
<label>Scale (%): <input id="scaleInput" type="number" min="10" max="1000" step="5"></label>
|
||||||
<label>Fade (ms): <input id="fadeInput" type="number" min="0" max="5000" step="10"></label>
|
<label>Fade (ms): <input id="fadeInput" type="number" min="0" max="5000" step="10"></label>
|
||||||
<label>Debug: <input id="debugInput" type="checkbox"></label>
|
<label>Debug: <input id="debugInput" type="checkbox"></label>
|
||||||
|
<label id="mockBsrSetting">Mock BSR key: <input id="mockBsrInput" type="text" placeholder="e.g. 4f4e4"></label>
|
||||||
|
<label id="debugPlayerSetting">Mock BeatLeader player id: <input id="debugPlayerInput" type="text" placeholder="7656119..."></label>
|
||||||
<br>
|
<br>
|
||||||
<strong>About</strong>
|
<strong>About</strong>
|
||||||
<a href="https://github.com/ibillingsley/BeatSaber-Overlay" target="_blank">This was forked from Iza's overlay</a>
|
<a href="https://github.com/ibillingsley/BeatSaber-Overlay" target="_blank">This was forked from Iza's overlay</a>
|
||||||
|
|||||||
302
index.js
302
index.js
@ -1,3 +1,144 @@
|
|||||||
|
// src/client/beatsaver.ts
|
||||||
|
var BASE_URL = "https://api.beatsaver.com";
|
||||||
|
var USE_RUNTIME_PROXY = typeof document !== "undefined";
|
||||||
|
function beatsaverUrl(path) {
|
||||||
|
if (USE_RUNTIME_PROXY) {
|
||||||
|
return `/api/beatsaver?path=${encodeURIComponent(path)}`;
|
||||||
|
}
|
||||||
|
return `${BASE_URL}${path}`;
|
||||||
|
}
|
||||||
|
async function fetchBeatSaverMeta(hash) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(beatsaverUrl(`/maps/hash/${encodeURIComponent(hash)}`));
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function fetchBeatSaverMapById(mapId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(beatsaverUrl(`/maps/id/${encodeURIComponent(mapId)}`));
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/client/beatleader.ts
|
||||||
|
var BASE_URL2 = "https://api.beatleader.com";
|
||||||
|
var PAGE_SIZE = 100;
|
||||||
|
var USE_RUNTIME_PROXY2 = typeof document !== "undefined";
|
||||||
|
function beatleaderUrl(path) {
|
||||||
|
if (USE_RUNTIME_PROXY2) {
|
||||||
|
return `/api/beatleader?path=${encodeURIComponent(path)}`;
|
||||||
|
}
|
||||||
|
return `${BASE_URL2}${path}`;
|
||||||
|
}
|
||||||
|
async function fetchBLLeaderboardsByHash(hash) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(beatleaderUrl(`/leaderboards/hash/${encodeURIComponent(hash)}`));
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
const leaderboards = Array.isArray(data) ? data : Array.isArray(data.leaderboards) ? data.leaderboards : [];
|
||||||
|
return leaderboards;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function resolveBeatLeaderPlayerId(playerId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(beatleaderUrl(`/player/${encodeURIComponent(playerId)}`));
|
||||||
|
if (!res.ok) return playerId;
|
||||||
|
const data = await res.json();
|
||||||
|
const canonicalId = data.id;
|
||||||
|
return canonicalId == null ? playerId : String(canonicalId);
|
||||||
|
} catch {
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function fetchLeaderboardScoresById(leaderboardId, maxPages = 20) {
|
||||||
|
const scores = [];
|
||||||
|
for (let page = 1; page <= maxPages; page += 1) {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
leaderboardContext: "general",
|
||||||
|
page: String(page),
|
||||||
|
sortBy: "rank",
|
||||||
|
order: "desc"
|
||||||
|
});
|
||||||
|
const url = beatleaderUrl(`/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`);
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(url);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!res.ok) break;
|
||||||
|
const payload = await res.json();
|
||||||
|
const batch = Array.isArray(payload.scores) ? payload.scores : [];
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
scores.push(...batch);
|
||||||
|
if (batch.length < PAGE_SIZE) break;
|
||||||
|
}
|
||||||
|
return scores;
|
||||||
|
}
|
||||||
|
async function fetchAllMapScoresByHash(hash, leaderboards, maxPagesPerLeaderboard = 20) {
|
||||||
|
const requests = leaderboards.map((lb) => {
|
||||||
|
const leaderboardId = lb.id == null ? null : String(lb.id);
|
||||||
|
if (!leaderboardId) return Promise.resolve([]);
|
||||||
|
return fetchLeaderboardScoresById(leaderboardId, maxPagesPerLeaderboard);
|
||||||
|
});
|
||||||
|
const batches = await Promise.all(requests);
|
||||||
|
return batches.flat();
|
||||||
|
}
|
||||||
|
async function fetchFollowersPage(playerId, type2, page, count) {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
type: type2,
|
||||||
|
page: String(page),
|
||||||
|
count: String(count)
|
||||||
|
});
|
||||||
|
const url = beatleaderUrl(`/player/${encodeURIComponent(playerId)}/followers?${qs}`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const data = await response.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function fetchAllFollowers(playerId, type2, maxPages = 100) {
|
||||||
|
const all = [];
|
||||||
|
for (let page = 1; page <= maxPages; page += 1) {
|
||||||
|
const batch = await fetchFollowersPage(playerId, type2, page, PAGE_SIZE);
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
all.push(...batch);
|
||||||
|
if (batch.length < PAGE_SIZE) break;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
async function fetchMutualFriendIds(playerId, maxPages = 100) {
|
||||||
|
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 mutuals = /* @__PURE__ */ new Set();
|
||||||
|
for (const entry of followers) {
|
||||||
|
const id = String(entry.id);
|
||||||
|
if (followingIds.has(id)) {
|
||||||
|
mutuals.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mutuals;
|
||||||
|
}
|
||||||
|
function normalizeAccuracy(value) {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||||
|
return value <= 1 ? value * 100 : value;
|
||||||
|
}
|
||||||
|
|
||||||
// src/client/index.ts
|
// src/client/index.ts
|
||||||
function must(id) {
|
function must(id) {
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
@ -32,6 +173,10 @@ var beatSaberPlus = {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "handshake":
|
||||||
|
currentPlayerPlatformId = data.playerPlatformId || "";
|
||||||
|
void refreshMapFriendScores();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.log("message", e.data);
|
console.log("message", e.data);
|
||||||
break;
|
break;
|
||||||
@ -41,6 +186,7 @@ var beatSaberPlus = {
|
|||||||
var provider = beatSaberPlus;
|
var provider = beatSaberPlus;
|
||||||
var retryMs = 1e4;
|
var retryMs = 1e4;
|
||||||
var retries = 0;
|
var retries = 0;
|
||||||
|
var currentPlayerPlatformId = "";
|
||||||
function connect() {
|
function connect() {
|
||||||
console.log(`Connecting to ${provider.url} (attempt ${retries++})`);
|
console.log(`Connecting to ${provider.url} (attempt ${retries++})`);
|
||||||
const ws = new WebSocket(provider.url);
|
const ws = new WebSocket(provider.url);
|
||||||
@ -72,6 +218,7 @@ var duration = 0;
|
|||||||
async function updateMapInfo(data) {
|
async function updateMapInfo(data) {
|
||||||
const custom = data.level_id.startsWith("custom_level_");
|
const custom = data.level_id.startsWith("custom_level_");
|
||||||
const wip = custom && data.level_id.endsWith("WIP");
|
const wip = custom && data.level_id.endsWith("WIP");
|
||||||
|
currentMapHash = custom ? data.level_id.substring(13, 53).toLowerCase() : "";
|
||||||
cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg";
|
cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg";
|
||||||
title.textContent = data.name || "";
|
title.textContent = data.name || "";
|
||||||
subTitle.textContent = data.sub_name || "";
|
subTitle.textContent = data.sub_name || "";
|
||||||
@ -84,19 +231,22 @@ async function updateMapInfo(data) {
|
|||||||
bsrKey.textContent = data.BSRKey || "???";
|
bsrKey.textContent = data.BSRKey || "???";
|
||||||
timeMultiplier = data.timeMultiplier || 1;
|
timeMultiplier = data.timeMultiplier || 1;
|
||||||
duration = data.duration / 1e3;
|
duration = data.duration / 1e3;
|
||||||
|
void refreshMapFriendScores();
|
||||||
if (custom && !wip) {
|
if (custom && !wip) {
|
||||||
document.body.classList.add("loading");
|
document.body.classList.add("loading");
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://api.beatsaver.com/maps/hash/${data.level_id.substring(13, 53)}`);
|
const map = await fetchBeatSaverMeta(currentMapHash);
|
||||||
const map = await response.json();
|
if (!map?.id) return;
|
||||||
if (!map.id) return;
|
|
||||||
bsrKey.textContent = map.id;
|
bsrKey.textContent = map.id;
|
||||||
mapper.textContent = map.metadata.levelAuthorName;
|
mapper.textContent = map.metadata?.levelAuthorName || "";
|
||||||
const diff = map.versions[0].diffs.find((d) => d.characteristic === data.characteristic && d.difficulty === data.difficulty);
|
const diff = map.versions?.[0]?.diffs?.find((d) => d.characteristic === data.characteristic && d.difficulty === data.difficulty);
|
||||||
if (diff?.label) difficultyLabel.textContent = diff.label;
|
if (diff?.label) difficultyLabel.textContent = diff.label;
|
||||||
} finally {
|
} finally {
|
||||||
document.body.classList.remove("loading");
|
document.body.classList.remove("loading");
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
bsrKey.textContent = "???";
|
||||||
|
difficultyLabel.textContent = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var timeText = must("timeText");
|
var timeText = must("timeText");
|
||||||
@ -124,19 +274,107 @@ function formatTime(t) {
|
|||||||
}
|
}
|
||||||
var accuracy = must("accuracy");
|
var accuracy = must("accuracy");
|
||||||
var mistakes = must("mistakes");
|
var mistakes = must("mistakes");
|
||||||
|
var friendScoresPanel = must("friendScores");
|
||||||
|
var friendScoresList = must("friendScoresList");
|
||||||
|
var friendScoresEmpty = must("friendScoresEmpty");
|
||||||
|
var currentMapHash = "";
|
||||||
|
var friendScoreRequestId = 0;
|
||||||
function updateScore(score) {
|
function updateScore(score) {
|
||||||
if (!settings.score) return;
|
if (!settings.score) return;
|
||||||
accuracy.textContent = (score.accuracy * 100).toFixed(1);
|
accuracy.textContent = (score.accuracy * 100).toFixed(1);
|
||||||
mistakes.textContent = score.missCount ? String(score.missCount) : "";
|
mistakes.textContent = score.missCount ? String(score.missCount) : "";
|
||||||
accuracy.classList.toggle("failed", score.currentHealth === 0);
|
accuracy.classList.toggle("failed", score.currentHealth === 0);
|
||||||
}
|
}
|
||||||
|
function clearFriendScores(message) {
|
||||||
|
friendScoresList.replaceChildren();
|
||||||
|
friendScoresEmpty.textContent = message;
|
||||||
|
friendScoresPanel.classList.remove("has-items", "is-loading");
|
||||||
|
}
|
||||||
|
function renderFriendScores(items) {
|
||||||
|
friendScoresList.replaceChildren();
|
||||||
|
friendScoresPanel.classList.toggle("has-items", items.length > 0);
|
||||||
|
friendScoresPanel.classList.remove("is-loading");
|
||||||
|
friendScoresEmpty.textContent = items.length ? "" : "No mutual scores on this map";
|
||||||
|
for (const item of items) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "friend-score-item";
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "friend-name";
|
||||||
|
name.textContent = item.name;
|
||||||
|
const acc = document.createElement("span");
|
||||||
|
acc.className = "friend-acc";
|
||||||
|
acc.textContent = `${item.acc.toFixed(2)}%`;
|
||||||
|
li.append(name, acc);
|
||||||
|
friendScoresList.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function refreshMapFriendScores() {
|
||||||
|
const hash = currentMapHash;
|
||||||
|
if (!settings.friends) {
|
||||||
|
clearFriendScores("Disabled in settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hash) {
|
||||||
|
clearFriendScores("No map loaded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const playerId = getEffectivePlayerId();
|
||||||
|
if (!playerId) {
|
||||||
|
clearFriendScores("Waiting for BeatLeader player id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
friendScoresPanel.classList.add("is-loading");
|
||||||
|
friendScoresEmpty.textContent = "Loading mutual friend scores...";
|
||||||
|
const requestId = ++friendScoreRequestId;
|
||||||
|
try {
|
||||||
|
const [leaderboards, mutualFriendIds] = await Promise.all([
|
||||||
|
fetchBLLeaderboardsByHash(hash),
|
||||||
|
fetchMutualFriendIds(playerId)
|
||||||
|
]);
|
||||||
|
if (requestId !== friendScoreRequestId) return;
|
||||||
|
if (leaderboards.length === 0) {
|
||||||
|
clearFriendScores("No BeatLeader leaderboards found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mutualFriendIds.size === 0) {
|
||||||
|
clearFriendScores("No mutual BeatLeader followers");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scores = await fetchAllMapScoresByHash(hash, leaderboards);
|
||||||
|
if (requestId !== friendScoreRequestId) return;
|
||||||
|
const bestByPlayer = /* @__PURE__ */ new Map();
|
||||||
|
for (const score of scores) {
|
||||||
|
const playerId2 = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
|
||||||
|
const playerKey = playerId2 == null ? "" : String(playerId2);
|
||||||
|
if (!playerKey || !mutualFriendIds.has(playerKey)) continue;
|
||||||
|
const acc = normalizeAccuracy(score.accuracy ?? score.acc);
|
||||||
|
if (acc === null) continue;
|
||||||
|
const existing = bestByPlayer.get(playerKey);
|
||||||
|
if (!existing || acc > existing.acc) {
|
||||||
|
const playerName = score.playerName || (typeof score.player === "object" ? score.player?.name : typeof score.player === "string" ? score.player : null);
|
||||||
|
bestByPlayer.set(playerKey, {
|
||||||
|
name: playerName || playerKey,
|
||||||
|
acc
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc);
|
||||||
|
renderFriendScores(sorted);
|
||||||
|
} catch {
|
||||||
|
if (requestId !== friendScoreRequestId) return;
|
||||||
|
clearFriendScores("Failed loading BeatLeader scores");
|
||||||
|
}
|
||||||
|
}
|
||||||
var settings = {
|
var settings = {
|
||||||
cover: true,
|
cover: true,
|
||||||
mapInfo: true,
|
mapInfo: true,
|
||||||
time: true,
|
time: true,
|
||||||
score: true,
|
score: true,
|
||||||
|
friends: true,
|
||||||
bsr: false,
|
bsr: false,
|
||||||
debug: false,
|
debug: false,
|
||||||
|
mockBsr: "4f4e4",
|
||||||
|
debugPlayerId: "76561199407393962",
|
||||||
right: false,
|
right: false,
|
||||||
bottom: true,
|
bottom: true,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@ -162,6 +400,32 @@ function saveSettings() {
|
|||||||
}
|
}
|
||||||
location.replace(`#${params.toString()}`);
|
location.replace(`#${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
async function applyMockMapFromBsr() {
|
||||||
|
const key = settings.mockBsr.trim();
|
||||||
|
if (!settings.debug || !key) return;
|
||||||
|
const map = await fetchBeatSaverMapById(key);
|
||||||
|
if (!map) return;
|
||||||
|
const hash = map.versions?.[0]?.hash?.toLowerCase?.() || "";
|
||||||
|
currentMapHash = hash;
|
||||||
|
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;
|
||||||
|
void refreshMapFriendScores();
|
||||||
|
}
|
||||||
|
function getEffectivePlayerId() {
|
||||||
|
const raw = settings.debug && settings.debugPlayerId.trim() ? settings.debugPlayerId.trim() : currentPlayerPlatformId;
|
||||||
|
if (!raw) return "";
|
||||||
|
const steamIdCandidate = raw.match(/\d{17,20}/)?.[0];
|
||||||
|
if (steamIdCandidate) return steamIdCandidate;
|
||||||
|
if (/^\d+$/.test(raw)) return raw;
|
||||||
|
if (settings.debug) return raw;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
window.onhashchange = loadSettings;
|
window.onhashchange = loadSettings;
|
||||||
loadSettings();
|
loadSettings();
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
@ -170,6 +434,7 @@ for (const key of [
|
|||||||
"mapInfo",
|
"mapInfo",
|
||||||
"time",
|
"time",
|
||||||
"score",
|
"score",
|
||||||
|
"friends",
|
||||||
"bsr",
|
"bsr",
|
||||||
"debug"
|
"debug"
|
||||||
]) {
|
]) {
|
||||||
@ -178,9 +443,29 @@ for (const key of [
|
|||||||
input.oninput = () => {
|
input.oninput = () => {
|
||||||
settings[key] = input.checked;
|
settings[key] = input.checked;
|
||||||
saveSettings();
|
saveSettings();
|
||||||
if (key === "debug") void loadRequestQueue();
|
if (key === "friends") void refreshMapFriendScores();
|
||||||
|
if (key === "debug") {
|
||||||
|
void loadRequestQueue();
|
||||||
|
void applyMockMapFromBsr();
|
||||||
|
void refreshMapFriendScores();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
var mockBsrInput = must("mockBsrInput");
|
||||||
|
mockBsrInput.value = settings.mockBsr;
|
||||||
|
mockBsrInput.oninput = () => {
|
||||||
|
settings.mockBsr = mockBsrInput.value.trim();
|
||||||
|
saveSettings();
|
||||||
|
void applyMockMapFromBsr();
|
||||||
|
};
|
||||||
|
var debugPlayerInput = must("debugPlayerInput");
|
||||||
|
debugPlayerInput.value = settings.debugPlayerId;
|
||||||
|
debugPlayerInput.oninput = () => {
|
||||||
|
settings.debugPlayerId = debugPlayerInput.value.trim();
|
||||||
|
saveSettings();
|
||||||
|
if (settings.debug) void refreshMapFriendScores();
|
||||||
|
};
|
||||||
|
void applyMockMapFromBsr();
|
||||||
var scale = must("scaleInput");
|
var scale = must("scaleInput");
|
||||||
scale.valueAsNumber = settings.scale * 100;
|
scale.valueAsNumber = settings.scale * 100;
|
||||||
scale.oninput = () => {
|
scale.oninput = () => {
|
||||||
@ -264,9 +549,8 @@ async function enrichRequestTitle(key, titleEl) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://api.beatsaver.com/maps/id/${encodeURIComponent(key)}`);
|
const map = await fetchBeatSaverMapById(key);
|
||||||
if (!response.ok) return;
|
if (!map) return;
|
||||||
const map = await response.json();
|
|
||||||
const name = map.metadata?.songName ?? map.name;
|
const name = map.metadata?.songName ?? map.name;
|
||||||
if (name && typeof name === "string") {
|
if (name && typeof name === "string") {
|
||||||
requestTitleCache.set(key, name);
|
requestTitleCache.set(key, name);
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"path": "."
|
"path": "."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../plebsaber.stream"
|
"path": "../../src/plebsaber.stream"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"settings": {}
|
"settings": {}
|
||||||
|
|||||||
199
src/client/beatleader.ts
Normal file
199
src/client/beatleader.ts
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import type {
|
||||||
|
BeatLeaderFollower,
|
||||||
|
BeatLeaderLeaderboard,
|
||||||
|
BeatLeaderLeaderboardsByHashResponse,
|
||||||
|
BeatLeaderScore,
|
||||||
|
BeatLeaderScoresResponse,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
|
const BASE_URL = "https://api.beatleader.com";
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
const USE_RUNTIME_PROXY = typeof document !== "undefined";
|
||||||
|
|
||||||
|
function beatleaderUrl(path: string): string {
|
||||||
|
if (USE_RUNTIME_PROXY) {
|
||||||
|
return `/api/beatleader?path=${encodeURIComponent(path)}`;
|
||||||
|
}
|
||||||
|
return `${BASE_URL}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BeatLeaderPlayerLookup {
|
||||||
|
id?: string | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBLLeaderboardsByHash(hash: string): Promise<BeatLeaderLeaderboard[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(beatleaderUrl(`/leaderboards/hash/${encodeURIComponent(hash)}`));
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json() as BeatLeaderLeaderboardsByHashResponse | BeatLeaderLeaderboard[];
|
||||||
|
const leaderboards = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: Array.isArray(data.leaderboards)
|
||||||
|
? data.leaderboards
|
||||||
|
: [];
|
||||||
|
return leaderboards;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveBeatLeaderPlayerId(playerId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(beatleaderUrl(`/player/${encodeURIComponent(playerId)}`));
|
||||||
|
if (!res.ok) return playerId;
|
||||||
|
const data = await res.json() as BeatLeaderPlayerLookup;
|
||||||
|
const canonicalId = data.id;
|
||||||
|
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 = 20,
|
||||||
|
): Promise<BeatLeaderScore[]> {
|
||||||
|
const scores: BeatLeaderScore[] = [];
|
||||||
|
for (let page = 1; page <= maxPages; page += 1) {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
leaderboardContext: "general",
|
||||||
|
page: String(page),
|
||||||
|
sortBy: "rank",
|
||||||
|
order: "desc",
|
||||||
|
});
|
||||||
|
const url = beatleaderUrl(`/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`);
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!res.ok) break;
|
||||||
|
const payload = await res.json() as BeatLeaderLeaderboardScoresResponse;
|
||||||
|
const batch = Array.isArray(payload.scores) ? payload.scores : [];
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
scores.push(...batch);
|
||||||
|
if (batch.length < PAGE_SIZE) break;
|
||||||
|
}
|
||||||
|
return scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllMapScoresByHash(
|
||||||
|
hash: string,
|
||||||
|
leaderboards: BeatLeaderLeaderboard[],
|
||||||
|
maxPagesPerLeaderboard = 20,
|
||||||
|
): Promise<BeatLeaderScore[]> {
|
||||||
|
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);
|
||||||
|
return batches.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFollowersPage(
|
||||||
|
playerId: string,
|
||||||
|
type: "Followers" | "Following",
|
||||||
|
page: number,
|
||||||
|
count: number,
|
||||||
|
): Promise<BeatLeaderFollower[]> {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
type,
|
||||||
|
page: String(page),
|
||||||
|
count: String(count),
|
||||||
|
});
|
||||||
|
const url = beatleaderUrl(`/player/${encodeURIComponent(playerId)}/followers?${qs}`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const data = await response.json() as BeatLeaderFollower[];
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllFollowers(
|
||||||
|
playerId: string,
|
||||||
|
type: "Followers" | "Following",
|
||||||
|
maxPages = 100,
|
||||||
|
): Promise<BeatLeaderFollower[]> {
|
||||||
|
const all: BeatLeaderFollower[] = [];
|
||||||
|
for (let page = 1; page <= maxPages; page += 1) {
|
||||||
|
const batch = await fetchFollowersPage(playerId, type, page, PAGE_SIZE);
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
all.push(...batch);
|
||||||
|
if (batch.length < PAGE_SIZE) break;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMutualFriendIds(playerId: string, 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 mutuals = new Set<string>();
|
||||||
|
for (const entry of followers) {
|
||||||
|
const id = String(entry.id);
|
||||||
|
if (followingIds.has(id)) {
|
||||||
|
mutuals.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mutuals;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMutualFriends(playerId: string, maxPages = 100): Promise<BeatLeaderFollower[]> {
|
||||||
|
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)));
|
||||||
|
return followers
|
||||||
|
.filter((entry) => followingIds.has(String(entry.id)))
|
||||||
|
.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
id: String(entry.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAccuracy(value: number | null | undefined): number | null {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||||
|
return value <= 1 ? value * 100 : value;
|
||||||
|
}
|
||||||
51
src/client/beatsaver.ts
Normal file
51
src/client/beatsaver.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
export interface BeatSaverDiff {
|
||||||
|
characteristic: string;
|
||||||
|
difficulty: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BeatSaverMap {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
metadata?: {
|
||||||
|
songName?: string;
|
||||||
|
songSubName?: string;
|
||||||
|
songAuthorName?: string;
|
||||||
|
levelAuthorName?: string;
|
||||||
|
};
|
||||||
|
versions?: Array<{
|
||||||
|
hash?: string;
|
||||||
|
coverURL?: string;
|
||||||
|
diffs?: BeatSaverDiff[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URL = "https://api.beatsaver.com";
|
||||||
|
const USE_RUNTIME_PROXY = typeof document !== "undefined";
|
||||||
|
|
||||||
|
function beatsaverUrl(path: string): string {
|
||||||
|
if (USE_RUNTIME_PROXY) {
|
||||||
|
return `/api/beatsaver?path=${encodeURIComponent(path)}`;
|
||||||
|
}
|
||||||
|
return `${BASE_URL}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBeatSaverMeta(hash: string): Promise<BeatSaverMap | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(beatsaverUrl(`/maps/hash/${encodeURIComponent(hash)}`));
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return await response.json() as BeatSaverMap;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBeatSaverMapById(mapId: string): Promise<BeatSaverMap | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(beatsaverUrl(`/maps/id/${encodeURIComponent(mapId)}`));
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return await response.json() as BeatSaverMap;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,13 @@ import type {
|
|||||||
MapInfo,
|
MapInfo,
|
||||||
Score,
|
Score,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
import { fetchBeatSaverMapById, fetchBeatSaverMeta } from "./beatsaver.ts";
|
||||||
|
import {
|
||||||
|
fetchAllMapScoresByHash,
|
||||||
|
fetchBLLeaderboardsByHash,
|
||||||
|
fetchMutualFriendIds,
|
||||||
|
normalizeAccuracy,
|
||||||
|
} from "./beatleader.ts";
|
||||||
|
|
||||||
function must<T extends HTMLElement>(id: string): T {
|
function must<T extends HTMLElement>(id: string): T {
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
@ -43,6 +50,10 @@ const beatSaberPlus = {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "handshake":
|
||||||
|
currentPlayerPlatformId = data.playerPlatformId || "";
|
||||||
|
void refreshMapFriendScores();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.log("message", e.data);
|
console.log("message", e.data);
|
||||||
break;
|
break;
|
||||||
@ -53,6 +64,7 @@ const beatSaberPlus = {
|
|||||||
const provider = beatSaberPlus;
|
const provider = beatSaberPlus;
|
||||||
const retryMs = 10000;
|
const retryMs = 10000;
|
||||||
let retries = 0;
|
let retries = 0;
|
||||||
|
let currentPlayerPlatformId = "";
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
console.log(`Connecting to ${provider.url} (attempt ${retries++})`);
|
console.log(`Connecting to ${provider.url} (attempt ${retries++})`);
|
||||||
@ -92,6 +104,7 @@ let duration = 0;
|
|||||||
async function updateMapInfo(data: MapInfo) {
|
async function updateMapInfo(data: MapInfo) {
|
||||||
const custom = data.level_id.startsWith("custom_level_");
|
const custom = data.level_id.startsWith("custom_level_");
|
||||||
const wip = custom && data.level_id.endsWith("WIP");
|
const wip = custom && data.level_id.endsWith("WIP");
|
||||||
|
currentMapHash = custom ? data.level_id.substring(13, 53).toLowerCase() : "";
|
||||||
cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg";
|
cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg";
|
||||||
title.textContent = data.name || "";
|
title.textContent = data.name || "";
|
||||||
subTitle.textContent = data.sub_name || "";
|
subTitle.textContent = data.sub_name || "";
|
||||||
@ -104,24 +117,26 @@ async function updateMapInfo(data: MapInfo) {
|
|||||||
bsrKey.textContent = data.BSRKey || "???"; // Always empty?
|
bsrKey.textContent = data.BSRKey || "???"; // Always empty?
|
||||||
timeMultiplier = data.timeMultiplier || 1;
|
timeMultiplier = data.timeMultiplier || 1;
|
||||||
duration = data.duration / 1000;
|
duration = data.duration / 1000;
|
||||||
|
void refreshMapFriendScores();
|
||||||
|
|
||||||
// Fetch extra info from BeatSaver
|
// Fetch extra info from BeatSaver
|
||||||
if (custom && !wip) {
|
if (custom && !wip) {
|
||||||
document.body.classList.add("loading");
|
document.body.classList.add("loading");
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://api.beatsaver.com/maps/hash/${data.level_id.substring(13, 53)}`);
|
const map = await fetchBeatSaverMeta(currentMapHash);
|
||||||
const map = await response.json();
|
if (!map?.id) return;
|
||||||
if (!map.id) return;
|
|
||||||
bsrKey.textContent = map.id;
|
bsrKey.textContent = map.id;
|
||||||
mapper.textContent = map.metadata.levelAuthorName; // Replace mapper name with full authors list
|
mapper.textContent = map.metadata?.levelAuthorName || "";
|
||||||
const diff = map.versions[0].diffs.find(
|
const diff = map.versions?.[0]?.diffs?.find(
|
||||||
(d: { characteristic: string; difficulty: string; label?: string }) =>
|
(d) => d.characteristic === data.characteristic && d.difficulty === data.difficulty,
|
||||||
d.characteristic === data.characteristic && d.difficulty === data.difficulty,
|
|
||||||
);
|
);
|
||||||
if (diff?.label) difficultyLabel.textContent = diff.label;
|
if (diff?.label) difficultyLabel.textContent = diff.label;
|
||||||
} finally {
|
} finally {
|
||||||
document.body.classList.remove("loading");
|
document.body.classList.remove("loading");
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
bsrKey.textContent = "???";
|
||||||
|
difficultyLabel.textContent = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,6 +173,12 @@ function formatTime(t: number) {
|
|||||||
|
|
||||||
const accuracy = must<HTMLElement>("accuracy");
|
const accuracy = must<HTMLElement>("accuracy");
|
||||||
const mistakes = must<HTMLElement>("mistakes");
|
const mistakes = must<HTMLElement>("mistakes");
|
||||||
|
const friendScoresPanel = must<HTMLElement>("friendScores");
|
||||||
|
const friendScoresList = must<HTMLOListElement>("friendScoresList");
|
||||||
|
const friendScoresEmpty = must<HTMLElement>("friendScoresEmpty");
|
||||||
|
|
||||||
|
let currentMapHash = "";
|
||||||
|
let friendScoreRequestId = 0;
|
||||||
|
|
||||||
function updateScore(score: Score) {
|
function updateScore(score: Score) {
|
||||||
if (!settings.score) return;
|
if (!settings.score) return;
|
||||||
@ -166,6 +187,91 @@ function updateScore(score: Score) {
|
|||||||
accuracy.classList.toggle("failed", score.currentHealth === 0);
|
accuracy.classList.toggle("failed", score.currentHealth === 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearFriendScores(message: string) {
|
||||||
|
friendScoresList.replaceChildren();
|
||||||
|
friendScoresEmpty.textContent = message;
|
||||||
|
friendScoresPanel.classList.remove("has-items", "is-loading");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFriendScores(items: Array<{ name: string; acc: number }>) {
|
||||||
|
friendScoresList.replaceChildren();
|
||||||
|
friendScoresPanel.classList.toggle("has-items", items.length > 0);
|
||||||
|
friendScoresPanel.classList.remove("is-loading");
|
||||||
|
friendScoresEmpty.textContent = items.length ? "" : "No mutual scores on this map";
|
||||||
|
for (const item of items) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "friend-score-item";
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "friend-name";
|
||||||
|
name.textContent = item.name;
|
||||||
|
const acc = document.createElement("span");
|
||||||
|
acc.className = "friend-acc";
|
||||||
|
acc.textContent = `${item.acc.toFixed(2)}%`;
|
||||||
|
li.append(name, acc);
|
||||||
|
friendScoresList.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMapFriendScores() {
|
||||||
|
const hash = currentMapHash;
|
||||||
|
if (!settings.friends) {
|
||||||
|
clearFriendScores("Disabled in settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hash) {
|
||||||
|
clearFriendScores("No map loaded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const playerId = getEffectivePlayerId();
|
||||||
|
if (!playerId) {
|
||||||
|
clearFriendScores("Waiting for BeatLeader player id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
friendScoresPanel.classList.add("is-loading");
|
||||||
|
friendScoresEmpty.textContent = "Loading mutual friend scores...";
|
||||||
|
const requestId = ++friendScoreRequestId;
|
||||||
|
try {
|
||||||
|
const [leaderboards, mutualFriendIds] = await Promise.all([
|
||||||
|
fetchBLLeaderboardsByHash(hash),
|
||||||
|
fetchMutualFriendIds(playerId),
|
||||||
|
]);
|
||||||
|
if (requestId !== friendScoreRequestId) return;
|
||||||
|
if (leaderboards.length === 0) {
|
||||||
|
clearFriendScores("No BeatLeader leaderboards found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mutualFriendIds.size === 0) {
|
||||||
|
clearFriendScores("No mutual BeatLeader followers");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scores = await fetchAllMapScoresByHash(hash, leaderboards);
|
||||||
|
if (requestId !== friendScoreRequestId) return;
|
||||||
|
const bestByPlayer = new Map<string, { name: string; acc: number }>();
|
||||||
|
for (const score of scores) {
|
||||||
|
const playerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
|
||||||
|
const playerKey = playerId == null ? "" : String(playerId);
|
||||||
|
if (!playerKey || !mutualFriendIds.has(playerKey)) continue;
|
||||||
|
const acc = normalizeAccuracy(score.accuracy ?? score.acc);
|
||||||
|
if (acc === null) continue;
|
||||||
|
const existing = bestByPlayer.get(playerKey);
|
||||||
|
if (!existing || acc > existing.acc) {
|
||||||
|
const playerName =
|
||||||
|
score.playerName ||
|
||||||
|
(typeof score.player === "object" ? score.player?.name : typeof score.player === "string" ? score.player : null);
|
||||||
|
bestByPlayer.set(playerKey, {
|
||||||
|
name: playerName || playerKey,
|
||||||
|
acc,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc);
|
||||||
|
renderFriendScores(sorted);
|
||||||
|
} catch {
|
||||||
|
if (requestId !== friendScoreRequestId) return;
|
||||||
|
clearFriendScores("Failed loading BeatLeader scores");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
@ -173,8 +279,11 @@ interface Settings {
|
|||||||
mapInfo: boolean;
|
mapInfo: boolean;
|
||||||
time: boolean;
|
time: boolean;
|
||||||
score: boolean;
|
score: boolean;
|
||||||
|
friends: boolean;
|
||||||
bsr: boolean;
|
bsr: boolean;
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
|
mockBsr: string;
|
||||||
|
debugPlayerId: string;
|
||||||
right: boolean;
|
right: boolean;
|
||||||
bottom: boolean;
|
bottom: boolean;
|
||||||
scale: number;
|
scale: number;
|
||||||
@ -186,8 +295,11 @@ const settings: Settings = {
|
|||||||
mapInfo: true,
|
mapInfo: true,
|
||||||
time: true,
|
time: true,
|
||||||
score: true,
|
score: true,
|
||||||
|
friends: true,
|
||||||
bsr: false,
|
bsr: false,
|
||||||
debug: false,
|
debug: false,
|
||||||
|
mockBsr: "4f4e4",
|
||||||
|
debugPlayerId: "76561199407393962",
|
||||||
right: false,
|
right: false,
|
||||||
bottom: true,
|
bottom: true,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@ -217,22 +329,75 @@ function saveSettings() {
|
|||||||
location.replace(`#${params.toString()}`);
|
location.replace(`#${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyMockMapFromBsr() {
|
||||||
|
const key = settings.mockBsr.trim();
|
||||||
|
if (!settings.debug || !key) return;
|
||||||
|
const map = await fetchBeatSaverMapById(key);
|
||||||
|
if (!map) return;
|
||||||
|
const hash = map.versions?.[0]?.hash?.toLowerCase?.() || "";
|
||||||
|
currentMapHash = hash;
|
||||||
|
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;
|
||||||
|
void refreshMapFriendScores();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectivePlayerId() {
|
||||||
|
const raw = settings.debug && settings.debugPlayerId.trim()
|
||||||
|
? settings.debugPlayerId.trim()
|
||||||
|
: currentPlayerPlatformId;
|
||||||
|
if (!raw) return "";
|
||||||
|
const steamIdCandidate = raw.match(/\d{17,20}/)?.[0];
|
||||||
|
if (steamIdCandidate) return steamIdCandidate;
|
||||||
|
if (/^\d+$/.test(raw)) return raw;
|
||||||
|
if (settings.debug) return raw;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
window.onhashchange = loadSettings;
|
window.onhashchange = loadSettings;
|
||||||
loadSettings();
|
loadSettings();
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
// Settings UI
|
// Settings UI
|
||||||
|
|
||||||
for (const key of ["cover", "mapInfo", "time", "score", "bsr", "debug"] as const) {
|
for (const key of ["cover", "mapInfo", "time", "score", "friends", "bsr", "debug"] as const) {
|
||||||
const input = must<HTMLInputElement>(`${key}Input`);
|
const input = must<HTMLInputElement>(`${key}Input`);
|
||||||
input.checked = settings[key];
|
input.checked = settings[key];
|
||||||
input.oninput = () => {
|
input.oninput = () => {
|
||||||
settings[key] = input.checked;
|
settings[key] = input.checked;
|
||||||
saveSettings();
|
saveSettings();
|
||||||
if (key === "debug") void loadRequestQueue();
|
if (key === "friends") void refreshMapFriendScores();
|
||||||
|
if (key === "debug") {
|
||||||
|
void loadRequestQueue();
|
||||||
|
void applyMockMapFromBsr();
|
||||||
|
void refreshMapFriendScores();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mockBsrInput = must<HTMLInputElement>("mockBsrInput");
|
||||||
|
mockBsrInput.value = settings.mockBsr;
|
||||||
|
mockBsrInput.oninput = () => {
|
||||||
|
settings.mockBsr = mockBsrInput.value.trim();
|
||||||
|
saveSettings();
|
||||||
|
void applyMockMapFromBsr();
|
||||||
|
};
|
||||||
|
|
||||||
|
const debugPlayerInput = must<HTMLInputElement>("debugPlayerInput");
|
||||||
|
debugPlayerInput.value = settings.debugPlayerId;
|
||||||
|
debugPlayerInput.oninput = () => {
|
||||||
|
settings.debugPlayerId = debugPlayerInput.value.trim();
|
||||||
|
saveSettings();
|
||||||
|
if (settings.debug) void refreshMapFriendScores();
|
||||||
|
};
|
||||||
|
|
||||||
|
void applyMockMapFromBsr();
|
||||||
|
|
||||||
const scale = must<HTMLInputElement>("scaleInput");
|
const scale = must<HTMLInputElement>("scaleInput");
|
||||||
scale.valueAsNumber = settings.scale * 100;
|
scale.valueAsNumber = settings.scale * 100;
|
||||||
scale.oninput = () => {
|
scale.oninput = () => {
|
||||||
@ -313,9 +478,8 @@ async function enrichRequestTitle(key: string, titleEl: HTMLElement) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://api.beatsaver.com/maps/id/${encodeURIComponent(key)}`);
|
const map = await fetchBeatSaverMapById(key);
|
||||||
if (!response.ok) return;
|
if (!map) return;
|
||||||
const map = await response.json();
|
|
||||||
const name = map.metadata?.songName ?? map.name;
|
const name = map.metadata?.songName ?? map.name;
|
||||||
if (name && typeof name === "string") {
|
if (name && typeof name === "string") {
|
||||||
requestTitleCache.set(key, name);
|
requestTitleCache.set(key, name);
|
||||||
|
|||||||
82
src/client/live-api.test.ts
Normal file
82
src/client/live-api.test.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { assert, assertEquals } from "jsr:@std/assert";
|
||||||
|
import {
|
||||||
|
fetchAllMapScoresByHash,
|
||||||
|
fetchAllLeaderboardScoresByHash,
|
||||||
|
fetchBLLeaderboardsByHash,
|
||||||
|
fetchMutualFriends,
|
||||||
|
fetchMutualFriendIds,
|
||||||
|
normalizeAccuracy,
|
||||||
|
} from "./beatleader.ts";
|
||||||
|
import { fetchBeatSaverMapById } from "./beatsaver.ts";
|
||||||
|
|
||||||
|
const MAP_KEY = "4f4e4";
|
||||||
|
const PLAYER_ID = "76561199407393962";
|
||||||
|
const MODE = "Standard";
|
||||||
|
|
||||||
|
function normalizeDifficultyName(value: string | null | undefined) {
|
||||||
|
return (value ?? "").toLowerCase().replace(/\s+/g, "").replace("expert+", "expertplus");
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaderboardId(lb: { id?: string | number | null; leaderboardId?: string | number | null }) {
|
||||||
|
const id = lb.id ?? lb.leaderboardId;
|
||||||
|
return id == null ? "" : String(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: "live lookup: map hash -> expertplus leaderboard -> mutual friend scores",
|
||||||
|
sanitizeOps: false,
|
||||||
|
sanitizeResources: false,
|
||||||
|
async fn() {
|
||||||
|
const map = await fetchBeatSaverMapById(MAP_KEY);
|
||||||
|
assert(map?.versions?.[0]?.hash, `BeatSaver map ${MAP_KEY} should resolve to a hash`);
|
||||||
|
const hash = String(map.versions[0].hash).toLowerCase();
|
||||||
|
|
||||||
|
const leaderboards = await fetchBLLeaderboardsByHash(hash);
|
||||||
|
assert(leaderboards.length > 0, "Expected BeatLeader leaderboards for hash");
|
||||||
|
|
||||||
|
const expertPlus = leaderboards.find((lb) => {
|
||||||
|
const modeOk = (lb.difficulty?.modeName ?? "").toLowerCase() === MODE.toLowerCase();
|
||||||
|
const diff = normalizeDifficultyName(lb.difficulty?.difficultyName);
|
||||||
|
return modeOk && diff === "expertplus";
|
||||||
|
});
|
||||||
|
assert(expertPlus, "Expected Standard ExpertPlus leaderboard");
|
||||||
|
|
||||||
|
const nonExpertPlus = leaderboards.find((lb) => {
|
||||||
|
const modeOk = (lb.difficulty?.modeName ?? "").toLowerCase() === MODE.toLowerCase();
|
||||||
|
const diff = normalizeDifficultyName(lb.difficulty?.difficultyName);
|
||||||
|
return modeOk && diff !== "expertplus";
|
||||||
|
});
|
||||||
|
assert(nonExpertPlus, "Expected a Standard non-ExpertPlus leaderboard");
|
||||||
|
assert(
|
||||||
|
leaderboardId(expertPlus) !== leaderboardId(nonExpertPlus),
|
||||||
|
"ExpertPlus should map to a different leaderboard id than another difficulty",
|
||||||
|
);
|
||||||
|
|
||||||
|
const [expertPlusScores, allMapScores, mutualIds, mutualFriends] = await Promise.all([
|
||||||
|
fetchAllLeaderboardScoresByHash(hash, "ExpertPlus", MODE, 8),
|
||||||
|
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);
|
||||||
|
return playerId != null && mutualIds.has(String(playerId));
|
||||||
|
});
|
||||||
|
assert(mutualScores.length > 0, "Expected at least one mutual friend score on this map");
|
||||||
|
|
||||||
|
const accuracies = mutualScores
|
||||||
|
.map((score) => normalizeAccuracy(score.accuracy ?? score.acc))
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
assert(accuracies.length > 0, "Expected mutual scores to include accuracy values");
|
||||||
|
assert(accuracies.some((acc) => acc > 0), "Expected positive accuracy values");
|
||||||
|
assertEquals(hash.length, 40);
|
||||||
|
},
|
||||||
|
});
|
||||||
19
src/client/live-friends.test.ts
Normal file
19
src/client/live-friends.test.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { assert } from "jsr:@std/assert";
|
||||||
|
import { fetchMutualFriends } from "./beatleader.ts";
|
||||||
|
|
||||||
|
const PLAYER_ID = "76561199407393962";
|
||||||
|
|
||||||
|
Deno.test({
|
||||||
|
name: "live lookup: player has mutual BeatLeader friends",
|
||||||
|
sanitizeOps: false,
|
||||||
|
sanitizeResources: false,
|
||||||
|
async fn() {
|
||||||
|
const mutuals = await fetchMutualFriends(PLAYER_ID, 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})`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -83,3 +83,49 @@ export interface ChatRequestPayload {
|
|||||||
queue: ChatRequestEntry[];
|
queue: ChatRequestEntry[];
|
||||||
history: ChatRequestEntry[];
|
history: ChatRequestEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BeatLeaderDifficulty {
|
||||||
|
modeName?: string | null;
|
||||||
|
difficultyName?: string | null;
|
||||||
|
value?: number | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BeatLeaderLeaderboard {
|
||||||
|
id?: string | number | null;
|
||||||
|
leaderboardId?: string | number | null;
|
||||||
|
difficulty?: BeatLeaderDifficulty;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BeatLeaderLeaderboardsByHashResponse {
|
||||||
|
leaderboards?: BeatLeaderLeaderboard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BeatLeaderPlayer {
|
||||||
|
id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BeatLeaderScore {
|
||||||
|
id?: number | null;
|
||||||
|
accuracy?: number | null;
|
||||||
|
acc?: number | null;
|
||||||
|
baseScore?: number | null;
|
||||||
|
modifiedScore?: number | null;
|
||||||
|
playerId?: string | number | null;
|
||||||
|
playerName?: string | null;
|
||||||
|
player?: BeatLeaderPlayer | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BeatLeaderScoresResponse {
|
||||||
|
data?: BeatLeaderScore[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BeatLeaderFollower {
|
||||||
|
id: string;
|
||||||
|
alias?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
count?: number | null;
|
||||||
|
mutual?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@ -32,6 +32,42 @@ function readOptionalPathFile(): string | undefined {
|
|||||||
const chatRequestDatabase =
|
const chatRequestDatabase =
|
||||||
Deno.env.get("CHAT_REQUEST_DATABASE")?.trim() || readOptionalPathFile();
|
Deno.env.get("CHAT_REQUEST_DATABASE")?.trim() || readOptionalPathFile();
|
||||||
|
|
||||||
|
function isSafeProxyPath(path: string): boolean {
|
||||||
|
if (!path) return false;
|
||||||
|
if (!path.startsWith("/")) return false;
|
||||||
|
if (path.includes("://")) return false;
|
||||||
|
if (path.includes("\\") || path.includes("\r") || path.includes("\n")) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyApiRequest(req: Request, upstreamBase: string): Promise<Response> {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const path = url.searchParams.get("path") ?? "";
|
||||||
|
if (!isSafeProxyPath(path)) {
|
||||||
|
return new Response("Invalid path\n", { status: 400 });
|
||||||
|
}
|
||||||
|
const upstream = new URL(`${upstreamBase}${path}`);
|
||||||
|
for (const [key, value] of url.searchParams.entries()) {
|
||||||
|
if (key === "path") continue;
|
||||||
|
upstream.searchParams.append(key, value);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const upstreamRes = await fetch(upstream, { method: "GET" });
|
||||||
|
const headers = new Headers();
|
||||||
|
const contentType = upstreamRes.headers.get("content-type");
|
||||||
|
if (contentType) headers.set("content-type", contentType);
|
||||||
|
headers.set("cache-control", "no-store");
|
||||||
|
return new Response(upstreamRes.body, {
|
||||||
|
status: upstreamRes.status,
|
||||||
|
statusText: upstreamRes.statusText,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return new Response(`Proxy request failed: ${message}\n`, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isChatRequestFilename(pathname: string): boolean {
|
function isChatRequestFilename(pathname: string): boolean {
|
||||||
const base = pathname.split("/").pop() ?? "";
|
const base = pathname.split("/").pop() ?? "";
|
||||||
return base === "ChatRequest.json" || base === "database.json";
|
return base === "ChatRequest.json" || base === "database.json";
|
||||||
@ -39,6 +75,12 @@ function isChatRequestFilename(pathname: string): boolean {
|
|||||||
|
|
||||||
Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => {
|
Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/beatleader") {
|
||||||
|
return proxyApiRequest(req, "https://api.beatleader.com");
|
||||||
|
}
|
||||||
|
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 && isChatRequestFilename(url.pathname)) {
|
||||||
try {
|
try {
|
||||||
let text = await Deno.readTextFile(chatRequestDatabase);
|
let text = await Deno.readTextFile(chatRequestDatabase);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user