Add an friends-all to include all followed and followers

This commit is contained in:
pleb 2026-05-05 17:48:08 -07:00
parent 3410e3324e
commit 0df2242f1c
10 changed files with 68 additions and 12 deletions

View File

@ -26,5 +26,5 @@ Beat Saber Plus itself (game mod) exposes the socket; this repo is only the HTML
- **Live API calls are proxied** same-origin via `src/server/serve.ts`: `/api/beatleader?path=/...`, `/api/beatsaver?path=/...` (CORS). - **Live API calls are proxied** same-origin via `src/server/serve.ts`: `/api/beatleader?path=/...`, `/api/beatsaver?path=/...` (CORS).
- **Map correlation is hash-based**: resolve BeatSaver map/hash first, then BeatLeader leaderboards via `/leaderboards/hash/{hash}`. - **Map correlation is hash-based**: resolve BeatSaver map/hash first, then BeatLeader leaderboards via `/leaderboards/hash/{hash}`.
- **Friend scores** use `/leaderboard/{leaderboardId}` for stable `playerId` fields. - **Friend scores** use `/leaderboard/{leaderboardId}` for stable `playerId` fields.
- **Mutual friends**: intersection of `/player/{id}/followers?type=Following` and `type=Followers` (paged by `page` + `count`). - **Mutual friends**: intersection of `/player/{id}/followers?type=Following` and `type=Followers` (paged by `page` + `count`). **`all` friends**: union of both lists (deduped).
- **`#friendScores`**: best accuracy per friend for the current map, sorted descending. - **`#friendScores`**: best accuracy per friend for the current map, sorted descending.

View File

@ -21,10 +21,12 @@ Configuration is `overlay.toml` in the repo root (copy from `overlay.toml.exampl
- `chat_request_database` — absolute path to Beat Saber Plus `ChatRequest/Database.json`. The server serves that file as `ChatRequest.json` over HTTP (no symlink). - `chat_request_database` — absolute path to Beat Saber Plus `ChatRequest/Database.json`. The server serves that file as `ChatRequest.json` over HTTP (no symlink).
- `beatleader_player_id` — default BeatLeader id for friend scores (the server logs a warning if this is missing). - `beatleader_player_id` — default BeatLeader id for friend scores (the server logs a warning if this is missing).
- Overlay UI defaults — optional toggles and layout matching the settings dialog: `cover`, `map_info`, `time`, `score`, `friends`, `friend_mode` (`mutual` \| `following` \| `followers`), `bsr`, `right`, `bottom`, `scale`, `fade`. - Overlay UI defaults — optional toggles and layout matching the settings dialog: `cover`, `map_info`, `time`, `score`, `friends`, `friend_mode` (`mutual` \| `following` \| `followers` \| `all`), `bsr`, `right`, `bottom`, `scale`, `fade`.
Mutual friend mode shows scores from players that you follow and that follow you back on Beatleader. Or in other words, users that are in both your following and follower list. Mutual friend mode shows scores from players that you follow and that follow you back on Beatleader. Or in other words, users that are in both your following and follower list.
Use `friend_mode = "all"` to include anyone you follow **or** anyone who follows you (union, deduped by player id).
## Usage ## Usage
Clone the repo and run `deno task serve` as above. Clone the repo and run `deno task serve` as above.

View File

@ -75,6 +75,7 @@
<option value="mutual">Followed + follower (mutual)</option> <option value="mutual">Followed + follower (mutual)</option>
<option value="following">Following (I follow them)</option> <option value="following">Following (I follow them)</option>
<option value="followers">Followers (they follow me)</option> <option value="followers">Followers (they follow me)</option>
<option value="all">All friends (following or followers)</option>
</select></label> </select></label>
<label id="beatLeaderPlayerSetting">Player id: <span class="beatLeaderPlayerRow"> <label id="beatLeaderPlayerSetting">Player id: <span class="beatLeaderPlayerRow">
<span class="debugSongIdHint">e.g. <button type="button" id="beatLeaderPlayerExample" title="Fill with pleb's numeric id">pleb</button></span> <span class="debugSongIdHint">e.g. <button type="button" id="beatLeaderPlayerExample" title="Fill with pleb's numeric id">pleb</button></span>

View File

@ -188,6 +188,23 @@ async function fetchFriends(playerId, mode, maxPages = 100) {
if (mode === "followers") { if (mode === "followers") {
return followers.map((entry) => normalizeFollowerEntry(entry)); return followers.map((entry) => normalizeFollowerEntry(entry));
} }
if (mode === "all") {
const seen = /* @__PURE__ */ new Set();
const out = [];
for (const entry of following) {
const id = String(entry.id);
if (seen.has(id)) continue;
seen.add(id);
out.push(normalizeFollowerEntry(entry));
}
for (const entry of followers) {
const id = String(entry.id);
if (seen.has(id)) continue;
seen.add(id);
out.push(normalizeFollowerEntry(entry));
}
return out;
}
return followers.filter((entry) => followingIds.has(String(entry.id))).map((entry) => normalizeFollowerEntry(entry)); return followers.filter((entry) => followingIds.has(String(entry.id))).map((entry) => normalizeFollowerEntry(entry));
} }
function normalizeAccuracy(value) { function normalizeAccuracy(value) {
@ -600,7 +617,7 @@ function beginFriendScoresForNewMapContext() {
friendScoresList.replaceChildren(); friendScoresList.replaceChildren();
friendScoresPanel.classList.remove("has-items"); friendScoresPanel.classList.remove("has-items");
friendScoresPanel.classList.add("is-loading"); friendScoresPanel.classList.add("is-loading");
friendScoresEmpty.textContent = "Loading mutual friend scores..."; friendScoresEmpty.textContent = "Loading friend scores...";
} }
async function refreshMapFriendScores() { async function refreshMapFriendScores() {
const hash = currentMapHash; const hash = currentMapHash;
@ -621,7 +638,7 @@ async function refreshMapFriendScores() {
friendScoresList.replaceChildren(); friendScoresList.replaceChildren();
friendScoresPanel.classList.remove("has-items"); friendScoresPanel.classList.remove("has-items");
friendScoresPanel.classList.add("is-loading"); friendScoresPanel.classList.add("is-loading");
friendScoresEmpty.textContent = "Loading mutual friend scores..."; friendScoresEmpty.textContent = "Loading friend scores...";
const requestId = ++friendScoreRequestId; const requestId = ++friendScoreRequestId;
try { try {
const relKey = friendsRelationListKey(playerId); const relKey = friendsRelationListKey(playerId);
@ -656,7 +673,7 @@ async function refreshMapFriendScores() {
])); ]));
const mutualFriendIds = new Set(friends.map((f) => f.id)); const mutualFriendIds = new Set(friends.map((f) => f.id));
if (mutualFriendIds.size === 0) { if (mutualFriendIds.size === 0) {
const relationLabel = settings.friendMode === "following" ? "No followed BeatLeader players" : settings.friendMode === "followers" ? "No BeatLeader followers" : "No mutual BeatLeader followers"; const relationLabel = settings.friendMode === "following" ? "No followed BeatLeader players" : settings.friendMode === "followers" ? "No BeatLeader followers" : settings.friendMode === "all" ? "No followed or follower BeatLeader players" : "No mutual BeatLeader followers";
clearFriendScores(relationLabel); clearFriendScores(relationLabel);
return; return;
} }

View File

@ -12,7 +12,7 @@ map_info = true
time = true time = true
score = true score = true
friends = true friends = true
friend_mode = "mutual" # mutual | following | followers friend_mode = "mutual" # mutual | following | followers | all
bsr = false bsr = false
right = false right = false
bottom = true bottom = true

View File

@ -196,6 +196,23 @@ export async function fetchFriends(playerId: string, mode: FriendMode, maxPages
if (mode === "followers") { if (mode === "followers") {
return followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); return followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
} }
if (mode === "all") {
const seen = new Set<string>();
const out: BeatLeaderFollower[] = [];
for (const entry of following) {
const id = String(entry.id);
if (seen.has(id)) continue;
seen.add(id);
out.push(normalizeFollowerEntry(entry as BeatLeaderFollower));
}
for (const entry of followers) {
const id = String(entry.id);
if (seen.has(id)) continue;
seen.add(id);
out.push(normalizeFollowerEntry(entry as BeatLeaderFollower));
}
return out;
}
return followers return followers
.filter((entry) => followingIds.has(String(entry.id))) .filter((entry) => followingIds.has(String(entry.id)))
.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); .map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));

View File

@ -484,7 +484,7 @@ function beginFriendScoresForNewMapContext() {
friendScoresList.replaceChildren(); friendScoresList.replaceChildren();
friendScoresPanel.classList.remove("has-items"); friendScoresPanel.classList.remove("has-items");
friendScoresPanel.classList.add("is-loading"); friendScoresPanel.classList.add("is-loading");
friendScoresEmpty.textContent = "Loading mutual friend scores..."; friendScoresEmpty.textContent = "Loading friend scores...";
} }
async function refreshMapFriendScores() { async function refreshMapFriendScores() {
@ -506,7 +506,7 @@ async function refreshMapFriendScores() {
friendScoresList.replaceChildren(); friendScoresList.replaceChildren();
friendScoresPanel.classList.remove("has-items"); friendScoresPanel.classList.remove("has-items");
friendScoresPanel.classList.add("is-loading"); friendScoresPanel.classList.add("is-loading");
friendScoresEmpty.textContent = "Loading mutual friend scores..."; friendScoresEmpty.textContent = "Loading friend scores...";
const requestId = ++friendScoreRequestId; const requestId = ++friendScoreRequestId;
try { try {
const relKey = friendsRelationListKey(playerId); const relKey = friendsRelationListKey(playerId);
@ -542,6 +542,8 @@ async function refreshMapFriendScores() {
? "No followed BeatLeader players" ? "No followed BeatLeader players"
: settings.friendMode === "followers" : settings.friendMode === "followers"
? "No BeatLeader followers" ? "No BeatLeader followers"
: settings.friendMode === "all"
? "No followed or follower BeatLeader players"
: "No mutual BeatLeader followers"; : "No mutual BeatLeader followers";
clearFriendScores(relationLabel); clearFriendScores(relationLabel);
return; return;

View File

@ -1,4 +1,4 @@
import { assert } from "jsr:@std/assert"; import { assert, assertGreaterOrEqual } from "jsr:@std/assert";
import { fetchFriends } from "./beatleader.ts"; import { fetchFriends } from "./beatleader.ts";
const PLAYER_ID = "76561199407393962"; const PLAYER_ID = "76561199407393962";
@ -12,3 +12,20 @@ Deno.test({
assert(mutuals.length > 0, `Expected mutual friends for player ${PLAYER_ID}`); assert(mutuals.length > 0, `Expected mutual friends for player ${PLAYER_ID}`);
}, },
}); });
Deno.test({
name: "live lookup: all friend mode is superset of mutual",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const [mutuals, allFriends] = await Promise.all([
fetchFriends(PLAYER_ID, "mutual", 100),
fetchFriends(PLAYER_ID, "all", 100),
]);
assertGreaterOrEqual(allFriends.length, mutuals.length, "Expected union count >= mutual count");
const allIds = new Set(allFriends.map((f) => f.id));
for (const m of mutuals) {
assert(allIds.has(m.id), `Expected mutual id ${m.id} in all friend list`);
}
},
});

View File

@ -21,7 +21,7 @@ export interface OverlayConfigApiBody {
defaults: Partial<OverlaySettings>; defaults: Partial<OverlaySettings>;
} }
const FRIEND_MODES = new Set<FriendMode>(["mutual", "following", "followers"]); const FRIEND_MODES = new Set<FriendMode>(["mutual", "following", "followers", "all"]);
/** Merge `/api/overlay-config` into the object used as `defaults` before applying the URL hash. */ /** Merge `/api/overlay-config` into the object used as `defaults` before applying the URL hash. */
export function mergeOverlayConfigResponse( export function mergeOverlayConfigResponse(
@ -84,7 +84,7 @@ export function overlayTomlToDefaults(toml: OverlayToml): {
if (FRIEND_MODES.has(raw as FriendMode)) defaults.friendMode = raw as FriendMode; if (FRIEND_MODES.has(raw as FriendMode)) defaults.friendMode = raw as FriendMode;
else { else {
warnings.push( warnings.push(
`overlay.toml: invalid friend_mode "${toml.friend_mode}" (expected mutual, following, or followers)`, `overlay.toml: invalid friend_mode "${toml.friend_mode}" (expected mutual, following, followers, or all)`,
); );
} }
} }

View File

@ -1,6 +1,6 @@
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay // https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
export type FriendMode = "mutual" | "following" | "followers"; export type FriendMode = "mutual" | "following" | "followers" | "all";
/** Overlay UI + BeatLeader id (URL hash overrides defaults from overlay.toml / `/api/overlay-config`). */ /** Overlay UI + BeatLeader id (URL hash overrides defaults from overlay.toml / `/api/overlay-config`). */
export interface OverlaySettings { export interface OverlaySettings {