Files
soulseek/web/useSoulseekBrowse.ts
officer c11f45c033 soulseek, extracted from the platform as a plugin
Seventh plugin out, and the first that was already dead. Soulseek had been
switched off since 2026-08-13: hono.ts had the router import and its mount
commented out, so createSidecarProxy never ran, registerProxiedPrefix was never
called, the slskd:server announcement fired into a void, and officer-slskd was
not in the ecosystem at all. The permission entry had been reduced to api: [],
which is why assertPermissionTotality was satisfied and the server booted
clean. The UI rendered perfectly and every request 404'd.

So this is a resurrection as well as a move.

The mount is now /api/soulseek, derived via mountPrefix from the appName — all
47 frontend call sites repointed from /slskd/. The credential key in
service_connections stays 'slskd': it names the daemon, not the route. Those
two strings differ on purpose and conflating them reads the wrong row.

The connection stays in core service_connections, as transmission does, so
'slskd' remains in a platform union this plugin cannot extend. Recorded in
PLUGIN.md. Unlike transmission this plugin does own a schema: favourites and
the two halves of the cached browse tree, which slskd has no concept of. Those
three tables had been dropped from the drizzle barrel, so bun db:push would
have deleted them on any server holding data.

313 palette classes converted to the semantic tone set, including a shared
PHASE_TONE the three transfer views previously each copied.
2026-08-19 02:55:02 +00:00

149 lines
6.0 KiB
TypeScript

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import type { SoulseekBrowseSnapshot, SoulseekBrowseLevel, SoulseekBrowseSearch, 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[]>('/soulseek/_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(`/soulseek/_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(`/soulseek/_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 LevelParams = { username: string | null; parent: string | null; limit: number; enabled?: boolean };
/**
* One level of a peer's tree — the children of `parent`, or the roots when it's null. Cached per level,
* so collapsing and reopening a folder is free, and expanding a shallow node never touches its subtree.
*/
export function useSoulseekBrowseLevel({ username, parent, limit, enabled = true }: LevelParams) {
const { get } = useClient();
return useQuery({
queryKey: ['soulseek', 'browse', username, 'level', parent, limit],
queryFn: () => {
const params = new URLSearchParams({ limit: String(limit) });
if (parent) params.set('parent', parent);
return get<SoulseekBrowseLevel>(`/soulseek/_officer/browse/${encodeURIComponent(username ?? '')}/tree?${params}`);
},
enabled: enabled && !!username,
staleTime: 5 * 60_000,
});
}
type SearchParams = { username: string | null; q: string; enabled?: boolean };
/** The filtered tree: matches with their ancestors, in one request, ready to render pre-expanded. */
export function useSoulseekBrowseSearch({ username, q, enabled = true }: SearchParams) {
const { get } = useClient();
return useQuery({
queryKey: ['soulseek', 'browse', username, 'search', q],
queryFn: () =>
get<SoulseekBrowseSearch>(
`/soulseek/_officer/browse/${encodeURIComponent(username ?? '')}/tree/search?q=${encodeURIComponent(q)}`,
),
enabled: enabled && !!username && !!q,
// The previous result stays on screen while the next one loads, so the tree doesn't blink per keystroke.
placeholderData: (prev) => prev,
staleTime: 60_000,
});
}
/** A folder (its whole subtree) or one file inside it, named the way the cache stores them. */
export type SoulseekDownloadTarget = { path: string; file?: string };
/**
* Queue a download from the cache. The sidecar expands a folder into its files and rejoins each one to
* its peer path, so this sends a path rather than a list — the browser never walks the subtree itself.
*/
export function useSoulseekEnqueue(username: string) {
const { post } = useClient();
return useMutation({
mutationFn: (target: SoulseekDownloadTarget) =>
post<{ queued: number }>(`/soulseek/_officer/browse/${encodeURIComponent(username)}/download`, target),
onSuccess: ({ queued }, target) =>
toast.success(queued === 1 ? `Queued ${target.file ?? basename(target.path)}` : `Queued ${queued} files`),
onError: (err) => toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`),
});
}
const basename = (path: string) => path.slice(path.lastIndexOf('\\') + 1);
/** 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[]>(`/soulseek/_officer/browse/${encodeURIComponent(username ?? '')}/dirs/${dirId}/files`),
enabled: !!username && dirId !== null,
staleTime: Infinity,
});
}