forked from Keelrleek/SoulStream
community: Discover ranks the shelves it already has into one, and every card still says where it came from
This commit is contained in:
1 parent
8285b97cbe
commit
08ca09586a
4 files changed
+288
-1
No files matched your search
+1
-1
@@ -7,7 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"tauri": "tauri",
|
||||
"test": "node --test \"tests/preview/*.test.mjs\""
|
||||
"test": "node --test \"tests/preview/*.test.mjs\" \"tests/discover/*.test.mjs\""
|
||||
},
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Discover's ranked shelf: one list across the signals the page already
|
||||
// draws in silos — the room you are in, the people your transfers touched,
|
||||
// the server's similar-interests answer, the library overlaps computed on
|
||||
// this device, the roster of the Space you have open. Nothing here reads
|
||||
// the network; every input is a store the page holds already.
|
||||
//
|
||||
// The page's rule holds: every shelf says where its people came from. A
|
||||
// ranked card is not exempt — its `reasons` are the shelves it drew from,
|
||||
// strongest first, and the template names each one. Contacts and friends
|
||||
// are left out: this is for people you have not sorted yet.
|
||||
//
|
||||
// The weights are the whole opinion, in one place so they can be argued
|
||||
// with. Each signal is capped so one huge browse cannot bury everything.
|
||||
|
||||
export const WEIGHTS = {
|
||||
/** Per album you both own (overlap), up to `CAPS.albums`. */
|
||||
albumInCommon: 3,
|
||||
/** Per album of theirs by an artist you follow that you lack. */
|
||||
gap: 1,
|
||||
/** Per artist in common, only when no album is (a weaker overlap). */
|
||||
artistInCommon: 0.5,
|
||||
/** Per declared like the server says you share. */
|
||||
like: 2,
|
||||
/** In the room you are looking at right now. */
|
||||
activeRoom: 2,
|
||||
/** Per other joined room they are in. */
|
||||
room: 1,
|
||||
/** Per file, either direction, this session. */
|
||||
transfer: 1,
|
||||
/** A seat beside yours in the open Space. */
|
||||
spaceMember: 3,
|
||||
};
|
||||
export const CAPS = { albums: 10, gaps: 10, artists: 10, rooms: 3, transfers: 5 };
|
||||
|
||||
const cap = (n, max) => Math.min(Math.max(n ?? 0, 0), max);
|
||||
|
||||
/**
|
||||
* @param {object} input
|
||||
* @param {string} input.me our own username (never listed)
|
||||
* @param {(u: string) => boolean} [input.skip] contacts, friends — people already sorted
|
||||
* @param {Record<string, {users: {username: string}[]}>} [input.rooms] joined rooms by name
|
||||
* @param {string|null} [input.activeRoom]
|
||||
* @param {{username?: string}[]} [input.downloads] this session's downloads
|
||||
* @param {{username?: string}[]} [input.uploads] this session's uploads
|
||||
* @param {{username: string, rating: number}[]} [input.similar]
|
||||
* @param {{username: string, albums_in_common_count?: number, gaps_count?: number, artists_in_common_count?: number}[]} [input.overlap]
|
||||
* @param {{name: string, members: {username?: string|null, is_me?: boolean}[]}|null} [input.space] the open Space
|
||||
* @param {number} [input.limit]
|
||||
* @returns {{username: string, score: number, reasons: object[]}[]} best first
|
||||
*/
|
||||
export function rankPeople(input) {
|
||||
const { me = "", skip = () => false, limit = 12 } = input;
|
||||
const people = new Map();
|
||||
const at = (u) => {
|
||||
if (!u || u === me || skip(u)) return null;
|
||||
let p = people.get(u);
|
||||
if (!p) people.set(u, (p = { username: u, score: 0, reasons: [] }));
|
||||
return p;
|
||||
};
|
||||
const add = (u, points, reason) => {
|
||||
const p = at(u);
|
||||
if (!p || points <= 0) return;
|
||||
p.score += points;
|
||||
p.reasons.push({ ...reason, points });
|
||||
};
|
||||
|
||||
for (const o of input.overlap ?? []) {
|
||||
const albums = cap(o.albums_in_common_count, CAPS.albums);
|
||||
const gaps = cap(o.gaps_count, CAPS.gaps);
|
||||
const artists = albums ? 0 : cap(o.artists_in_common_count, CAPS.artists);
|
||||
const points = albums * WEIGHTS.albumInCommon + gaps * WEIGHTS.gap + artists * WEIGHTS.artistInCommon;
|
||||
add(o.username, points, {
|
||||
kind: "overlap",
|
||||
albums: o.albums_in_common_count ?? 0,
|
||||
gaps: o.gaps_count ?? 0,
|
||||
artists: o.artists_in_common_count ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const su of input.similar ?? []) {
|
||||
add(su.username, (su.rating ?? 0) * WEIGHTS.like, { kind: "likes", n: su.rating ?? 0 });
|
||||
}
|
||||
|
||||
// Rooms: the one on screen counts most; the rest count once each.
|
||||
const roomsOf = new Map();
|
||||
for (const [name, room] of Object.entries(input.rooms ?? {})) {
|
||||
for (const u of room?.users ?? []) {
|
||||
if (!u?.username) continue;
|
||||
if (!roomsOf.has(u.username)) roomsOf.set(u.username, []);
|
||||
roomsOf.get(u.username).push(name);
|
||||
}
|
||||
}
|
||||
for (const [u, names] of roomsOf) {
|
||||
const active = input.activeRoom && names.includes(input.activeRoom) ? input.activeRoom : null;
|
||||
const others = names.filter((n) => n !== active);
|
||||
const points = (active ? WEIGHTS.activeRoom : 0) + cap(others.length, CAPS.rooms) * WEIGHTS.room;
|
||||
add(u, points, { kind: "rooms", active, others });
|
||||
}
|
||||
|
||||
const tally = new Map();
|
||||
const count = (u, side) => {
|
||||
if (!u) return;
|
||||
if (!tally.has(u)) tally.set(u, { from: 0, to: 0 });
|
||||
tally.get(u)[side] += 1;
|
||||
};
|
||||
for (const d of input.downloads ?? []) count(d?.username, "from");
|
||||
for (const x of input.uploads ?? []) count(x?.username, "to");
|
||||
for (const [u, t] of tally) {
|
||||
add(u, cap(t.from + t.to, CAPS.transfers) * WEIGHTS.transfer, { kind: "transfers", from: t.from, to: t.to });
|
||||
}
|
||||
|
||||
const space = input.space;
|
||||
for (const m of space?.members ?? []) {
|
||||
if (!m?.username || m.is_me) continue;
|
||||
add(m.username, WEIGHTS.spaceMember, { kind: "space", name: space.name });
|
||||
}
|
||||
|
||||
const out = [...people.values()].filter((p) => p.score > 0);
|
||||
// Reasons strongest first; a tie keeps the order the signals were read
|
||||
// in above, which is the order of how much each one says about taste.
|
||||
for (const p of out) p.reasons.sort((a, b) => b.points - a.points);
|
||||
out.sort((a, b) => b.score - a.score || a.reasons.length - b.reasons.length || a.username.localeCompare(b.username));
|
||||
return out.slice(0, limit);
|
||||
}
|
||||
|
||||
/** A reason as the card draws it: segments, the numbers and names bold.
|
||||
* The wording is each source shelf's own, so a reason reads the same here
|
||||
* as where it came from. */
|
||||
export function reasonParts(r) {
|
||||
const b = (t) => ({ t: String(t), b: true });
|
||||
const t = (s) => ({ t: s, b: false });
|
||||
const n = (k, one, many) => [b(k), t(` ${k === 1 ? one : many}`)];
|
||||
switch (r.kind) {
|
||||
case "overlap":
|
||||
if (r.albums) return [t("shares "), ...n(r.albums, "album", "albums"), t(" you own"), ...(r.gaps ? [t(" · "), b(r.gaps), t(" you lack")] : [])];
|
||||
if (r.artists) return [...n(r.artists, "artist", "artists"), t(" in common"), ...(r.gaps ? [t(" · "), b(r.gaps), t(" albums you lack")] : [])];
|
||||
return [t("browsed, nothing in common")];
|
||||
case "likes":
|
||||
return [t("shares "), b(r.n), t(" of your declared likes")];
|
||||
case "rooms": {
|
||||
const parts = [];
|
||||
if (r.active) parts.push(t("in "), b(`#${r.active}`), t(" now"));
|
||||
if (r.others.length) {
|
||||
if (parts.length) parts.push(t(" · "));
|
||||
if (r.others.length === 1) parts.push(t("also in "), b(`#${r.others[0]}`));
|
||||
else parts.push(t("in "), b(r.others.length), t(" rooms with you"));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
case "transfers": {
|
||||
const parts = [];
|
||||
if (r.from) parts.push(t("sent you "), ...n(r.from, "file", "files"));
|
||||
if (r.from && r.to) parts.push(t(" · "));
|
||||
if (r.to) parts.push(t("you sent them "), b(r.to));
|
||||
return parts;
|
||||
}
|
||||
case "space":
|
||||
return [t("with you in "), b(r.name)];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** A reason in words — what a test reads, and what the card says. */
|
||||
export const reasonText = (r) => reasonParts(r).map((s) => s.t).join("");
|
||||
@@ -13,6 +13,7 @@
|
||||
import { onMount, untrack } from "svelte";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import Identicon, { hueOf } from "../components/Identicon.svelte";
|
||||
import { rankPeople, reasonParts } from "../lib/discover.js";
|
||||
import Soulseek from "./Soulseek.svelte";
|
||||
import * as api from "../lib/api.js";
|
||||
import {
|
||||
@@ -362,6 +363,22 @@
|
||||
}
|
||||
|
||||
// ---- discover ------------------------------------------------------------
|
||||
// The ranked shelf: the four silos below scored as one list, each card
|
||||
// naming the shelves it drew from. Contacts and friends are left off —
|
||||
// they have shelves of their own; this one is for people not yet sorted.
|
||||
const ranked = $derived.by(() =>
|
||||
rankPeople({
|
||||
me,
|
||||
skip: (u) => isContact(u) || friendshipFor(u)?.state === "friends",
|
||||
rooms: slsk.joined,
|
||||
activeRoom: slsk.activeRoom,
|
||||
downloads: Object.values(downloads.items),
|
||||
uploads: Object.values(network.uploads),
|
||||
similar: community.similar?.users ?? [],
|
||||
overlap: community.overlap,
|
||||
space: community.space ? { name: community.space.name, members: community.space.members } : null,
|
||||
}),
|
||||
);
|
||||
const roomPeople = $derived.by(() => {
|
||||
if (!activeRoom) return [];
|
||||
return activeRoom.users
|
||||
@@ -1173,6 +1190,25 @@
|
||||
|
||||
<!-- ── DISCOVER ───────────────────────────────────────────────────── -->
|
||||
{:else if community.section === "discover"}
|
||||
<div class="shelf">
|
||||
<div class="ax-head">
|
||||
<h2 class="ax-title">Closest to you</h2>
|
||||
<span class="prov"><Icon name="sparkle" size={12} /> the shelves below, scored together · every reason named</span>
|
||||
</div>
|
||||
{#if ranked.length}
|
||||
<div class="track">
|
||||
{#each ranked as p (p.username)}
|
||||
<button class="pcard" onclick={() => (community.drawer = p.username)}>
|
||||
<span class="face" style:background="hsl({hueOf(p.username)} 30% 14%)"><Identicon seed={p.username} size={84} presence={presenceOf(p.username)} /></span>
|
||||
<span class="nm">{display(p.username)}</span>
|
||||
<span class="why">{#each p.reasons as r, i}{#if i}{" · "}{/if}{#each reasonParts(r) as seg}{#if seg.b}<b>{seg.t}</b>{:else}{seg.t}{/if}{/each}{/each}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="emptyshelf"><Icon name="sparkle" size={22} /><div><b>Nothing to rank yet.</b> This shelf is the ones below added up — join a room, browse someone, declare a like — with contacts and friends left off.</div></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="shelf">
|
||||
<div class="ax-head">
|
||||
<h2 class="ax-title">{slsk.activeRoom ? `In #${slsk.activeRoom} right now` : "In a room right now"}</h2>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { rankPeople, reasonText, reasonParts, WEIGHTS, CAPS } from "../../src/lib/discover.js";
|
||||
|
||||
const overlapOf = (username, albums, gaps = 0, artists = 0) => ({
|
||||
username,
|
||||
albums_in_common_count: albums,
|
||||
gaps_count: gaps,
|
||||
artists_in_common_count: artists,
|
||||
});
|
||||
|
||||
test("one list across the shelves, every card naming where it came from", () => {
|
||||
const ranked = rankPeople({
|
||||
me: "zoocone",
|
||||
rooms: {
|
||||
ambient: { users: [{ username: "aphex" }, { username: "zoocone" }, { username: "boc" }] },
|
||||
idm: { users: [{ username: "boc" }] },
|
||||
},
|
||||
activeRoom: "ambient",
|
||||
downloads: [{ username: "aphex" }, { username: "aphex" }],
|
||||
uploads: [{ username: "autechre" }],
|
||||
similar: [{ username: "boc", rating: 3 }, { username: "aphex", rating: 1 }],
|
||||
overlap: [overlapOf("autechre", 4, 2), overlapOf("aphex", 0, 0, 0)],
|
||||
space: { name: "Late Night Ambient", members: [{ username: "boc" }, { username: null }, { username: "zoocone", is_me: true }] },
|
||||
});
|
||||
assert.deepEqual(
|
||||
ranked.map((p) => p.username),
|
||||
["autechre", "boc", "aphex"],
|
||||
);
|
||||
const [autechre, boc, aphex] = ranked;
|
||||
// autechre: 4 albums in common + 2 gaps, one upload
|
||||
assert.equal(autechre.score, 4 * WEIGHTS.albumInCommon + 2 * WEIGHTS.gap + WEIGHTS.transfer);
|
||||
assert.deepEqual(autechre.reasons.map(reasonText), ["shares 4 albums you own · 2 you lack", "you sent them 1"]);
|
||||
// boc: 3 likes, in the active room and one other, a seat in the Space
|
||||
assert.equal(boc.score, 3 * WEIGHTS.like + WEIGHTS.activeRoom + WEIGHTS.room + WEIGHTS.spaceMember);
|
||||
assert.deepEqual(boc.reasons.map(reasonText), [
|
||||
"shares 3 of your declared likes",
|
||||
"in #ambient now · also in #idm",
|
||||
"with you in Late Night Ambient",
|
||||
]);
|
||||
// aphex: a browse with nothing in common scores nothing and is not a
|
||||
// reason; the three that remain tie at two points each and keep the
|
||||
// signal order (likes, rooms, transfers)
|
||||
assert.equal(aphex.score, WEIGHTS.activeRoom + 2 * WEIGHTS.transfer + WEIGHTS.like);
|
||||
assert.deepEqual(aphex.reasons.map(reasonText), ["shares 1 of your declared likes", "in #ambient now", "sent you 2 files"]);
|
||||
// we never list ourselves, and a member without a Soulseek name has no card
|
||||
assert.ok(!ranked.some((p) => p.username === "zoocone"));
|
||||
});
|
||||
|
||||
test("contacts and friends are someone else's shelf, and a cap keeps one browse honest", () => {
|
||||
const ranked = rankPeople({
|
||||
me: "zoocone",
|
||||
skip: (u) => u === "buddy",
|
||||
overlap: [overlapOf("buddy", 40, 40), overlapOf("hoarder", 40, 40, 40), overlapOf("thin", 0, 3, 2)],
|
||||
similar: [{ username: "buddy", rating: 9 }],
|
||||
});
|
||||
assert.deepEqual(ranked.map((p) => p.username), ["hoarder", "thin"]);
|
||||
assert.equal(ranked[0].score, CAPS.albums * WEIGHTS.albumInCommon + CAPS.gaps * WEIGHTS.gap);
|
||||
// artists only count when no album is in common
|
||||
assert.equal(ranked[1].score, 3 * WEIGHTS.gap + 2 * WEIGHTS.artistInCommon);
|
||||
assert.equal(reasonText(ranked[1].reasons[0]), "2 artists in common · 3 albums you lack");
|
||||
});
|
||||
|
||||
test("ties break toward fewer, stronger reasons, then by name; the limit holds", () => {
|
||||
const ranked = rankPeople({
|
||||
me: "",
|
||||
similar: [{ username: "b", rating: 2 }, { username: "a", rating: 2 }],
|
||||
downloads: [{ username: "c" }, { username: "c" }, { username: "c" }, { username: "c" }],
|
||||
limit: 2,
|
||||
});
|
||||
// c: 4 transfers = 4 points; a and b: 2 likes = 4 points each, one reason each
|
||||
assert.deepEqual(ranked.map((p) => p.username), ["a", "b"]);
|
||||
assert.deepEqual(rankPeople({ me: "", similar: [] }), []);
|
||||
assert.equal(reasonText({ kind: "rooms", active: null, others: ["x", "y"] }), "in 2 rooms with you");
|
||||
// the numbers and names are what the card bolds
|
||||
assert.deepEqual(
|
||||
reasonParts({ kind: "transfers", from: 2, to: 1 }).filter((s) => s.b).map((s) => s.t),
|
||||
["2", "1"],
|
||||
);
|
||||
assert.deepEqual(reasonParts({ kind: "rooms", active: "idm", others: [] }), [
|
||||
{ t: "in ", b: false },
|
||||
{ t: "#idm", b: true },
|
||||
{ t: " now", b: false },
|
||||
]);
|
||||
});
|
||||
Reference in new issue
Block a user