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 @@ + 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} +
from your rooms' holdings · {people} {people === 1 ? "person" : "people"}names compared, never files
+ {/if} +
+ + diff --git a/src/components/WhoHasSheet.svelte b/src/components/WhoHasSheet.svelte new file mode 100644 index 0000000000000000000000000000000000000000..0e447b79bb52060faf7ba6cddde8e5f06e31454d GIT binary patch literal 5340 zcmb_g>yF#T74C08#W5YElD(uZUOTpSwT|NiP0*wTngT{q^v94KiIXKc^l)@p3j*>G z0s26FlRinmGsBy-8z(<%tO;@E%(;H&GQ6%6qbhszi12SRp||xd)tRUyyM-c9}GiWM0dNesorUMgRTz7jL*stkNZ^(#aDg z=}ML)@_17?B2~Bi-e>w-QAhPw<`$;rB7qL7%vC9`zzb&M&pA^Rl8oMA0h}lm=9bpE zs5?&1+g|C^h@CWltf>%I8YS|`iBknTn!bY@X=QXlK{Vl)MtX2Hr1FY&bSHYAQXb$? zHllH1bMV+6jV8H@C!VB{6#Z0#g_8#HwWas8q|3{xSHLg*@b+(i`tC1SpUwSp4lKzW z3!l{%W{t{Co{#&jO6m%DI^T9eR-4iX!~<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`x6=FZ~LBF68B-@)_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(eUG2~$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+jY;ryp2v4{2{=zUF9kK3>jWGWDpZ^+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#?EJpMB1lco#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)ImcF5X7o9|?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} +
+

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}