diff --git a/src/App.svelte b/src/App.svelte
index 10d8aa8..ada0b01 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -28,6 +28,7 @@
import UploadToast from "./components/UploadToast.svelte";
import ProposalToast from "./components/ProposalToast.svelte";
import FollowNewsToast from "./components/FollowNewsToast.svelte";
+ import WhoHasSheet from "./components/WhoHasSheet.svelte";
import SetlistScanChip from "./components/SetlistScanChip.svelte";
import {
ui,
@@ -188,6 +189,7 @@
+
diff --git a/src/components/WhoHas.svelte b/src/components/WhoHas.svelte
new file mode 100644
index 0000000..cdbe265
--- /dev/null
+++ b/src/components/WhoHas.svelte
@@ -0,0 +1,196 @@
+
+
+
+ {#if asking && holders === null}
+
Asking your rooms…
+ {:else if !holders?.length}
+
+
+ {community.spaces.length ? "Nobody in your rooms has published this one." : "You are in no rooms yet — holdings are asked room by room."}
+
+ {:else}
+ {#each rooms as r (r.key)}
+
{r.name}{r.holders.length} of the room
+ {#each r.holders as h (h.member)}
+ {@const u = h.username}
+
+
+
+ {u ? display(u) : fpShort(h.member)}{#if u}Soulseek{/if}
+ {line(h)}
+ {#if failed[u]}{failed[u]}{/if}
+
+
+ {#if tierWord(h.tier)}{tierWord(h.tier)}{/if}
+ {#if !u}
+
+ {:else if grabbed[u] != null}
+ {grabbed[u]} {grabbed[u] === 1 ? "file" : "files"} queued
+ {:else if waitingOn(u, artist, album)}
+ waiting
+
+ {:else if failed[u]}
+
+
+ {:else}
+
+ {/if}
+
+
+ {/each}
+ {/each}
+
+ {/if}
+
+
+
diff --git a/src/components/WhoHasSheet.svelte b/src/components/WhoHasSheet.svelte
new file mode 100644
index 0000000..0e447b7
Binary files /dev/null and b/src/components/WhoHasSheet.svelte differ
diff --git a/src/lib/api.js b/src/lib/api.js
index 3ebe5b7..00fa487 100644
--- a/src/lib/api.js
+++ b/src/lib/api.js
@@ -196,6 +196,14 @@ export const spaceBoard = (owner, space) => invoke("space_board", { owner, space
export const spacePropose = (owner, space, base, ops, note) => invoke("space_propose", { owner, space, base, ops, note });
export const spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason });
export const overlapList = () => invoke("overlap_list");
+export const holdingsSetShare = (owner, space, on) => invoke("holdings_set_share", { owner, space, on });
+export const holdingsRoom = (owner, space, refresh = false) => invoke("holdings_room", { owner, space, refresh });
+export const holdingsAsk = (items) => invoke("holdings_ask", { items });
+export const holdingsFindFolder = (username, artist, album) => invoke("holdings_find_folder", { username, artist, album });
+export const holdingsRepublish = () => invoke("holdings_republish");
+export const holdingsWaits = () => invoke("holdings_waits");
+export const holdingsWaitAdd = (username, artist, album, via = null) => invoke("holdings_wait_add", { username, artist, album, via });
+export const holdingsWaitRemove = (username, artist, album) => invoke("holdings_wait_remove", { username, artist, album });
export const overlapCompute = (username) => invoke("overlap_compute", { username });
export const overlapForget = (username) => invoke("overlap_forget", { username });
export const buddyList = () => invoke("buddy_list");
diff --git a/src/lib/community.svelte.js b/src/lib/community.svelte.js
index bbf6315..e33012f 100644
--- a/src/lib/community.svelte.js
+++ b/src/lib/community.svelte.js
@@ -91,6 +91,11 @@ export const community = $state({
boardError: "",
/** overlap_list: library comparisons computed on this device, best first. */
overlap: [],
+ /** `owner:space` → holdings_room: who in that room publishes what they
+ * hold, and whether this device does. Read when a Space opens. */
+ holdings: {},
+ /** holdings_waits: albums to grab from someone the moment they come online. */
+ waits: [],
/** persona key → ProfileView | {none} | {error} | {pending}: other
* people's signed profiles, read once per session when surfaced. */
remoteProfiles: {},
@@ -350,6 +355,18 @@ function wireEvents() {
void refreshFollowing();
});
api.onEvent("community:overlap", () => void refreshOverlap());
+ api.onEvent("community:holdings", () => {
+ // A read finished, a publish landed, a wait changed: re-read the
+ // open room's lists from the cache (fresh members cost no network).
+ if (community.space) void refreshHoldings(community.space.owner, community.space.space_id);
+ void refreshWaits();
+ });
+ // The library changed: every room with the switch on gets the new
+ // list, coalesced so a scan's burst of changes is one publish.
+ api.onEvent("soulseek:library-changed", () => {
+ clearTimeout(republishTimer);
+ republishTimer = setTimeout(() => void api.holdingsRepublish().catch(() => {}), REPUBLISH_DEBOUNCE_MS);
+ });
api.onEvent("community:proposal", (p) => {
announceProposal(p.from, p.playlist);
// If the board is already on screen, show what it is talking about.
@@ -357,6 +374,8 @@ function wireEvents() {
});
api.onEvent("soulseek:user-state", (p) => {
if (p.status === 1 || p.status === 2) noteSeen(p.username);
+ // Someone we are waiting on came online: the grab runs now.
+ if (p.status === 2) void drainWaits(p.username);
});
}
}
@@ -364,7 +383,7 @@ function wireEvents() {
/** Idempotent: pulls the state the Community page draws. */
export async function initCommunity() {
wireEvents();
- await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshFollowing(), refreshInbox(), refreshSpaces(), refreshOverlap()]);
+ await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshFollowing(), refreshInbox(), refreshSpaces(), refreshOverlap(), refreshWaits()]);
await refreshPersona();
}
@@ -379,6 +398,9 @@ export async function initCommunity() {
* that comes up later — or an inbox published later — simply starts working
* without anything having to restart the timer. */
const INBOX_POLL_MS = 90_000;
+/** A library scan raises many change events in a row; one publish serves them all. */
+const REPUBLISH_DEBOUNCE_MS = 30_000;
+let republishTimer = 0;
/** Followed playlists are read far less often: every pointer read is a ring
* search that can take seconds, and an owner's next revision is a matter
* of days, not minutes. */
@@ -705,6 +727,67 @@ export async function refreshSpaces() {
}
}
/** Read a Space through the node; the view is what the checkpoint says. */
+// ---- holdings: who has this, and get it from them ---------------------------------
+const holdingsId = (owner, space) => `${owner}:${space}`;
+/** The room's lists as the cache has them; the backend reads stale members
+ * in the background and the event brings the result back here. */
+export async function refreshHoldings(owner, space, refresh = false) {
+ try {
+ community.holdings[holdingsId(owner, space)] = await api.holdingsRoom(owner, space, refresh);
+ } catch {
+ // stays as it was
+ }
+}
+export const holdingsFor = (owner, space) => community.holdings[holdingsId(owner, space)] ?? null;
+/** The switch: publish this device's list into the room, or withdraw it. */
+export const setShareHoldings = (owner, space, on) =>
+ run("holdings:share", async () => {
+ community.holdings[holdingsId(owner, space)] = await api.holdingsSetShare(owner, space, on);
+ if (community.space?.owner === owner && community.space?.space_id === space) await openSpace(owner, space);
+ });
+/** Who holds each (artist, album), across every room — local, instant. */
+export const askHoldings = (items) => api.holdingsAsk(items);
+/** Get it: browse the holder, find the folder the room said they hold, and
+ * queue every audio file through the hub's own download — the transfers
+ * page shows them like any album grab. */
+export const holdingsGrab = (username, artist, album) =>
+ run(`grab:${username}:${album}`, async () => {
+ const m = await api.holdingsFindFolder(username, artist, album);
+ if (!m) throw new Error(`${username}'s share does not name ${album} any more`);
+ for (const f of m.files) await api.downloadFile(username, f.path, f.size);
+ return m;
+ });
+export async function refreshWaits() {
+ try {
+ community.waits = await api.holdingsWaits();
+ } catch {
+ // stays as it was
+ }
+}
+export const waitFor = (username, artist, album, via = null) =>
+ run(`wait:${username}:${album}`, async () => {
+ community.waits = await api.holdingsWaitAdd(username, artist, album, via);
+ });
+export const forgetWait = (username, artist, album) =>
+ run(`wait:${username}:${album}`, async () => {
+ community.waits = await api.holdingsWaitRemove(username, artist, album);
+ });
+export const waitingOn = (username, artist, album) =>
+ community.waits.some((w) => w.username.toLowerCase() === (username ?? "").toLowerCase() && w.artist === artist && w.album === album);
+/** A waited-on person came online: run their grabs, each once. */
+async function drainWaits(username) {
+ const mine = community.waits.filter((w) => w.username.toLowerCase() === (username ?? "").toLowerCase());
+ for (const w of mine) {
+ try {
+ await holdingsGrab(w.username, w.artist, w.album);
+ await forgetWait(w.username, w.artist, w.album);
+ } catch {
+ // their share no longer names it, or the browse failed: the wait
+ // stays, and the row says what happened when it is looked at
+ }
+ }
+}
+
export async function openSpace(owner, space) {
community.spaceError = "";
community.busy["space:" + space] = true;
@@ -814,4 +897,4 @@ export const decideProposal = (owner, space, proposal, accept, reason) =>
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
// through this, never through a bare import (which would be a second copy).
if (import.meta.env.DEV) globalThis.__community = community;
-if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, refreshFollowing, followFor, followNews, checkFollows, followUpdate, unfollow, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest };
+if (import.meta.env.DEV) globalThis.__communityActions = { refreshHoldings, holdingsFor, setShareHoldings, askHoldings, holdingsGrab, refreshWaits, waitFor, forgetWait, waitingOn, friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, refreshFollowing, followFor, followNews, checkFollows, followUpdate, unfollow, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest };
diff --git a/src/lib/whoHas.svelte.js b/src/lib/whoHas.svelte.js
new file mode 100644
index 0000000..2c9b38b
Binary files /dev/null and b/src/lib/whoHas.svelte.js differ
diff --git a/src/views/CatalogAlbum.svelte b/src/views/CatalogAlbum.svelte
index 53ed647..5cba703 100644
--- a/src/views/CatalogAlbum.svelte
+++ b/src/views/CatalogAlbum.svelte
@@ -30,6 +30,8 @@
import { listenerPeak, listenerShare } from "../lib/listenerBars.js";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import Icon from "../components/Icon.svelte";
+ import WhoHas from "../components/WhoHas.svelte";
+ import { community } from "../lib/community.svelte.js";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
@@ -887,6 +889,13 @@
{/if}
+ {#if !inLibrary && community.spaces.length}
+
+ Who has this
from your rooms' holdings · names compared, never files
+ {#key album.artist + "\u0000" + album.title}{/key}
+
+ {/if}
+
{#if tracks.length}
@@ -1976,4 +1985,8 @@
:global(:root.glass) .stat-note {
border-color: var(--glass-hairline);
}
+ .whohas-sec { margin: 8px 0 18px; padding: 12px 14px; border-radius: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.07); }
+ .whohas-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; }
+ .whohas-head h2 { margin: 0; font-size: 15px; font-weight: 800; }
+ .whohas-head .p { font-size: 11.5px; color: var(--text-muted); }
diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte
index 088730f..49c17bf 100644
--- a/src/views/CatalogArtist.svelte
+++ b/src/views/CatalogArtist.svelte
@@ -52,6 +52,8 @@
import { openPreview } from "../lib/preview/previewSession.svelte.js";
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
import Icon from "../components/Icon.svelte";
+ import WhoHas from "../components/WhoHas.svelte";
+ import { openWhoHas, holdersFor, pairKey, holderCount } from "../lib/whoHas.svelte.js";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
@@ -2536,6 +2538,17 @@
});
const visibleTabs = $derived(tabs.filter((tab) => tab.key === activeTabKey));
+ // Who in your rooms holds a release you lack: asked for the visible tab's
+ // families at once, filed by pairKey; the count rides the card, and the
+ // open album shows the people.
+ let heldBy = $state({});
+ const heldFor = (title) => (page ? heldBy[pairKey(page.artist.name, title)] : null);
+ $effect(() => {
+ const name = page?.artist?.name;
+ const items = visibleTabs.flatMap((g) => g.items).filter((al) => !inLibrary(al)).map((al) => ({ artist: name, album: al.title }));
+ if (!name || !items.length) return;
+ untrack(() => void holdersFor(items).then((m) => { for (const [k, v] of m) heldBy[k] = v; }));
+ });
// FLIP for the stage-2 correction. Svelte's `animate:` directive can't be
// used here — it requires the animated element to be the ONLY child of the
@@ -5399,6 +5412,9 @@
onclick={(e) => { e.stopPropagation(); streamRelease(al, al); }}
>
{/if}
+ {#if heldFor(al.title)?.length}
+
+ {/if}
{al.title}
@@ -5565,6 +5581,12 @@
{/if}
+ {#if openAlbum && !inLibrary(openAlbum) && heldFor(openAlbum.title)?.length}
+
+
Who has thisfrom your rooms' holdings · names compared, never files
+ {#key openAlbum.id}
{/key}
+
+ {/if}
{#if openLoading}