From b8e6fb73961c230000464682e227356b4df674c9 Mon Sep 17 00:00:00 2001
From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com>
Date: Tue, 8 Sep 2026 12:10:49 -0500
Subject: [PATCH] who has this: a gap card says who holds it, the sheet and the
open album name them, a Space grows a Holdings tab, and Get it is the hub's
own grab or a wait for their presence
---
src/App.svelte | 2 +
src/components/WhoHas.svelte | 196 ++++++++++++++++++++++++++++++
src/components/WhoHasSheet.svelte | Bin 0 -> 5340 bytes
src/lib/api.js | 8 ++
src/lib/community.svelte.js | 87 ++++++++++++-
src/lib/whoHas.svelte.js | Bin 0 -> 2158 bytes
src/views/CatalogAlbum.svelte | 13 ++
src/views/CatalogArtist.svelte | 29 +++++
src/views/Community.svelte | 73 +++++++++++
src/views/Library.svelte | 29 ++++-
src/views/Soulseek.svelte | 23 ++++
11 files changed, 454 insertions(+), 6 deletions(-)
create mode 100644 src/components/WhoHas.svelte
create mode 100644 src/components/WhoHasSheet.svelte
create mode 100644 src/lib/whoHas.svelte.js
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 @@
Qs*D;Px22sZKV9EN#S~ivUSkrtcU- zp3)0pLL=?Qlysm}*4F5|1&zkEydh>78CmG9#KO@ZIC={wPoL6P9(Wu_P6Rj%aKIZ7 zgzy@TSXfQU;15zlhbHz2 zc&V*Sqxf#*_3cgf&K96*BIJ{$H`x 6=FZ~LBF68 B-@)_Xxj2isi&~`U&Io{KgML _Ac2{5HaIIYJYV(J96NCP0CHqx{w4xsYH593=*=5?J|B37 z^=7Wmh`IG1$i|JZdwL=UA&4K)uuBzogbQJp7qG)=_nBO+P$} XSyV*z}d6*pyGR`ZJlbcInT6uP0A#XO4eVP5;zWJ?_pHRC8R$I}b z(e UG2~$7&xmd&tLb3%}it!Q`|IQZ#nG4oINGhq;Jlz6^k1%0x|vMlCmT90Nb2u`7n! zW$9ppH-`o#=w+s_BD~H2u2r#atxL90(j?1Pm_LFs2gU@1A!NN4F>AUK7?T2;^lC7x z!-A)$2Sn7E_<9*sWx>t08(K=tKx}uNK(P8KmxuZE2)2j`j+}9ysMRU&q3MaP?#_&N z)(v8317wng`mK51mp7-c*ApB$m3+=f1pOIfk %P)SqkGN1+j
Y;ryp2v4{2{=zUF9kK3>jWGWD pZ^+T?5Rehy(im8s1$}#&b#}lkVP!bII{YC z4Sw%Ljq!3kj@Tt*_D9#+4inhzjcPpN!u06TwW~0mF!FW3Z-|wy6<<^q{@&EryN@Bt zl-w`q^^1OS$0Of@=k#jduVQQqo1H}D$!Ip6y0K+`S?vb}oXIjb4K6SOH*`EWoY8UB zS><}SAhEH!7ZZ!*c5O7~;su#?EJpMB1 lco#IQ_UMDTN}Cfe zUVyxNOrzQS-l5QlRBdXUh5Ehp3f&mDPAt#C(?3qU5bJ%2yDysGf~L+UdI %Y^+QN zcA#BCSfR=`*AY*4FdWvYWI?gQ$QyEb!UkuMU)VK?|7wb3`1)dHh=!ejsMI?nDj3?O zeWub>mIyV&w_lbyKJe74?xcw`X)eU~4XGujaC|I53jDt$e>jtjGV>`HIzt@xP`TW7 znE2~XR}E*;W$Vi!rlSuOLr@22_rdESIYSGF?tji|HJPyorv`>|2eKB1{_PBguV3@g zPVy&^^=W^5fSee$Ut}Cps51nmFX&xmb~PI1xku)c)oRrm^JO71`J$&9zVg%|E 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 0000000000000000000000000000000000000000..2c9b38bc339c6b6a2deb3f0c768a4008099a1c06 GIT binary patch literal 2158 zcmaJ?!EWO=5bfDtF*O!gDKO-_7pc*vTP)Bm3N!_JSS+x#G`84Cq(D+})WE==`T<41 zaKEH)NXoJI&>)E `ttc-yfP{Oup-|o#|`Z!;zxE9 z`O)oF%`i}bYGueN?U{7gkXo$QbWj79T|?H;d%bhYO)FC5xMKx7tm2GvLHj_1Ixsd3 z*@3ax?Z#eg?+0A)+Kab$wrjLG0GirW^t*sE{>YBBr_R=@qe756YkU8i+SwYA0itdJ z92ZJh;;i9EU2}<;ti%St;s#cL%*Kv~mV%|m(%w42IKmwxe(v^U_Y#;V#B5E?72Ju( zUrjXNARCH;?h(&mT~J+zyWwqTn@Pw6#qX5AZ<#~oAGr%Kl&E2~ceWR2$CjON9J!Aa z0-Z7q{_56B65vmVioPF)?1OqcnjndraU^uQ7ZCI;VV)^G#hYkZt!vx&qtRi?ZmK{1 z^37twPw`Z34RV9%0hn&-l@BU#@w_1X2;>dzRp)sX7g8oSv|Q$IN#e_Azthcz%-D5l zyS51Ma$$XQ18xxbOVmePTRdt$Ml{1yO4v(?B08@F?bz!^lvmNsI)RBq&F)X-OHBLG z)ImcF5 X7o9| ?TT!(E{0FQ8(n5}XR;9x{?y(fUgr#!EAGDdk^t {g0
&|-49M3f6nDf!7 zpLkZ%FIC;61Rr=p4M|U+z3$k@a%SfVRI-anzEbF*wEG!nZmGCRWJ$N*(Wh6>d6l+X zsI#ns(|v)5eQ(h~?$C^i^7M_EMY8|T>Jx+{NTrknko1RM8NijJo-}lYPJm+D0DnUD zAux#s8N?qGm)*KSB%|ZHWf9LkIUV zma=9 {/if} + {#if !inLibrary && community.spaces.length} + + + {/if} ++ {#key album.artist + "\u0000" + album.title}Who has this
from your rooms' holdings · names compared, never files{/key} + {#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} +++ {/if} {#if openLoading}Who has thisfrom your rooms' holdings · names compared, never files+ {#key openAlbum.id}{/key} +