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.
149 lines
6.0 KiB
TypeScript
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,
|
|
});
|
|
}
|