diff --git a/package.json b/package.json index 3e46da8..ab95191 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/lib/discover.js b/src/lib/discover.js new file mode 100644 index 0000000..9db8b1e --- /dev/null +++ b/src/lib/discover.js @@ -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} [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(""); diff --git a/src/views/Community.svelte b/src/views/Community.svelte index 0903e78..18cf53d 100644 --- a/src/views/Community.svelte +++ b/src/views/Community.svelte @@ -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 @@ {:else if community.section === "discover"} +
+
+

Closest to you

+ the shelves below, scored together · every reason named +
+ {#if ranked.length} +
+ {#each ranked as p (p.username)} + + {/each} +
+ {:else} +
Nothing to rank yet. This shelf is the ones below added up — join a room, browse someone, declare a like — with contacts and friends left off.
+ {/if} +

{slsk.activeRoom ? `In #${slsk.activeRoom} right now` : "In a room right now"}

diff --git a/tests/discover/rank.test.mjs b/tests/discover/rank.test.mjs new file mode 100644 index 0000000..103e373 --- /dev/null +++ b/tests/discover/rank.test.mjs @@ -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 }, + ]); +});