soulseek: cache peer share trees server-side
Browsing a peer inline could never work. slskd answers GET /users/{u}/browse with
the entire tree in one blocking response — measured at 59 MB / 18k folders / 284k
files for a single real peer — and it takes minutes because it round-trips to that
peer. The browser was made to wait for that, so navigating away threw the whole
thing out and the panel showed an error more often than a tree.
So the fetch moves into the sidecar and the result into Postgres. Clicking "fetch
shares" returns 202 and the job keeps running without the tab; the UI polls the
snapshot row and reads back pages. Folders are rows and files ride along as jsonb
on their folder, because folders are what you filter and page through while files
are only ever read for the one folder you opened — a row per file would be 284k
rows per peer for no gain.
A failed or in-flight refresh deliberately leaves the previous folders in place: a
peer going offline shouldn't cost you a good cache, so the panel drives off rows
existing rather than off status. Interrupted 'pending' snapshots are failed at
sidecar boot, since the job died with the process and would otherwise spin forever.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
FolderOpen,
|
||||
Folder,
|
||||
Loader2,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Download,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
TriangleAlert,
|
||||
Trash2,
|
||||
FileAudio,
|
||||
} from 'lucide-react';
|
||||
import { useSoulseekBrowseSnapshots, useSoulseekBrowseDirs, useSoulseekBrowseFiles } from './useSoulseekBrowse';
|
||||
import { formatSize, formatWhen, type SoulseekBrowseSnapshot } from './shared';
|
||||
|
||||
// A peer's shared folders, read from Officer's cache rather than browsed live.
|
||||
//
|
||||
// The live slskd call returns the entire tree in one blocking response (59 MB / 18k folders / 284k files
|
||||
// for a real peer) and takes minutes, because it round-trips to that peer. So nothing here triggers it
|
||||
// inline: you ask the sidecar to fetch, it keeps going without the tab, and this reads back pages.
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
/** Debounce the filter box so a keystroke doesn't become a request. */
|
||||
function useDebounced<T>(value: T, ms: number): T {
|
||||
const [held, setHeld] = useState(value);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setHeld(value), ms);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, ms]);
|
||||
return held;
|
||||
}
|
||||
|
||||
/** Cache state for one peer, compact enough for a favourites row. */
|
||||
export const BrowseStateChip = ({ snapshot }: { snapshot: SoulseekBrowseSnapshot | null }) => {
|
||||
if (!snapshot) return <span className="shrink-0 text-xs text-zinc-600">not cached</span>;
|
||||
if (snapshot.status === 'pending')
|
||||
return (
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-primary">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
fetching
|
||||
</span>
|
||||
);
|
||||
if (snapshot.status === 'failed' && !snapshot.directoryCount)
|
||||
return (
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-red-400" title={snapshot.error ?? undefined}>
|
||||
<TriangleAlert className="h-3 w-3" />
|
||||
failed
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
|
||||
{snapshot.directoryCount.toLocaleString()} folders
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const SharesBrowser = ({ username }: { username: string }) => {
|
||||
const { snapshotOf, fetchShares, dropShares } = useSoulseekBrowseSnapshots();
|
||||
const [filter, setFilter] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [openDir, setOpenDir] = useState<number | null>(null);
|
||||
const q = useDebounced(filter.trim(), 250);
|
||||
|
||||
const snapshot = snapshotOf(username);
|
||||
// Folder rows survive a failed or in-flight refresh, so drive the list off the data existing rather
|
||||
// than off the status — a stale tree is still worth browsing while a new one is being fetched.
|
||||
const hasCache = !!snapshot && snapshot.directoryCount > 0;
|
||||
|
||||
const { data, isFetching } = useSoulseekBrowseDirs({ username, q, page, pageSize: PAGE_SIZE, enabled: hasCache });
|
||||
|
||||
// A new peer or a new filter invalidates the current page number and any expanded folder.
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
setOpenDir(null);
|
||||
}, [username, q]);
|
||||
useEffect(() => {
|
||||
setFilter('');
|
||||
}, [username]);
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const pending = snapshot?.status === 'pending';
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-zinc-950">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-white/10 px-4 py-2.5">
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-100">Shared folders</span>
|
||||
{snapshot && !!snapshot.directoryCount && (
|
||||
<span className="text-xs tabular-nums text-zinc-500">
|
||||
· {snapshot.directoryCount.toLocaleString()} folders · {snapshot.fileCount.toLocaleString()} files ·{' '}
|
||||
{formatSize(snapshot.totalSize)}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchShares(username)}
|
||||
disabled={pending}
|
||||
title={hasCache ? 'Fetch again in the background' : 'Fetch this share tree in the background'}
|
||||
className="flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-zinc-300 transition hover:bg-white/10 hover:text-zinc-100 disabled:opacity-50"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : hasCache ? (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{pending ? 'Fetching…' : hasCache ? 'Refresh' : 'Fetch shares'}
|
||||
</button>
|
||||
{hasCache && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dropShares(username)}
|
||||
title="Drop this cached share tree"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-zinc-500 transition hover:bg-red-500/10 hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status banners — a fetch runs server-side, so leaving the panel is safe. */}
|
||||
{pending && (
|
||||
<div className="flex items-center gap-2 border-b border-white/10 bg-primary/5 px-4 py-2 text-xs text-zinc-300">
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />
|
||||
Fetching this peer's shares in the background — it can take a few minutes for a large share, and it keeps
|
||||
going if you navigate away.
|
||||
</div>
|
||||
)}
|
||||
{snapshot?.status === 'failed' && (
|
||||
<div className="flex items-start gap-2 border-b border-white/10 bg-red-500/5 px-4 py-2 text-xs text-red-300">
|
||||
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Last fetch failed{snapshot.error ? `: ${snapshot.error}` : ''}.{' '}
|
||||
{hasCache && 'Showing the previously cached tree.'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasCache && !pending ? (
|
||||
<div className="flex flex-col items-center gap-2 px-4 py-10 text-center">
|
||||
<Folder className="h-8 w-8 text-zinc-700" />
|
||||
<p className="max-w-sm text-sm text-zinc-500">
|
||||
No cached shares for this peer yet. Fetching runs on the server and survives you closing the tab — come back
|
||||
and it'll be here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
hasCache && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-2">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(ev) => setFilter(ev.target.value)}
|
||||
placeholder="Filter folders…"
|
||||
className="h-7 min-w-0 flex-1 bg-transparent text-sm text-zinc-100 placeholder:text-zinc-600 outline-none"
|
||||
/>
|
||||
{isFetching && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-zinc-500" />}
|
||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">{total.toLocaleString()} matching</span>
|
||||
</div>
|
||||
|
||||
{total === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-zinc-500">No folders match “{q}”.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-white/5">
|
||||
{data?.dirs.map((dir) => (
|
||||
<div key={dir.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenDir((cur) => (cur === dir.id ? null : dir.id))}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition-colors hover:bg-white/[0.03]"
|
||||
>
|
||||
{openDir === dir.id ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-zinc-200" title={dir.name}>
|
||||
{dir.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
|
||||
{dir.fileCount} file{dir.fileCount === 1 ? '' : 's'} · {formatSize(dir.totalSize)}
|
||||
</span>
|
||||
</button>
|
||||
{openDir === dir.id && <DirFiles username={username} dirId={dir.id} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-white/10 px-4 py-2 text-xs text-zinc-500">
|
||||
<span className="tabular-nums">
|
||||
{(page * PAGE_SIZE + 1).toLocaleString()}–{Math.min(total, (page + 1) * PAGE_SIZE).toLocaleString()}{' '}
|
||||
of {total.toLocaleString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="rounded-md px-2 py-1 transition hover:bg-white/10 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="tabular-nums">
|
||||
{page + 1} / {pages.toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(pages - 1, p + 1))}
|
||||
disabled={page >= pages - 1}
|
||||
className="rounded-md px-2 py-1 transition hover:bg-white/10 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshot?.completedAt && (
|
||||
<div className="border-t border-white/5 px-4 py-1.5 text-right text-xs text-zinc-600">
|
||||
cached {formatWhen(snapshot.completedAt)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type DirFilesProps = { username: string; dirId: number };
|
||||
|
||||
const DirFiles = ({ username, dirId }: DirFilesProps) => {
|
||||
const { data, isPending, error } = useSoulseekBrowseFiles(username, dirId);
|
||||
|
||||
if (isPending)
|
||||
return (
|
||||
<div className="flex items-center gap-2 bg-black/20 px-4 py-2 pl-10 text-xs text-zinc-500">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading files…
|
||||
</div>
|
||||
);
|
||||
if (error) return <p className="bg-black/20 px-4 py-2 pl-10 text-xs text-red-400">Could not load this folder.</p>;
|
||||
if (!data?.length) return <p className="bg-black/20 px-4 py-2 pl-10 text-xs text-zinc-500">Empty folder.</p>;
|
||||
|
||||
return (
|
||||
<div className="max-h-72 overflow-y-auto bg-black/20">
|
||||
{data.map((file) => (
|
||||
<div key={file.name} className="flex items-center gap-2 px-4 py-1.5 pl-10 text-xs">
|
||||
<FileAudio className="h-3 w-3 shrink-0 text-zinc-600" />
|
||||
<span className="min-w-0 flex-1 truncate text-zinc-300" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-zinc-500">{formatSize(file.size)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,25 +2,20 @@ import { useState, useEffect } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { toast } from 'sonner';
|
||||
import { Users, Search, FolderOpen, CircleCheck, CircleSlash, Clock, Loader2, Star, X } from 'lucide-react';
|
||||
import { Users, Search, CircleCheck, CircleSlash, Clock, Loader2, Star, X, Download } from 'lucide-react';
|
||||
import { useSoulseekFavorites } from './useSoulseekFavorites';
|
||||
import {
|
||||
SOULSEEK_USER_CHANNEL,
|
||||
type SlskdBrowseDirectory,
|
||||
type SlskdBrowseResponse,
|
||||
type SlskdUserInfo,
|
||||
type SlskdUserStatus,
|
||||
type SoulseekUserRequest,
|
||||
} from './shared';
|
||||
import { useSoulseekBrowseSnapshots } from './useSoulseekBrowse';
|
||||
import { SharesBrowser, BrowseStateChip } from './SharesBrowser';
|
||||
import { SOULSEEK_USER_CHANNEL, type SlskdUserInfo, type SlskdUserStatus, type SoulseekUserRequest } from './shared';
|
||||
|
||||
// Users panel — look up a peer: their presence (online/away/offline), profile info (description, upload
|
||||
// slots, queue), and optionally browse their shared folders. GET /users/{u}/status + /info fetch the
|
||||
// header; GET /users/{u}/browse pulls the share tree (a flat directory list).
|
||||
// Users panel — look up a peer: their presence (online/away/offline) and profile info (description,
|
||||
// upload slots, queue), both cheap live calls (GET /users/{u}/status + /info). Their shared folders come
|
||||
// from Officer's own cache instead, via <SharesBrowser> — see that file for why it isn't browsed live.
|
||||
//
|
||||
// This is also where the workspace's peer actions land: the username dropdown in search results and
|
||||
// downloads publishes to 'soulseek:user', which we consume once (clearing it) to look the peer up and
|
||||
// auto-browse. Favourites — Officer's own data, since slskd has no such concept — get their own section
|
||||
// at the top, which also serves as this panel's landing content before any lookup.
|
||||
// downloads publishes to 'soulseek:user', which we consume once (clearing it) to look the peer up.
|
||||
// Favourites — Officer's own data, since slskd has no such concept — get their own section at the top,
|
||||
// which also serves as this panel's landing content before any lookup.
|
||||
|
||||
type Loaded = {
|
||||
username: string;
|
||||
@@ -44,34 +39,16 @@ export const SoulseekUsers = () => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [peer, setPeer] = useState<Loaded | null>(null);
|
||||
const [dirs, setDirs] = useState<SlskdBrowseDirectory[] | null>(null);
|
||||
const [browsing, setBrowsing] = useState(false);
|
||||
const [request, setRequest] = usePanelChannel<SoulseekUserRequest | null>(SOULSEEK_USER_CHANNEL, null);
|
||||
const { favorites, isFavorite, toggle } = useSoulseekFavorites();
|
||||
const { snapshotOf, fetchShares } = useSoulseekBrowseSnapshots();
|
||||
|
||||
const browse = async (username: string) => {
|
||||
setBrowsing(true);
|
||||
try {
|
||||
const res = await client.get<SlskdBrowseResponse | SlskdBrowseDirectory[]>(
|
||||
`/slskd/api/v0/users/${encodeURIComponent(username)}/browse`,
|
||||
);
|
||||
// Tolerate both shapes: 0.26.0's source returns a bare array, the running build wraps it.
|
||||
const tree = Array.isArray(res) ? res : (res?.directories ?? []);
|
||||
setDirs([...tree].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
} catch (err) {
|
||||
toast.error(`Browse failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setBrowsing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const lookup = async (name: string, autoBrowse = false) => {
|
||||
const lookup = async (name: string) => {
|
||||
const username = name.trim();
|
||||
if (!username || loading) return;
|
||||
setQuery(username);
|
||||
setLoading(true);
|
||||
setPeer(null);
|
||||
setDirs(null);
|
||||
const [status, info] = await Promise.allSettled([
|
||||
client.get<SlskdUserStatus>(`/slskd/api/v0/users/${encodeURIComponent(username)}/status`),
|
||||
client.get<SlskdUserInfo>(`/slskd/api/v0/users/${encodeURIComponent(username)}/info`),
|
||||
@@ -86,15 +63,15 @@ export const SoulseekUsers = () => {
|
||||
status: status.status === 'fulfilled' ? status.value : null,
|
||||
info: info.status === 'fulfilled' ? info.value : null,
|
||||
});
|
||||
if (autoBrowse) browse(username);
|
||||
};
|
||||
|
||||
// Consume a peer request from the username dropdown. Cleared as it's handled, so switching away and
|
||||
// back doesn't re-run the (expensive) browse.
|
||||
// back doesn't re-run the lookup. Shares are NOT fetched automatically — that's a multi-minute
|
||||
// server-side job, so it stays an explicit click in the shares card.
|
||||
useEffect(() => {
|
||||
if (!request) return;
|
||||
setRequest(null);
|
||||
lookup(request.username, request.browse);
|
||||
lookup(request.username);
|
||||
// lookup is recreated each render; the effect deliberately runs only when a request arrives.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [request]);
|
||||
@@ -166,6 +143,17 @@ export const SoulseekUsers = () => {
|
||||
>
|
||||
{u}
|
||||
</button>
|
||||
<BrowseStateChip snapshot={snapshotOf(u)} />
|
||||
{!snapshotOf(u) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchShares(u)}
|
||||
title={`Fetch ${u}'s shares in the background`}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-500 transition hover:bg-white/10 hover:text-zinc-200"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(u)}
|
||||
@@ -232,47 +220,7 @@ export const SoulseekUsers = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shares */}
|
||||
<div className="rounded-xl border border-white/10 bg-zinc-950">
|
||||
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-2.5">
|
||||
<FolderOpen className="h-4 w-4 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-100">Shared folders</span>
|
||||
{dirs && <span className="text-xs text-zinc-500">· {dirs.length}</span>}
|
||||
{!dirs && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => browse(peer.username)}
|
||||
disabled={browsing}
|
||||
className="ml-auto flex items-center gap-1.5 text-xs text-zinc-400 transition hover:text-zinc-100 disabled:opacity-40"
|
||||
>
|
||||
{browsing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{browsing ? 'Browsing…' : 'Browse shares'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{dirs &&
|
||||
(dirs.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-zinc-500">No shared folders.</p>
|
||||
) : (
|
||||
<div className="max-h-96 divide-y divide-white/5 overflow-y-auto">
|
||||
{dirs.map((d) => (
|
||||
<div key={d.name} className="flex items-center gap-2 px-4 py-2 text-sm">
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
<span className="min-w-0 flex-1 truncate text-zinc-200" title={d.name}>
|
||||
{d.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
|
||||
{d.fileCount} file{d.fileCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SharesBrowser username={peer.username} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
} from './shared';
|
||||
|
||||
// The peer dropdown, shared by search results and downloads: click a username anywhere in the workspace
|
||||
// and act on that peer. "Browse user files" hands the username to the Users section over a panel channel
|
||||
// (which owns the lookup + browse); favouriting goes to the sidecar's Officer-owned favourites route.
|
||||
// and act on that peer. Opening a peer hands the username to the Users section over a panel channel
|
||||
// (which owns the lookup and the cached share tree); favouriting goes to the sidecar's favourites route.
|
||||
|
||||
type UserMenuProps = { username: string };
|
||||
|
||||
@@ -29,8 +29,8 @@ export const UserMenu = ({ username }: UserMenuProps) => {
|
||||
const { isFavorite, toggle } = useSoulseekFavorites();
|
||||
const favorited = isFavorite(username);
|
||||
|
||||
const browse = () => {
|
||||
setRequest({ username, browse: true, nonce: Date.now() });
|
||||
const openPeer = () => {
|
||||
setRequest({ username, nonce: Date.now() });
|
||||
setSection('users');
|
||||
};
|
||||
|
||||
@@ -54,9 +54,9 @@ export const UserMenu = ({ username }: UserMenuProps) => {
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel className="truncate">{username}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={browse}>
|
||||
<DropdownMenuItem onSelect={openPeer}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
Browse user files
|
||||
View profile & shares
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={toggleFavorite}>
|
||||
{favorited ? <StarOff className="mr-2 h-4 w-4" /> : <Star className="mr-2 h-4 w-4" />}
|
||||
|
||||
@@ -185,6 +185,29 @@ export type SlskdBrowseResponse = {
|
||||
lockedDirectoryCount: number;
|
||||
};
|
||||
|
||||
// ── Cached share trees (Officer's own, from the sidecar's /_officer/browse routes) ──
|
||||
//
|
||||
// A live browse is one blocking slskd call carrying every file of every folder — 59 MB / 18k folders /
|
||||
// 284k files for a real peer, thrown away the moment you navigate. So the sidecar fetches it in the
|
||||
// background into Postgres and the UI reads paged slices. Timestamps arrive as ISO strings.
|
||||
|
||||
export type SoulseekBrowseStatus = 'pending' | 'ready' | 'failed';
|
||||
export type SoulseekBrowseSnapshot = {
|
||||
username: string;
|
||||
status: SoulseekBrowseStatus;
|
||||
error: string | null;
|
||||
directoryCount: number;
|
||||
fileCount: number;
|
||||
totalSize: number;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
};
|
||||
export type SoulseekBrowseDir = { id: number; name: string; fileCount: number; totalSize: number };
|
||||
export type SoulseekBrowseDirPage = { dirs: SoulseekBrowseDir[]; total: number };
|
||||
// Browse carries no bitrate/duration (real peers send empty `attributes`), so unlike a search result
|
||||
// there's no quality metadata to show — just name, size, extension.
|
||||
export type SoulseekBrowsedFile = { name: string; size: number; extension: string };
|
||||
|
||||
// Server connection state (GET /server, and the server block of GET /application).
|
||||
export type SlskdServerState = { address?: string; state?: string; isConnected?: boolean; username?: string };
|
||||
|
||||
@@ -225,9 +248,9 @@ export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
|
||||
|
||||
// Published by the username dropdown (search results / downloads) to jump straight to a peer in the Users
|
||||
// section. Nonce-stamped and consumed-once (the Users panel clears it) so revisiting the section doesn't
|
||||
// re-run an expensive browse, while two requests for the SAME username still each trigger a fresh lookup.
|
||||
// re-run the lookup, while two requests for the SAME username still each trigger a fresh one.
|
||||
export const SOULSEEK_USER_CHANNEL = 'soulseek:user';
|
||||
export type SoulseekUserRequest = { username: string; browse: boolean; nonce: number };
|
||||
export type SoulseekUserRequest = { username: string; nonce: number };
|
||||
|
||||
// A past search, as listed by GET /searches (no responses inlined).
|
||||
export type SlskdSearchSummary = {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { SoulseekBrowseSnapshot, SoulseekBrowseDirPage, SoulseekBrowsedFile } from './shared';
|
||||
|
||||
// Client for the sidecar's cached share trees. The expensive work happens server-side and outlives the
|
||||
// tab, so this hook only ever starts a fetch and polls for its status — it never pulls a whole tree.
|
||||
|
||||
const SNAPSHOTS_KEY = ['soulseek', 'browse'] as const;
|
||||
const EMPTY: SoulseekBrowseSnapshot[] = [];
|
||||
|
||||
// While something is fetching we need to notice it finishing; otherwise this data barely changes.
|
||||
const POLL_MS = 3000;
|
||||
|
||||
/**
|
||||
* Every peer's cache state in one request, so the favourites list can badge each row without N queries.
|
||||
* Polls only while at least one fetch is in flight.
|
||||
*/
|
||||
export function useSoulseekBrowseSnapshots() {
|
||||
const { get, post, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: SNAPSHOTS_KEY,
|
||||
queryFn: () => get<SoulseekBrowseSnapshot[]>('/slskd/_officer/browse'),
|
||||
staleTime: 10_000,
|
||||
refetchInterval: (query) => (query.state.data?.some((s) => s.status === 'pending') ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const snapshots = data ?? EMPTY;
|
||||
const snapshotOf = (username: string) => snapshots.find((s) => s.username === username) ?? null;
|
||||
|
||||
const start = useMutation({
|
||||
mutationFn: (username: string) => post(`/slskd/_officer/browse/${encodeURIComponent(username)}`, {}),
|
||||
// Flip to pending immediately so the row shows a spinner before the first poll lands.
|
||||
onMutate: (username) => {
|
||||
const prev = qc.getQueryData<SoulseekBrowseSnapshot[]>(SNAPSHOTS_KEY) ?? EMPTY;
|
||||
const existing = prev.find((s) => s.username === username);
|
||||
const pending: SoulseekBrowseSnapshot = {
|
||||
...(existing ?? {
|
||||
username,
|
||||
directoryCount: 0,
|
||||
fileCount: 0,
|
||||
totalSize: 0,
|
||||
completedAt: null,
|
||||
}),
|
||||
status: 'pending',
|
||||
error: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
qc.setQueryData<SoulseekBrowseSnapshot[]>(
|
||||
SNAPSHOTS_KEY,
|
||||
existing ? prev.map((s) => (s.username === username ? pending : s)) : [...prev, pending],
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _username, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(SNAPSHOTS_KEY, ctx.prev);
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: SNAPSHOTS_KEY }),
|
||||
});
|
||||
|
||||
const drop = useMutation({
|
||||
mutationFn: (username: string) => del(`/slskd/_officer/browse/${encodeURIComponent(username)}`),
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: SNAPSHOTS_KEY }),
|
||||
});
|
||||
|
||||
return {
|
||||
snapshots,
|
||||
snapshotOf,
|
||||
fetchShares: (username: string) => start.mutate(username),
|
||||
dropShares: (username: string) => drop.mutate(username),
|
||||
};
|
||||
}
|
||||
|
||||
type DirsParams = { username: string | null; q: string; page: number; pageSize: number; enabled?: boolean };
|
||||
|
||||
/** One page of a peer's cached folders. Keeps the previous page visible while the next loads. */
|
||||
export function useSoulseekBrowseDirs({ username, q, page, pageSize, enabled = true }: DirsParams) {
|
||||
const { get } = useClient();
|
||||
const offset = page * pageSize;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['soulseek', 'browse', username, 'dirs', q, offset, pageSize],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
||||
if (q) params.set('q', q);
|
||||
return get<SoulseekBrowseDirPage>(`/slskd/_officer/browse/${encodeURIComponent(username ?? '')}/dirs?${params}`);
|
||||
},
|
||||
enabled: enabled && !!username,
|
||||
placeholderData: (prev) => prev,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** A single folder's files, fetched only when it's expanded. Cached indefinitely — the rows are immutable. */
|
||||
export function useSoulseekBrowseFiles(username: string | null, dirId: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['soulseek', 'browse', username, 'dir', dirId],
|
||||
queryFn: () =>
|
||||
get<SoulseekBrowsedFile[]>(`/slskd/_officer/browse/${encodeURIComponent(username ?? '')}/dirs/${dirId}/files`),
|
||||
enabled: !!username && dirId !== null,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user