soulseek, extracted into plugins/soulseek — the platform half
Seventh extraction, and the first of something that was already dead. Soulseek had been switched off since 2026-08-13: hono.ts carried the router import and its PROTECTED_MOUNTS entry commented out, so createSidecarProxy never ran and registerProxiedPrefix was never called; officer-slskd was not in the ecosystem at all. Because the permission entry had been reduced to api: [], the totality check was satisfied and the server booted clean — the UI rendered and every request 404'd. Worth naming: the boot check proves every MOUNTED prefix has a permission, and is silent about a feature that mounts nothing. Also closes a live data hazard: schema.ts had the soulseek export commented out, so the three tables were absent from the drizzle barrel and bun db:push would have dropped soulseek_favorites, soulseek_browse_snapshots and soulseek_browse_dirs on any server holding them. rebuild-soulseek-tree.ts documented the workaround rather than the problem; deleted with the rest. The connection stays in core service_connections under service 'slskd' while the mount is now /api/soulseek. Those differ on purpose. Test fixtures: slskd was canonical in three app-store tests, the third extraction running to break them. installer.test.ts now builds a literal fixture instead of reaching into the catalogue — it pins planSteps, which is installer logic, and no catalogue entry is both provisioned and members:'none' any more. catalogue.test.ts and members.test.ts re-anchored to vnc/notify/gitea. Unrelated but mine: check:theme caught 5 palette classes I introduced in CommandHQ earlier tonight against a baseline of 0. Converted to bg-success / bg-warning. check:theme is not part of bun test, which is why the green suite never showed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 KiB |
@@ -1,52 +0,0 @@
|
|||||||
// Re-derive the stored tree shape for every cached share snapshot, from the folder rows already in
|
|
||||||
// Postgres — no peer contact, so it works with everyone offline and is deterministic.
|
|
||||||
//
|
|
||||||
// Written for the backfill when the tree columns were added, but kept because it's the answer to any
|
|
||||||
// future change in tree shape: it runs the SAME buildTree + finishSoulseekBrowse the sidecar's ingest
|
|
||||||
// runs, so there's no second implementation to drift.
|
|
||||||
//
|
|
||||||
// bun scripts/rebuild-soulseek-tree.ts
|
|
||||||
|
|
||||||
import type { BrowsedFile } from 'officerdb';
|
|
||||||
import { eq, asc } from 'drizzle-orm';
|
|
||||||
import { db, finishSoulseekBrowse } from 'officerdb';
|
|
||||||
// soulseek is a plugin, so its tables are commented out of officerdb's schema aggregator —
|
|
||||||
// import them from the feature directly.
|
|
||||||
import { soulseekBrowseSnapshots, soulseekBrowseDirs } from 'officerdb/soulseek/schema';
|
|
||||||
import { buildTree } from '../src/servers/sidecar/slskd/browse';
|
|
||||||
|
|
||||||
const snapshots = await db
|
|
||||||
.select({
|
|
||||||
id: soulseekBrowseSnapshots.id,
|
|
||||||
userId: soulseekBrowseSnapshots.userId,
|
|
||||||
username: soulseekBrowseSnapshots.username,
|
|
||||||
status: soulseekBrowseSnapshots.status,
|
|
||||||
})
|
|
||||||
.from(soulseekBrowseSnapshots)
|
|
||||||
.orderBy(asc(soulseekBrowseSnapshots.id));
|
|
||||||
|
|
||||||
console.log(`${snapshots.length} snapshot(s) to rebuild`);
|
|
||||||
|
|
||||||
for (const snap of snapshots) {
|
|
||||||
const started = Date.now();
|
|
||||||
const rows = await db
|
|
||||||
.select({ name: soulseekBrowseDirs.name, files: soulseekBrowseDirs.files })
|
|
||||||
.from(soulseekBrowseDirs)
|
|
||||||
.where(eq(soulseekBrowseDirs.snapshotId, snap.id));
|
|
||||||
|
|
||||||
if (!rows.length) {
|
|
||||||
console.log(` #${snap.id} ${snap.username}: no folder rows, skipped`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tree = buildTree(rows.map((r) => ({ name: r.name, files: r.files as BrowsedFile[] })));
|
|
||||||
await finishSoulseekBrowse(snap.id, tree);
|
|
||||||
const roots = tree.filter((t) => t.parentPath === null);
|
|
||||||
console.log(
|
|
||||||
` #${snap.id} ${snap.username}: ${rows.length} -> ${tree.length} rows ` +
|
|
||||||
`(+${tree.length - rows.length} synthesized), ${roots.length} root(s), ` +
|
|
||||||
`${roots.reduce((n, r) => n + r.subtreeFileCount, 0)} files, ${Date.now() - started}ms`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"src/apps/officer-web/Screens": 144,
|
"src/apps/officer-web/Screens": 122,
|
||||||
"src/workspaces/components": 47,
|
"src/workspaces/components": 47,
|
||||||
"src/workspaces/officerdev/src/apps/AppStore": 6,
|
"src/workspaces/officerdev/src/apps/AppStore": 6,
|
||||||
"src/workspaces/officerdev/src/apps/Chat": 12,
|
"src/workspaces/officerdev/src/apps/Chat": 12,
|
||||||
@@ -11,13 +11,9 @@
|
|||||||
"src/workspaces/officerdev/src/apps/FileViewer": 38,
|
"src/workspaces/officerdev/src/apps/FileViewer": 38,
|
||||||
"src/workspaces/officerdev/src/apps/Invoices": 95,
|
"src/workspaces/officerdev/src/apps/Invoices": 95,
|
||||||
"src/workspaces/officerdev/src/apps/Jellyfin": 48,
|
"src/workspaces/officerdev/src/apps/Jellyfin": 48,
|
||||||
"src/workspaces/officerdev/src/apps/Photos": 47,
|
|
||||||
"src/workspaces/officerdev/src/apps/Preview": 4,
|
"src/workspaces/officerdev/src/apps/Preview": 4,
|
||||||
"src/workspaces/officerdev/src/apps/QrTransfer": 5,
|
"src/workspaces/officerdev/src/apps/QrTransfer": 5,
|
||||||
"src/workspaces/officerdev/src/apps/Soulseek": 313,
|
|
||||||
"src/workspaces/officerdev/src/apps/SystemMonitor": 1,
|
"src/workspaces/officerdev/src/apps/SystemMonitor": 1,
|
||||||
"src/workspaces/officerdev/src/apps/Terminal": 10,
|
"src/workspaces/officerdev/src/apps/Terminal": 10,
|
||||||
"src/workspaces/officerdev/src/apps/Transmission": 43,
|
"src/workspaces/officerdev/src/apps/Wallet": 68
|
||||||
"src/workspaces/officerdev/src/apps/Wallet": 68,
|
|
||||||
"src/workspaces/officerdev/src/components": 22
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,8 +80,6 @@ export function App() {
|
|||||||
<Route path="/files/*" element={<Dashboard.FilesScreen />} />
|
<Route path="/files/*" element={<Dashboard.FilesScreen />} />
|
||||||
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
||||||
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
||||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
|
||||||
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
|
|
||||||
<Route path="/command-hq" element={<Dashboard.CommandHqScreen />} />
|
<Route path="/command-hq" element={<Dashboard.CommandHqScreen />} />
|
||||||
<Route path="/command-hq/:sessionId" element={<Dashboard.CommandHqScreen />} />
|
<Route path="/command-hq/:sessionId" element={<Dashboard.CommandHqScreen />} />
|
||||||
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Navigate, useParams } from 'react-router';
|
|
||||||
import type { LayoutNode } from 'officerdev';
|
|
||||||
import { WorkspaceView, DEFAULT_SOULSEEK_SECTION, soulseekSectionPath, isSoulseekSection } from 'officerdev';
|
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
|
||||||
import { defaultLayout } from './defaultLayout';
|
|
||||||
|
|
||||||
// /soulseek uses the Workspace/Panel system (like /command-hq): a section nav (soulseek-nav) on the left and
|
|
||||||
// a section view (soulseek-view) on the right, both reading `:section` from the URL. Deeper selection —
|
|
||||||
// which search, which peer — is the query string. Both talk to slskd through the /api/slskd auth proxy.
|
|
||||||
|
|
||||||
export const SoulseekScreen = () => {
|
|
||||||
const { section } = useParams();
|
|
||||||
// v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted
|
|
||||||
// layout doesn't resurrect as two mismatched panels.
|
|
||||||
const workspace = useDashboardState<LayoutNode>('screens/soulseek-v2', defaultLayout);
|
|
||||||
|
|
||||||
// After the hooks — it returns early.
|
|
||||||
if (!isSoulseekSection(section)) return <Navigate to={soulseekSectionPath(DEFAULT_SOULSEEK_SECTION)} replace />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full pt-2">
|
|
||||||
<WorkspaceView
|
|
||||||
workspace={workspace}
|
|
||||||
locked
|
|
||||||
appTypes={{ allowed: ['soulseek-nav', 'soulseek-view'], fallback: 'soulseek-view' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
|
||||||
|
|
||||||
export const defaultLayout: LayoutNode = {
|
|
||||||
type: 'group',
|
|
||||||
id: 'soulseek-root',
|
|
||||||
direction: 'horizontal',
|
|
||||||
children: [
|
|
||||||
{ node: { type: 'panel', id: 'soulseek-nav', appType: 'soulseek-nav' }, size: 22 },
|
|
||||||
{ node: { type: 'panel', id: 'soulseek-view', appType: 'soulseek-view' }, size: 78 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './SoulseekScreen';
|
|
||||||
@@ -13,7 +13,6 @@ export * from './Tasks';
|
|||||||
export * from './Files';
|
export * from './Files';
|
||||||
export * from './Calendar';
|
export * from './Calendar';
|
||||||
export * from './Contacts';
|
export * from './Contacts';
|
||||||
export * from './Soulseek';
|
|
||||||
export * from './CommandHQ';
|
export * from './CommandHQ';
|
||||||
export * from './Jellyfin';
|
export * from './Jellyfin';
|
||||||
export * from './Gitea';
|
export * from './Gitea';
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/marketplace'), title: 'Marketplace' },
|
{ match: (p) => p.startsWith('/marketplace'), title: 'Marketplace' },
|
||||||
{ match: (p) => p.startsWith('/plugins'), title: 'Marketplace' },
|
{ match: (p) => p.startsWith('/plugins'), title: 'Marketplace' },
|
||||||
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
||||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
|
||||||
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
|
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
|
||||||
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
|
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
|
||||||
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
|
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export * from './dav';
|
|||||||
export * from './invoiceshelf';
|
export * from './invoiceshelf';
|
||||||
export * from './jellyfin';
|
export * from './jellyfin';
|
||||||
export * from './notify';
|
export * from './notify';
|
||||||
export * from './soulseek';
|
|
||||||
export * from './wallet';
|
export * from './wallet';
|
||||||
|
|
||||||
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
|
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export * from './service-connections/schema'; // service_connections
|
|||||||
// export * from './dav/schema'; // dav_app_passwords officer-caldav
|
// export * from './dav/schema'; // dav_app_passwords officer-caldav
|
||||||
// export * from './jellyfin/schema'; // jellyfin_servers officer-jellyfin
|
// export * from './jellyfin/schema'; // jellyfin_servers officer-jellyfin
|
||||||
// export * from './invoiceshelf/schema'; // invoiceshelf_accounts officer-invoiceshelf
|
// export * from './invoiceshelf/schema'; // invoiceshelf_accounts officer-invoiceshelf
|
||||||
// export * from './soulseek/schema'; // soulseek_favorites, _browse_snapshots, _browse_dirs
|
|
||||||
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
|
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
|
||||||
|
|
||||||
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
|
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
export { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from './queries';
|
|
||||||
|
|
||||||
export {
|
|
||||||
getSoulseekBrowseSnapshots,
|
|
||||||
getSoulseekBrowseSnapshot,
|
|
||||||
startSoulseekBrowse,
|
|
||||||
finishSoulseekBrowse,
|
|
||||||
failSoulseekBrowse,
|
|
||||||
resetStaleSoulseekBrowses,
|
|
||||||
getSoulseekBrowseLevel,
|
|
||||||
searchSoulseekBrowseTree,
|
|
||||||
getSoulseekBrowseDirFiles,
|
|
||||||
getSoulseekBrowseDownload,
|
|
||||||
deleteSoulseekBrowse,
|
|
||||||
} from './queries';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
BrowseDownloadFile,
|
|
||||||
BrowsedFile,
|
|
||||||
BrowseDirInput,
|
|
||||||
BrowseDirRow,
|
|
||||||
BrowseTreeNode,
|
|
||||||
BrowseLevel,
|
|
||||||
BrowseTreeSearch,
|
|
||||||
SoulseekBrowseSnapshot,
|
|
||||||
} from './queries';
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
import { eq, and, asc, isNull, inArray, sql } from 'drizzle-orm';
|
|
||||||
import { db } from '../db';
|
|
||||||
import { soulseekFavorites, soulseekBrowseSnapshots, soulseekBrowseDirs } from './schema';
|
|
||||||
|
|
||||||
/** A user's favourited Soulseek peers, alphabetical (the order the UI lists them in). */
|
|
||||||
export async function getSoulseekFavorites(userId: number): Promise<string[]> {
|
|
||||||
const rows = await db
|
|
||||||
.select({ username: soulseekFavorites.username })
|
|
||||||
.from(soulseekFavorites)
|
|
||||||
.where(eq(soulseekFavorites.userId, userId))
|
|
||||||
.orderBy(asc(soulseekFavorites.username));
|
|
||||||
return rows.map((r) => r.username);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add a favourite (idempotent — a repeat add is a no-op via the unique constraint). */
|
|
||||||
export async function addSoulseekFavorite(userId: number, username: string): Promise<void> {
|
|
||||||
await db.insert(soulseekFavorites).values({ userId, username }).onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove a favourite (no-op if it wasn't set). */
|
|
||||||
export async function removeSoulseekFavorite(userId: number, username: string): Promise<void> {
|
|
||||||
await db
|
|
||||||
.delete(soulseekFavorites)
|
|
||||||
.where(and(eq(soulseekFavorites.userId, userId), eq(soulseekFavorites.username, username)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cached share trees ──
|
|
||||||
|
|
||||||
/** A file inside a cached folder, slimmed at ingest (see the schema note on dropped attributes). */
|
|
||||||
export type BrowsedFile = { name: string; size: number; extension: string };
|
|
||||||
/** One folder as slskd gives it: a full backslash-delimited path, plus its files. */
|
|
||||||
export type BrowseDirInput = { name: string; files: BrowsedFile[] };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One folder as it's stored — a tree node. slskd's flat paths are linked up into this shape by the
|
|
||||||
* sidecar's `buildTree` before they get here, which is why every count is already on the row.
|
|
||||||
*/
|
|
||||||
export type BrowseDirRow = {
|
|
||||||
name: string;
|
|
||||||
parentPath: string | null;
|
|
||||||
depth: number;
|
|
||||||
label: string;
|
|
||||||
childCount: number;
|
|
||||||
fileCount: number;
|
|
||||||
totalSize: number;
|
|
||||||
subtreeFileCount: number;
|
|
||||||
subtreeSize: number;
|
|
||||||
files: BrowsedFile[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SoulseekBrowseSnapshot = {
|
|
||||||
username: string;
|
|
||||||
status: string;
|
|
||||||
error: string | null;
|
|
||||||
directoryCount: number;
|
|
||||||
fileCount: number;
|
|
||||||
totalSize: number;
|
|
||||||
startedAt: Date;
|
|
||||||
completedAt: Date | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const snapshotCols = {
|
|
||||||
username: soulseekBrowseSnapshots.username,
|
|
||||||
status: soulseekBrowseSnapshots.status,
|
|
||||||
error: soulseekBrowseSnapshots.error,
|
|
||||||
directoryCount: soulseekBrowseSnapshots.directoryCount,
|
|
||||||
fileCount: soulseekBrowseSnapshots.fileCount,
|
|
||||||
totalSize: soulseekBrowseSnapshots.totalSize,
|
|
||||||
startedAt: soulseekBrowseSnapshots.startedAt,
|
|
||||||
completedAt: soulseekBrowseSnapshots.completedAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Every cached share tree for a user, so the UI can badge each favourite in one request. */
|
|
||||||
export async function getSoulseekBrowseSnapshots(userId: number): Promise<SoulseekBrowseSnapshot[]> {
|
|
||||||
return db
|
|
||||||
.select(snapshotCols)
|
|
||||||
.from(soulseekBrowseSnapshots)
|
|
||||||
.where(eq(soulseekBrowseSnapshots.userId, userId))
|
|
||||||
.orderBy(asc(soulseekBrowseSnapshots.username));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One peer's snapshot, or null if never fetched. */
|
|
||||||
export async function getSoulseekBrowseSnapshot(
|
|
||||||
userId: number,
|
|
||||||
username: string,
|
|
||||||
): Promise<SoulseekBrowseSnapshot | null> {
|
|
||||||
const [row] = await db
|
|
||||||
.select(snapshotCols)
|
|
||||||
.from(soulseekBrowseSnapshots)
|
|
||||||
.where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)))
|
|
||||||
.limit(1);
|
|
||||||
return row ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark a peer's tree as being fetched and return the snapshot id to ingest into. Deliberately leaves the
|
|
||||||
* existing folder rows and counts alone: they stay readable while the refresh runs, and survive it failing.
|
|
||||||
*/
|
|
||||||
export async function startSoulseekBrowse(userId: number, username: string): Promise<number> {
|
|
||||||
const [row] = await db
|
|
||||||
.insert(soulseekBrowseSnapshots)
|
|
||||||
.values({ userId, username, status: 'pending' })
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [soulseekBrowseSnapshots.userId, soulseekBrowseSnapshots.username],
|
|
||||||
set: { status: 'pending', error: null, startedAt: new Date(), completedAt: null },
|
|
||||||
})
|
|
||||||
.returning({ id: soulseekBrowseSnapshots.id });
|
|
||||||
if (!row) throw new Error('failed to create browse snapshot');
|
|
||||||
return row.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each row carries a whole folder's file list, so keep batches small enough that no single INSERT gets
|
|
||||||
// absurd. 18k folders is ~36 round trips at this size.
|
|
||||||
const INGEST_BATCH = 500;
|
|
||||||
|
|
||||||
/** Swap in a freshly built tree: replace the folder rows and mark the snapshot ready, atomically. */
|
|
||||||
export async function finishSoulseekBrowse(snapshotId: number, tree: BrowseDirRow[]): Promise<void> {
|
|
||||||
const rows = tree.map((r) => ({ ...r, snapshotId }));
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
await tx.delete(soulseekBrowseDirs).where(eq(soulseekBrowseDirs.snapshotId, snapshotId));
|
|
||||||
for (let i = 0; i < rows.length; i += INGEST_BATCH) {
|
|
||||||
await tx.insert(soulseekBrowseDirs).values(rows.slice(i, i + INGEST_BATCH));
|
|
||||||
}
|
|
||||||
// Summed over the roots, not over every row: the subtree rollups already include descendants, so
|
|
||||||
// adding them all up would count each file once per level above it.
|
|
||||||
const roots = rows.filter((r) => r.parentPath === null);
|
|
||||||
await tx
|
|
||||||
.update(soulseekBrowseSnapshots)
|
|
||||||
.set({
|
|
||||||
status: 'ready',
|
|
||||||
error: null,
|
|
||||||
completedAt: new Date(),
|
|
||||||
directoryCount: rows.length,
|
|
||||||
fileCount: roots.reduce((n, r) => n + r.subtreeFileCount, 0),
|
|
||||||
totalSize: roots.reduce((n, r) => n + r.subtreeSize, 0),
|
|
||||||
})
|
|
||||||
.where(eq(soulseekBrowseSnapshots.id, snapshotId));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Record a failed fetch. Counts and folder rows are left as they were — a stale cache beats none. */
|
|
||||||
export async function failSoulseekBrowse(snapshotId: number, error: string): Promise<void> {
|
|
||||||
await db
|
|
||||||
.update(soulseekBrowseSnapshots)
|
|
||||||
.set({ status: 'failed', error: error.slice(0, 1000), completedAt: new Date() })
|
|
||||||
.where(eq(soulseekBrowseSnapshots.id, snapshotId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fail every snapshot still marked pending. Called on sidecar boot: an in-flight fetch dies with the
|
|
||||||
* process, so without this a crash mid-browse leaves a row spinning in the UI forever.
|
|
||||||
*/
|
|
||||||
export async function resetStaleSoulseekBrowses(): Promise<number> {
|
|
||||||
const rows = await db
|
|
||||||
.update(soulseekBrowseSnapshots)
|
|
||||||
.set({ status: 'failed', error: 'interrupted by a sidecar restart', completedAt: new Date() })
|
|
||||||
.where(eq(soulseekBrowseSnapshots.status, 'pending'))
|
|
||||||
.returning({ id: soulseekBrowseSnapshots.id });
|
|
||||||
return rows.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A tree row as the UI renders it. `files` stays out — those load per folder, on expand. */
|
|
||||||
export type BrowseTreeNode = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
parentPath: string | null;
|
|
||||||
label: string;
|
|
||||||
depth: number;
|
|
||||||
childCount: number;
|
|
||||||
fileCount: number;
|
|
||||||
totalSize: number;
|
|
||||||
subtreeFileCount: number;
|
|
||||||
subtreeSize: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const treeCols = {
|
|
||||||
id: soulseekBrowseDirs.id,
|
|
||||||
name: soulseekBrowseDirs.name,
|
|
||||||
parentPath: soulseekBrowseDirs.parentPath,
|
|
||||||
label: soulseekBrowseDirs.label,
|
|
||||||
depth: soulseekBrowseDirs.depth,
|
|
||||||
childCount: soulseekBrowseDirs.childCount,
|
|
||||||
fileCount: soulseekBrowseDirs.fileCount,
|
|
||||||
totalSize: soulseekBrowseDirs.totalSize,
|
|
||||||
subtreeFileCount: soulseekBrowseDirs.subtreeFileCount,
|
|
||||||
subtreeSize: soulseekBrowseDirs.subtreeSize,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The snapshot a request is scoped to, or null if this peer was never fetched. */
|
|
||||||
async function snapshotIdOf(userId: number, username: string): Promise<number | null> {
|
|
||||||
const [snap] = await db
|
|
||||||
.select({ id: soulseekBrowseSnapshots.id })
|
|
||||||
.from(soulseekBrowseSnapshots)
|
|
||||||
.where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)))
|
|
||||||
.limit(1);
|
|
||||||
return snap?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BrowseLevel = { nodes: BrowseTreeNode[]; total: number };
|
|
||||||
type BrowseLevelParams = { userId: number; username: string; parent?: string; limit: number; offset: number };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One level of the tree: the children of `parent`, or the roots when it's absent. Paged, because fan-out
|
|
||||||
* is not gentle — the widest folder measured on a real peer has 1,181 children.
|
|
||||||
*/
|
|
||||||
export async function getSoulseekBrowseLevel(params: BrowseLevelParams): Promise<BrowseLevel> {
|
|
||||||
const { userId, username, parent, limit, offset } = params;
|
|
||||||
const snapshotId = await snapshotIdOf(userId, username);
|
|
||||||
if (snapshotId === null) return { nodes: [], total: 0 };
|
|
||||||
|
|
||||||
const where = and(
|
|
||||||
eq(soulseekBrowseDirs.snapshotId, snapshotId),
|
|
||||||
parent ? eq(soulseekBrowseDirs.parentPath, parent) : isNull(soulseekBrowseDirs.parentPath),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [nodes, [count]] = await Promise.all([
|
|
||||||
db
|
|
||||||
.select(treeCols)
|
|
||||||
.from(soulseekBrowseDirs)
|
|
||||||
.where(where)
|
|
||||||
.orderBy(asc(soulseekBrowseDirs.name))
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset),
|
|
||||||
db
|
|
||||||
.select({ total: sql<number>`count(*)::int` })
|
|
||||||
.from(soulseekBrowseDirs)
|
|
||||||
.where(where),
|
|
||||||
]);
|
|
||||||
return { nodes, total: count?.total ?? 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// These names come off strangers' filesystems, so they're littered with typographic punctuation that no
|
|
||||||
// keyboard produces: curly apostrophes, en dashes, and U+2010 HYPHEN, which is pixel-identical to '-'.
|
|
||||||
// Typing "Hell's Ditch" or "1984-1985" found nothing at all, so both sides get folded to ASCII first.
|
|
||||||
// Same character set on both sides — extend one and you must extend the other.
|
|
||||||
const FOLD_FROM = '‘’ʼ'“”‐‑‒–—―− ';
|
|
||||||
const FOLD_TO = "''''\"\"------- ";
|
|
||||||
// Not in the translate() pair because it's one character standing in for three.
|
|
||||||
const ELLIPSIS = '…';
|
|
||||||
|
|
||||||
const foldPunctuation = (value: string): string => {
|
|
||||||
let out = '';
|
|
||||||
for (const ch of value) {
|
|
||||||
const at = FOLD_FROM.indexOf(ch);
|
|
||||||
out += at === -1 ? ch : FOLD_TO[at];
|
|
||||||
}
|
|
||||||
return out.split(ELLIPSIS).join('...');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Postgres reads '%', '_' and '\' as LIKE metacharacters, so unescaped input meant a typed '_' matched
|
|
||||||
// every folder in the share while a pasted path — and every name here IS a backslash-delimited path —
|
|
||||||
// silently matched none. Backslash stays the escape character, which is why it's escaped first.
|
|
||||||
const likePattern = (term: string): string => `%${foldPunctuation(term).replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
|
|
||||||
|
|
||||||
/** The column, folded the same way `likePattern` folds the term. */
|
|
||||||
const foldedName = sql`replace(translate(${soulseekBrowseDirs.name}, ${FOLD_FROM}, ${FOLD_TO}), ${ELLIPSIS}, '...')`;
|
|
||||||
|
|
||||||
export type BrowseTreeSearch = {
|
|
||||||
/** Matches AND every ancestor of a match, so the UI has a whole tree to render, not orphans. */
|
|
||||||
nodes: BrowseTreeNode[];
|
|
||||||
/** `name`s of the rows that actually matched — the rest of `nodes` are there to hold them up. */
|
|
||||||
matched: string[];
|
|
||||||
/** Matches in the snapshot, which exceeds `matched.length` when the cap kicked in. */
|
|
||||||
total: number;
|
|
||||||
truncated: boolean;
|
|
||||||
};
|
|
||||||
type BrowseTreeSearchParams = { userId: number; username: string; q: string; limit: number };
|
|
||||||
|
|
||||||
const SEP = '\\';
|
|
||||||
|
|
||||||
/** Every path above `name`, longest first: 'a\b\c' → ['a\b', 'a']. */
|
|
||||||
function ancestorsOf(name: string): string[] {
|
|
||||||
const out: string[] = [];
|
|
||||||
for (let cut = name.lastIndexOf(SEP); cut > 0; cut = name.lastIndexOf(SEP, cut - 1)) {
|
|
||||||
out.push(name.slice(0, cut));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filtered view of the tree. Filtering never flattens it: matches come back with their full ancestor
|
|
||||||
* chains, which is what lets the UI open straight down to a hit and keep the shape visible around it.
|
|
||||||
*
|
|
||||||
* Ancestors are resolved by string prefix rather than a recursive CTE — the parent's path IS a prefix of
|
|
||||||
* the child's, so one indexed `name in (…)` lookup does what a walk would take `depth` round trips to do.
|
|
||||||
*/
|
|
||||||
export async function searchSoulseekBrowseTree(params: BrowseTreeSearchParams): Promise<BrowseTreeSearch> {
|
|
||||||
const { userId, username, q, limit } = params;
|
|
||||||
const snapshotId = await snapshotIdOf(userId, username);
|
|
||||||
const term = q.trim();
|
|
||||||
if (snapshotId === null || !term) return { nodes: [], matched: [], total: 0, truncated: false };
|
|
||||||
|
|
||||||
// Words are AND-ed, each matched anywhere in the path, so 'pogues hell' finds
|
|
||||||
// '…\The Pogues\… - Hell's Ditch …' — a folder you can name from two ends but never type in full.
|
|
||||||
// ILIKE with a leading wildcard can't use the btree index, so this was already a scan of the snapshot's
|
|
||||||
// ~18k rows — which is why folding the column, once per word, costs nothing on top.
|
|
||||||
const words = term.split(/\s+/).filter(Boolean);
|
|
||||||
const where = and(
|
|
||||||
eq(soulseekBrowseDirs.snapshotId, snapshotId),
|
|
||||||
...words.map((word) => sql`${foldedName} ilike ${likePattern(word)}`),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [matches, [count]] = await Promise.all([
|
|
||||||
db.select(treeCols).from(soulseekBrowseDirs).where(where).orderBy(asc(soulseekBrowseDirs.name)).limit(limit),
|
|
||||||
db
|
|
||||||
.select({ total: sql<number>`count(*)::int` })
|
|
||||||
.from(soulseekBrowseDirs)
|
|
||||||
.where(where),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const have = new Set(matches.map((m) => m.name));
|
|
||||||
const wanted = new Set<string>();
|
|
||||||
for (const m of matches) for (const a of ancestorsOf(m.name)) if (!have.has(a)) wanted.add(a);
|
|
||||||
|
|
||||||
const ancestors = wanted.size
|
|
||||||
? await db
|
|
||||||
.select(treeCols)
|
|
||||||
.from(soulseekBrowseDirs)
|
|
||||||
.where(and(eq(soulseekBrowseDirs.snapshotId, snapshotId), inArray(soulseekBrowseDirs.name, [...wanted])))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const nodes = [...matches, ...ancestors].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
||||||
const total = count?.total ?? 0;
|
|
||||||
return { nodes, matched: matches.map((m) => m.name), total, truncated: total > matches.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One cached folder's files. Scoped by user so a guessed dir id can't read another account's cache. */
|
|
||||||
type DirFilesParams = { userId: number; username: string; dirId: number };
|
|
||||||
|
|
||||||
// Joined and scoped by BOTH owner and peer: a dir id alone would happily return another peer's folder
|
|
||||||
// (ids are global), so the username in the request URL has to be part of the predicate, not decoration.
|
|
||||||
export async function getSoulseekBrowseDirFiles({
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
dirId,
|
|
||||||
}: DirFilesParams): Promise<BrowsedFile[] | null> {
|
|
||||||
const [row] = await db
|
|
||||||
.select({ files: soulseekBrowseDirs.files })
|
|
||||||
.from(soulseekBrowseDirs)
|
|
||||||
.innerJoin(soulseekBrowseSnapshots, eq(soulseekBrowseDirs.snapshotId, soulseekBrowseSnapshots.id))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(soulseekBrowseDirs.id, dirId),
|
|
||||||
eq(soulseekBrowseSnapshots.userId, userId),
|
|
||||||
eq(soulseekBrowseSnapshots.username, username),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
return row ? (row.files as BrowsedFile[]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A file as slskd wants it enqueued: the peer's own full path, plus the size it advertised. */
|
|
||||||
export type BrowseDownloadFile = { filename: string; size: number };
|
|
||||||
type BrowseDownloadParams = { userId: number; username: string; path: string; file?: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a download request to the exact files to enqueue — one folder's subtree, or a single file.
|
|
||||||
*
|
|
||||||
* Sizes and paths come from the cache rather than the client: slskd matches a queued download on
|
|
||||||
* filename AND size, so a wrong number is a transfer that never starts. The stored file name is a
|
|
||||||
* basename (browse, unlike search, doesn't repeat the folder), so the peer's real path is rejoined here.
|
|
||||||
*/
|
|
||||||
export async function getSoulseekBrowseDownload({
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
path,
|
|
||||||
file,
|
|
||||||
}: BrowseDownloadParams): Promise<BrowseDownloadFile[]> {
|
|
||||||
const snapshotId = await snapshotIdOf(userId, username);
|
|
||||||
if (snapshotId === null) return [];
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({ name: soulseekBrowseDirs.name, files: soulseekBrowseDirs.files })
|
|
||||||
.from(soulseekBrowseDirs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(soulseekBrowseDirs.snapshotId, snapshotId),
|
|
||||||
// starts_with, not LIKE: these paths are full of backslashes, and one would be a LIKE escape.
|
|
||||||
file
|
|
||||||
? eq(soulseekBrowseDirs.name, path)
|
|
||||||
: sql`(${soulseekBrowseDirs.name} = ${path} or starts_with(${soulseekBrowseDirs.name}, ${path + SEP}))`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(soulseekBrowseDirs.name));
|
|
||||||
|
|
||||||
return rows.flatMap((row) =>
|
|
||||||
(row.files as BrowsedFile[])
|
|
||||||
.filter((f) => (file ? f.name === file : true))
|
|
||||||
.map((f) => ({ filename: `${row.name}${SEP}${f.name}`, size: f.size })),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop a peer's cache entirely (snapshot + folders, via the FK cascade). */
|
|
||||||
export async function deleteSoulseekBrowse(userId: number, username: string): Promise<void> {
|
|
||||||
await db
|
|
||||||
.delete(soulseekBrowseSnapshots)
|
|
||||||
.where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)));
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import {
|
|
||||||
pgTable,
|
|
||||||
serial,
|
|
||||||
integer,
|
|
||||||
bigint,
|
|
||||||
text,
|
|
||||||
timestamp,
|
|
||||||
index,
|
|
||||||
jsonb,
|
|
||||||
foreignKey,
|
|
||||||
uniqueIndex,
|
|
||||||
} from 'drizzle-orm/pg-core';
|
|
||||||
import { users } from '../auth/schema';
|
|
||||||
|
|
||||||
// Soulseek state that Officer owns because slskd has none. slskd exposes no favourites/buddy-list API
|
|
||||||
// (verified against 0.26.0's UsersController: only endpoint/browse/directory/info/status), so the peers
|
|
||||||
// the owner marks live here instead of in a forked daemon.
|
|
||||||
//
|
|
||||||
// Every table here is `soulseek_`-prefixed, and this file deliberately holds nothing else: when sidecars
|
|
||||||
// start owning their own schema, it moves wholesale into src/servers/sidecar/slskd/ with no untangling.
|
|
||||||
// Only the officer-slskd sidecar reads or writes these tables.
|
|
||||||
|
|
||||||
export const soulseekFavorites = pgTable(
|
|
||||||
'soulseek_favorites',
|
|
||||||
{
|
|
||||||
id: serial('id').primaryKey(),
|
|
||||||
userId: integer('user_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
|
||||||
username: text('username').notNull(),
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
},
|
|
||||||
// Also the lookup index — (user_id, username) covers "all of a user's favourites" by leftmost prefix.
|
|
||||||
(t) => [uniqueIndex('uq_soulseek_favorites_user_username').on(t.userId, t.username)],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Cached share trees ──
|
|
||||||
//
|
|
||||||
// A peer's share tree comes from slskd as ONE blocking response with every file of every folder inlined:
|
|
||||||
// measured at 59 MB / 18k folders / 284k files for a single real peer. Sending that to a browser is
|
|
||||||
// hopeless, and the browse is lost the moment the tab navigates away. So the sidecar fetches it in the
|
|
||||||
// background and caches it here, and the UI reads paginated slices instead.
|
|
||||||
//
|
|
||||||
// One snapshot row per (user, peer) — refreshes replace it rather than accumulating history.
|
|
||||||
|
|
||||||
export const soulseekBrowseSnapshots = pgTable(
|
|
||||||
'soulseek_browse_snapshots',
|
|
||||||
{
|
|
||||||
id: serial('id').primaryKey(),
|
|
||||||
userId: integer('user_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
|
||||||
username: text('username').notNull(),
|
|
||||||
// 'pending' while the sidecar is fetching, then 'ready' or 'failed'. A failed refresh keeps the
|
|
||||||
// previous rows, so a peer going offline never destroys a good cache.
|
|
||||||
status: text('status').notNull().default('pending'),
|
|
||||||
error: text('error'),
|
|
||||||
directoryCount: integer('directory_count').notNull().default(0),
|
|
||||||
fileCount: integer('file_count').notNull().default(0),
|
|
||||||
// Shares run to terabytes, well past int4.
|
|
||||||
totalSize: bigint('total_size', { mode: 'number' }).notNull().default(0),
|
|
||||||
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
|
|
||||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
|
||||||
},
|
|
||||||
(t) => [uniqueIndex('uq_soulseek_browse_snapshots_user_username').on(t.userId, t.username)],
|
|
||||||
);
|
|
||||||
|
|
||||||
// One row per FOLDER, with that folder's files inlined as jsonb — not a row per file, which would be
|
|
||||||
// 284k rows per peer. Folders are what you page and filter through; files are only ever read for the
|
|
||||||
// folder you opened, so they ride along in the row.
|
|
||||||
//
|
|
||||||
// slskd hands back a flat list of backslash-delimited paths, so the tree is derived once at ingest and
|
|
||||||
// stored here rather than recomputed per request: `parentPath` links a row to its parent and the subtree
|
|
||||||
// rollups are precomputed, which is what lets the UI open one level at a time. Measured on real peers,
|
|
||||||
// the tree is already complete in slskd's response — exactly one intermediate folder was missing across
|
|
||||||
// 30k rows — but ingest synthesizes any that are absent so a level can never dead-end.
|
|
||||||
export const soulseekBrowseDirs = pgTable(
|
|
||||||
'soulseek_browse_dirs',
|
|
||||||
{
|
|
||||||
id: serial('id').primaryKey(),
|
|
||||||
snapshotId: integer('snapshot_id').notNull(),
|
|
||||||
name: text('name').notNull(),
|
|
||||||
// null for a root. Its parent's `name`, i.e. `name` minus the last backslash segment.
|
|
||||||
parentPath: text('parent_path'),
|
|
||||||
// Segment count, 1-based, so the UI can indent without walking back up the chain.
|
|
||||||
depth: integer('depth').notNull().default(1),
|
|
||||||
// Last path segment — what a tree row actually displays.
|
|
||||||
label: text('label').notNull().default(''),
|
|
||||||
childCount: integer('child_count').notNull().default(0),
|
|
||||||
fileCount: integer('file_count').notNull().default(0),
|
|
||||||
totalSize: bigint('total_size', { mode: 'number' }).notNull().default(0),
|
|
||||||
// This folder AND everything under it. A parent's own file count is usually cover art; the number
|
|
||||||
// worth showing on a collapsed row is the whole subtree's.
|
|
||||||
subtreeFileCount: integer('subtree_file_count').notNull().default(0),
|
|
||||||
subtreeSize: bigint('subtree_size', { mode: 'number' }).notNull().default(0),
|
|
||||||
// BrowsedFile[] — slimmed at ingest to {name,size,extension}: slskd's browse response carries
|
|
||||||
// per-file `attributes` (bitrate/duration) that real peers leave empty, and dropping them plus the
|
|
||||||
// wire noise took the sample from 59 MB to 19 MB.
|
|
||||||
files: jsonb('files').notNull().default([]),
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
// Filtered search and the name lookups behind ancestor expansion.
|
|
||||||
index('idx_soulseek_browse_dirs_snapshot_name').on(t.snapshotId, t.name),
|
|
||||||
// The tree's hot path: one level, ordered by name. Covers "the roots" too, via a null parent_path.
|
|
||||||
index('idx_soulseek_browse_dirs_snapshot_parent').on(t.snapshotId, t.parentPath, t.name),
|
|
||||||
// Named explicitly: drizzle's derived name for this pair would be 64 chars, one over Postgres's
|
|
||||||
// 63-char identifier cap, so it would be silently truncated and never match the schema again.
|
|
||||||
foreignKey({
|
|
||||||
name: 'soulseek_browse_dirs_snapshot_id_fk',
|
|
||||||
columns: [t.snapshotId],
|
|
||||||
foreignColumns: [soulseekBrowseSnapshots.id],
|
|
||||||
}).onDelete('cascade'),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
|
||||||
|
|
||||||
// /api/slskd/* — auth, then forward to officer-slskd. No routes of its own and no slskd knowledge:
|
|
||||||
// this file must never grow app logic.
|
|
||||||
//
|
|
||||||
// Covers both slskd's own API (/api/v0/searches, /api/v0/transfers, …) and the sidecar's Officer-owned
|
|
||||||
// routes (/_officer/*, features slskd has no concept of). Both are opaque from here; the sidecar injects
|
|
||||||
// the slskd API key. The full contract is documented at the top of src/servers/sidecar/slskd/index.ts.
|
|
||||||
|
|
||||||
const proxy = createSidecarProxy({
|
|
||||||
name: 'slskd',
|
|
||||||
prefix: '/api/slskd',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const slskdRouter = proxy.router;
|
|
||||||
|
|
||||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
|
||||||
export const getSlskdServerUrl = proxy.getHttpUrl;
|
|
||||||
@@ -107,10 +107,11 @@ describe('member provisioning is declared for every entry', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('never promises accounts for a single-tenant daemon', () => {
|
it('never promises accounts for a single-tenant daemon', () => {
|
||||||
// slskd has no user concept; claiming otherwise would make the installer try to create accounts
|
// A daemon with no user concept; claiming otherwise would make the installer try to create accounts
|
||||||
// against an API that does not exist. (Transmission was the other example until 2026-08-18, when it
|
// against an API that does not exist. Transmission was an example until 2026-08-18 and slskd until
|
||||||
// became `plugins/transmission/`.)
|
// 2026-08-19, when they became `plugins/transmission/` and `plugins/soulseek/` — hence the list
|
||||||
for (const id of ['slskd']) expect(byId(id)!.members).toBe('none');
|
// rather than a single id: it must stay non-empty, or this assertion passes by iterating nothing.
|
||||||
|
for (const id of ['vnc', 'notify', 'gitea']) expect(byId(id)!.members).toBe('none');
|
||||||
});
|
});
|
||||||
|
|
||||||
// A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become
|
// A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become
|
||||||
|
|||||||
@@ -226,27 +226,6 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
// Deleting the entry is REQUIRED, not tidying — see the note where photos used to sit, and the trap in
|
// Deleting the entry is REQUIRED, not tidying — see the note where photos used to sit, and the trap in
|
||||||
// `EXTRACTING-A-PLUGIN.md`. A leftover entry makes the permission permanently `unavailable`, because
|
// `EXTRACTING-A-PLUGIN.md`. A leftover entry makes the permission permanently `unavailable`, because
|
||||||
// `permissionAvailability()` derives from `sidecar_installs` and a plugin never gets a row there.
|
// `permissionAvailability()` derives from `sidecar_installs` and a plugin never gets a row there.
|
||||||
{
|
|
||||||
id: 'slskd',
|
|
||||||
ui: {
|
|
||||||
name: 'Soulseek',
|
|
||||||
image: '/plugins/slskd/icon.png',
|
|
||||||
color: '#ffffff',
|
|
||||||
rootRoute: '/soulseek',
|
|
||||||
routes: ['/soulseek'],
|
|
||||||
},
|
|
||||||
process: 'officer-slskd',
|
|
||||||
label: 'Soulseek',
|
|
||||||
summary: 'slskd — search and download from the Soulseek network',
|
|
||||||
members: 'none',
|
|
||||||
modes: ['existing', 'provisioned'],
|
|
||||||
permission: 'soulseek',
|
|
||||||
composeTemplate: 'slskd',
|
|
||||||
existingFields: [
|
|
||||||
{ key: 'url', label: 'slskd URL', type: 'url', required: true },
|
|
||||||
{ key: 'secret', label: 'API key', type: 'secret', required: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'caldav',
|
id: 'caldav',
|
||||||
ui: {
|
ui: {
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import { byId } from './catalogue';
|
|||||||
// when something goes wrong: resume, blocking, and not doing work twice.
|
// when something goes wrong: resume, blocking, and not doing work twice.
|
||||||
|
|
||||||
const jellyfin = byId('jellyfin')!; // existing|provisioned, members: 'accounts'
|
const jellyfin = byId('jellyfin')!; // existing|provisioned, members: 'accounts'
|
||||||
const slskd = byId('slskd')!; // existing|provisioned, members: 'none'
|
// A LITERAL, not `byId(...)`. This was `byId('slskd')` until slskd became `plugins/soulseek/`, and
|
||||||
|
// before that the same assertion had to be re-pointed when photos and transmission left. No catalogue
|
||||||
|
// entry today is both `provisioned` and `members: 'none'`, so there is nothing left to borrow — and
|
||||||
|
// borrowing was the mistake anyway: this pins `planSteps`, which is installer logic, and it should not
|
||||||
|
// break because an unrelated service was extracted. Shaped from jellyfin so the rest of the entry stays
|
||||||
|
// realistic.
|
||||||
|
const noMemberConcept = { ...byId('jellyfin')!, id: 'test-single-tenant', members: 'none' as const };
|
||||||
const configOnly = byId('notify')!; // config only
|
const configOnly = byId('notify')!; // config only
|
||||||
|
|
||||||
/** Records what was actually called, so a test can assert on absence as well as presence. */
|
/** Records what was actually called, so a test can assert on absence as well as presence. */
|
||||||
@@ -46,7 +52,7 @@ describe('planSteps', () => {
|
|||||||
it('omits the members step for a service with no user concept', () => {
|
it('omits the members step for a service with no user concept', () => {
|
||||||
// Otherwise a Transmission install reports a members step that did nothing, which reads as a
|
// Otherwise a Transmission install reports a members step that did nothing, which reads as a
|
||||||
// silent failure to anyone debugging why a member has no access.
|
// silent failure to anyone debugging why a member has no access.
|
||||||
expect(planSteps(slskd, 'provisioned')).not.toContain('members');
|
expect(planSteps(noMemberConcept, 'provisioned')).not.toContain('members');
|
||||||
expect(planSteps(jellyfin, 'provisioned')).toContain('members');
|
expect(planSteps(jellyfin, 'provisioned')).toContain('members');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ describe('what adding a member would do', () => {
|
|||||||
it('predicts the outcome per service without contacting anything', () => {
|
it('predicts the outcome per service without contacting anything', () => {
|
||||||
expect(plannedOutcome(byId('jellyfin')!)).toBe('provisioned');
|
expect(plannedOutcome(byId('jellyfin')!)).toBe('provisioned');
|
||||||
expect(plannedOutcome(inviteOnly)).toBe('invited');
|
expect(plannedOutcome(inviteOnly)).toBe('invited');
|
||||||
expect(plannedOutcome(byId('slskd')!)).toBe('not-applicable');
|
expect(plannedOutcome(byId('vnc')!)).toBe('not-applicable');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('which installed services have work to do', () => {
|
describe('which installed services have work to do', () => {
|
||||||
it('skips single-tenant daemons entirely', () => {
|
it('skips single-tenant daemons entirely', () => {
|
||||||
const ids = servicesNeedingMemberProvision(['jellyfin', 'slskd', 'memos']).map((e) => e.id);
|
const ids = servicesNeedingMemberProvision(['jellyfin', 'vnc', 'memos']).map((e) => e.id);
|
||||||
expect(ids.sort()).toEqual(['jellyfin', 'memos']);
|
expect(ids.sort()).toEqual(['jellyfin', 'memos']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { devProxyRouter } from './api/dev-proxy/router';
|
|||||||
import { pluginsRouter } from './api/plugins/router';
|
import { pluginsRouter } from './api/plugins/router';
|
||||||
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
||||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||||
// import { slskdRouter } from './api/slskd/router';
|
|
||||||
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
||||||
// import { jellyfinRouter } from './api/jellyfin/router';
|
// import { jellyfinRouter } from './api/jellyfin/router';
|
||||||
// import { walletRouter } from './api/wallet/router';
|
// import { walletRouter } from './api/wallet/router';
|
||||||
@@ -141,7 +140,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
|||||||
['/api-keys', apiKeysRouter], // your own keys; the `account` core permission covers it
|
['/api-keys', apiKeysRouter], // your own keys; the `account` core permission covers it
|
||||||
['/dashboards', dashboardsRouter],
|
['/dashboards', dashboardsRouter],
|
||||||
['/file-browser', fileBrowserRouter],
|
['/file-browser', fileBrowserRouter],
|
||||||
// ['/slskd', slskdRouter], // plugin — switched off 2026-08-13
|
|
||||||
['/terminal', terminalRouter],
|
['/terminal', terminalRouter],
|
||||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||||
// ['/gitea', giteaRouter], // plugin — switched off 2026-08-13
|
// ['/gitea', giteaRouter], // plugin — switched off 2026-08-13
|
||||||
|
|||||||
@@ -200,15 +200,6 @@ const CORE_REGISTRY: Permission[] = [
|
|||||||
// Device registration is the caller's own — a phone subscribing to its own push channel.
|
// Device registration is the caller's own — a phone subscribing to its own push channel.
|
||||||
personal: ['/devices', '/subscriptions'],
|
personal: ['/devices', '/subscriptions'],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'soulseek',
|
|
||||||
label: 'Soulseek',
|
|
||||||
description: 'Search and download from the Soulseek network',
|
|
||||||
kind: 'app',
|
|
||||||
// api: ['/slskd'], // plugin — switched off 2026-08-13
|
|
||||||
api: [],
|
|
||||||
routes: ['/soulseek'],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'invoices',
|
key: 'invoices',
|
||||||
label: 'Invoices',
|
label: 'Invoices',
|
||||||
|
|||||||
@@ -149,6 +149,27 @@ const BUNDLED: CatalogueEntry[] = [
|
|||||||
// `schema: false` — the connection lives in `service_connections`, a core table. See its manifest.
|
// `schema: false` — the connection lives in `service_connections`, a core table. See its manifest.
|
||||||
has: { api: true, schema: false, sidecar: true, web: true },
|
has: { api: true, schema: false, sidecar: true, web: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
appName: 'soulseek',
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
label: 'Soulseek',
|
||||||
|
summary: 'Search and download from the Soulseek network, via your own slskd daemon',
|
||||||
|
color: '#22c55e',
|
||||||
|
icon: 'Music2',
|
||||||
|
source: 'https://gitea.officer.dev/plugins/soulseek.git',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'soulseek',
|
||||||
|
label: 'Soulseek',
|
||||||
|
description: 'Search and download from the Soulseek network',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// `schema: true` unlike transmission: the CONNECTION lives in core `service_connections`, but the
|
||||||
|
// favourites and the cached browse tree are this plugin's own three tables.
|
||||||
|
has: { api: true, schema: true, sidecar: true, web: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
appName: 'vault',
|
appName: 'vault',
|
||||||
publisher: 'officerdev',
|
publisher: 'officerdev',
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
import type { BrowseDirInput, BrowseDirRow, BrowsedFile } from 'officerdb';
|
|
||||||
import { startSoulseekBrowse, finishSoulseekBrowse, failSoulseekBrowse } from 'officerdb';
|
|
||||||
import { authHeaders, getSlskdConfig } from './upstream';
|
|
||||||
|
|
||||||
// Background share-tree fetches.
|
|
||||||
//
|
|
||||||
// slskd's GET /users/{u}/browse is a single blocking call that inlines every file of every folder —
|
|
||||||
// measured at 59 MB / 18k folders / 284k files for one real peer, and it can take minutes because it
|
|
||||||
// round-trips to that peer over the Soulseek network. Doing it from the browser means a tab that hangs
|
|
||||||
// and throws the result away on navigation.
|
|
||||||
//
|
|
||||||
// So it runs HERE instead: the route kicks this off and returns immediately, the fetch outlives the
|
|
||||||
// request (and the tab), and the result lands in Postgres for the UI to page through. The sidecar is a
|
|
||||||
// long-lived PM2 process, which is what makes "come back later and it's there" work at all.
|
|
||||||
|
|
||||||
// slskd's wire shape. Files carry `attributes` (bitrate/duration) that real peers leave empty, plus
|
|
||||||
// `code`/`attributeCount` noise; all of it is dropped at ingest.
|
|
||||||
type SlskdBrowseFile = { filename: string; size?: number; extension?: string };
|
|
||||||
type SlskdBrowseDir = { name: string; fileCount?: number; files?: SlskdBrowseFile[] };
|
|
||||||
type SlskdBrowsePayload = { directories?: SlskdBrowseDir[] } | SlskdBrowseDir[];
|
|
||||||
|
|
||||||
// One browse per (user, peer) at a time. A second click while one is running is a no-op rather than a
|
|
||||||
// duplicate multi-minute fetch of the same tree.
|
|
||||||
const inFlight = new Map<string, Promise<void>>();
|
|
||||||
const keyOf = (userId: number, username: string) => `${userId}:${username}`;
|
|
||||||
|
|
||||||
/** Peers currently being fetched, so a caller can report "already running" without racing the map. */
|
|
||||||
export const isBrowseRunning = (userId: number, username: string): boolean => inFlight.has(keyOf(userId, username));
|
|
||||||
|
|
||||||
// Generous, but not unbounded: a peer that accepts the connection and then stalls shouldn't pin a
|
|
||||||
// snapshot in 'pending' forever.
|
|
||||||
const BROWSE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
const extensionOf = (file: SlskdBrowseFile): string => {
|
|
||||||
const raw = file.extension?.trim();
|
|
||||||
if (raw) return raw.replace(/^\./, '').toLowerCase();
|
|
||||||
const dot = file.filename.lastIndexOf('.');
|
|
||||||
return dot > 0 ? file.filename.slice(dot + 1).toLowerCase() : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip the response to what the UI actually renders. Locked directories are skipped: they're shares the
|
|
||||||
* peer won't serve us, so listing them would only offer downloads that always fail.
|
|
||||||
*/
|
|
||||||
function slim(payload: SlskdBrowsePayload): BrowseDirInput[] {
|
|
||||||
const dirs = Array.isArray(payload) ? payload : (payload?.directories ?? []);
|
|
||||||
return dirs.map((dir) => {
|
|
||||||
const files: BrowsedFile[] = (dir.files ?? []).map((f) => ({
|
|
||||||
// Browse gives a basename only, unlike search results' full remote path. Downloading one means
|
|
||||||
// rejoining it to the directory name with a backslash.
|
|
||||||
name: f.filename,
|
|
||||||
size: f.size ?? 0,
|
|
||||||
extension: extensionOf(f),
|
|
||||||
}));
|
|
||||||
return { name: dir.name, files };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEP = '\\';
|
|
||||||
|
|
||||||
/** Split a slskd path into the parent path and the last segment, ignoring any trailing separator. */
|
|
||||||
function splitPath(name: string): { parentPath: string | null; label: string; depth: number } {
|
|
||||||
const trimmed = name.endsWith(SEP) ? name.slice(0, -1) : name;
|
|
||||||
const cut = trimmed.lastIndexOf(SEP);
|
|
||||||
const depth = trimmed.split(SEP).filter(Boolean).length || 1;
|
|
||||||
// `cut > 0` not `>= 0`: a leading separator would otherwise make the parent an empty string, which is
|
|
||||||
// neither null nor a real row, and the level query would never find it.
|
|
||||||
return { parentPath: cut > 0 ? trimmed.slice(0, cut) : null, label: trimmed.slice(cut + 1), depth };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turn slskd's flat list of paths into a linked tree, once, here — so the UI can open one level at a
|
|
||||||
* time instead of the platform recomputing the shape on every request.
|
|
||||||
*
|
|
||||||
* Two things the response can't be trusted on. Ancestors: measured across two real peers (~30k folders)
|
|
||||||
* exactly one intermediate folder was missing, so they're nearly always present, but a single gap would
|
|
||||||
* make a whole subtree unreachable — hence `ensure` synthesizes any absent one as an empty folder.
|
|
||||||
* Rollups: a parent's own file count is usually just cover art, so the number worth showing on a
|
|
||||||
* collapsed row is the subtree's, and that's summed bottom-up here rather than per request.
|
|
||||||
*/
|
|
||||||
export function buildTree(dirs: BrowseDirInput[]): BrowseDirRow[] {
|
|
||||||
const byName = new Map<string, BrowseDirRow>();
|
|
||||||
|
|
||||||
const ensure = (name: string): BrowseDirRow => {
|
|
||||||
const existing = byName.get(name);
|
|
||||||
if (existing) return existing;
|
|
||||||
const { parentPath, label, depth } = splitPath(name);
|
|
||||||
const row: BrowseDirRow = {
|
|
||||||
name,
|
|
||||||
parentPath,
|
|
||||||
label,
|
|
||||||
depth,
|
|
||||||
childCount: 0,
|
|
||||||
fileCount: 0,
|
|
||||||
totalSize: 0,
|
|
||||||
subtreeFileCount: 0,
|
|
||||||
subtreeSize: 0,
|
|
||||||
files: [],
|
|
||||||
};
|
|
||||||
// Set before recursing so a cycle-shaped path can't loop, and so the parent's own ancestors follow.
|
|
||||||
byName.set(name, row);
|
|
||||||
if (parentPath) ensure(parentPath);
|
|
||||||
return row;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const dir of dirs) {
|
|
||||||
const row = ensure(dir.name);
|
|
||||||
// Concatenated rather than assigned: a path repeated in the response would otherwise lose files.
|
|
||||||
row.files = row.files.length ? row.files.concat(dir.files) : dir.files;
|
|
||||||
row.fileCount = row.files.length;
|
|
||||||
row.totalSize = row.files.reduce((n, f) => n + (f.size || 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = [...byName.values()];
|
|
||||||
for (const row of rows) {
|
|
||||||
row.subtreeFileCount = row.fileCount;
|
|
||||||
row.subtreeSize = row.totalSize;
|
|
||||||
}
|
|
||||||
// Deepest first, so every child is final before its parent absorbs it.
|
|
||||||
for (const row of [...rows].sort((a, b) => b.depth - a.depth)) {
|
|
||||||
const parent = row.parentPath ? byName.get(row.parentPath) : undefined;
|
|
||||||
if (!parent) continue;
|
|
||||||
parent.childCount += 1;
|
|
||||||
parent.subtreeFileCount += row.subtreeFileCount;
|
|
||||||
parent.subtreeSize += row.subtreeSize;
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run(userId: number, username: string, snapshotId: number): Promise<void> {
|
|
||||||
const cfg = await getSlskdConfig(userId);
|
|
||||||
if (!cfg) {
|
|
||||||
await failSoulseekBrowse(snapshotId, 'slskd upstream not configured');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const started = Date.now();
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${cfg.base}/api/v0/users/${encodeURIComponent(username)}/browse`, {
|
|
||||||
headers: authHeaders(cfg),
|
|
||||||
signal: AbortSignal.timeout(BROWSE_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = (await res.text().catch(() => '')).slice(0, 200);
|
|
||||||
throw new Error(`slskd returned ${res.status}${detail ? `: ${detail}` : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dirs = slim((await res.json()) as SlskdBrowsePayload);
|
|
||||||
const tree = buildTree(dirs);
|
|
||||||
await finishSoulseekBrowse(snapshotId, tree);
|
|
||||||
const files = dirs.reduce((n, d) => n + d.files.length, 0);
|
|
||||||
// Against distinct names, not the array length, so a repeated path doesn't read as a negative.
|
|
||||||
const synthesized = tree.length - new Set(dirs.map((d) => d.name)).size;
|
|
||||||
console.log(
|
|
||||||
`[slskd] browse ${username} -> ${tree.length} dirs (${synthesized} synthesized) / ${files} files in ${Date.now() - started}ms`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
console.error(`[slskd] browse ${username} failed after ${Date.now() - started}ms: ${message}`);
|
|
||||||
await failSoulseekBrowse(snapshotId, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start caching a peer's share tree and return once it's *registered*, not once it's done — the caller
|
|
||||||
* responds 202 and the fetch continues in the background. Resolves false if one was already running.
|
|
||||||
*/
|
|
||||||
export async function startBrowse(userId: number, username: string): Promise<boolean> {
|
|
||||||
const key = keyOf(userId, username);
|
|
||||||
if (inFlight.has(key)) return false;
|
|
||||||
|
|
||||||
const snapshotId = await startSoulseekBrowse(userId, username);
|
|
||||||
// Registered before awaiting anything inside run(), so two rapid clicks can't both get past the check.
|
|
||||||
const task = run(userId, username, snapshotId).finally(() => inFlight.delete(key));
|
|
||||||
inFlight.set(key, task);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import type { SlskdConfig } from './upstream';
|
|
||||||
import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
|
|
||||||
import { authHeaders, getSlskdConfig, invalidateSlskdConfig, normalizeBase } from './upstream';
|
|
||||||
|
|
||||||
// `/_config` — where the slskd daemon is and what key opens it, driven from the app rather than from a
|
|
||||||
// shell on the server.
|
|
||||||
//
|
|
||||||
// The API key is WRITE-ONLY across this boundary. The GET reports the URL and whether a key is stored; it
|
|
||||||
// has no field that could carry the key itself, masked or otherwise. The only way to change one is to send
|
|
||||||
// a new one, which is the same shape the photos and offscale registries use.
|
|
||||||
//
|
|
||||||
// ONE connection, not a registry: nobody runs two Soulseek daemons. So there is no label, no id in the
|
|
||||||
// path and no activate route — a save is an upsert over the single row, and the UI is one form.
|
|
||||||
//
|
|
||||||
// A save is validated against the live daemon before it is stored: a wrong URL or a rejected key is a 400
|
|
||||||
// with the reason, not a saved row that makes every later panel fail mysteriously.
|
|
||||||
|
|
||||||
export type ProbeResult =
|
|
||||||
| { ok: true; version: string | null; server: string | null }
|
|
||||||
| { ok: false; version: string | null; error: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ask a daemon whether it is really there and whether the key works.
|
|
||||||
*
|
|
||||||
* Two calls, because they answer different questions: `/health` is unauthenticated, so a failure there means
|
|
||||||
* the URL is wrong or slskd is down, while `/api/v0/application` failing after it succeeded means the key is
|
|
||||||
* the problem. Collapsing them would report "daemon unreachable" for a mistyped key.
|
|
||||||
*/
|
|
||||||
export async function probe(cfg: SlskdConfig): Promise<ProbeResult> {
|
|
||||||
try {
|
|
||||||
const health = await fetch(`${cfg.base}/health`, { signal: AbortSignal.timeout(5000), redirect: 'manual' });
|
|
||||||
if (!health.ok) return { ok: false, version: null, error: `daemon returned ${health.status}` };
|
|
||||||
} catch (err) {
|
|
||||||
return { ok: false, version: null, error: `could not reach the daemon (${String(err)})` };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${cfg.base}/api/v0/application`, {
|
|
||||||
headers: { Accept: 'application/json', ...authHeaders(cfg) },
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
redirect: 'manual',
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const reason = res.status === 401 || res.status === 403 ? 'API key was rejected' : 'daemon returned';
|
|
||||||
return { ok: false, version: null, error: `${reason} (${res.status})` };
|
|
||||||
}
|
|
||||||
const app = (await res.json()) as {
|
|
||||||
version?: { current?: string; full?: string };
|
|
||||||
server?: { state?: string };
|
|
||||||
};
|
|
||||||
return { ok: true, version: app.version?.full ?? app.version?.current ?? null, server: app.server?.state ?? null };
|
|
||||||
} catch (err) {
|
|
||||||
return { ok: false, version: null, error: String(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the browser is allowed to know about the connection. Never includes the key. */
|
|
||||||
async function connectionState(userId: number): Promise<Response> {
|
|
||||||
const connection = await getServiceConnection(userId, 'slskd');
|
|
||||||
return Response.json({ configured: !!connection, connection });
|
|
||||||
}
|
|
||||||
|
|
||||||
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
|
||||||
|
|
||||||
type ConnectionBody = { url?: unknown; apiKey?: unknown };
|
|
||||||
|
|
||||||
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
|
|
||||||
|
|
||||||
/** Save the connection: validated against the live daemon, then stored with the key encrypted. */
|
|
||||||
async function save(req: Request, userId: number): Promise<Response> {
|
|
||||||
const body = ((await req.json().catch(() => null)) as ConnectionBody | null) ?? {};
|
|
||||||
const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
|
|
||||||
// A blank key on an existing connection means "keep the stored one", so changing the URL does not need
|
|
||||||
// the key re-typed. `undefined` is what saveServiceConnection reads as leave-alone.
|
|
||||||
const apiKey = typeof body.apiKey === 'string' && body.apiKey.trim() ? body.apiKey.trim() : undefined;
|
|
||||||
|
|
||||||
if (!url) return bad('url is required');
|
|
||||||
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
|
|
||||||
|
|
||||||
const current = await getServiceConnection(userId, 'slskd');
|
|
||||||
if (!apiKey && !current?.hasSecret) return bad('an API key is required');
|
|
||||||
|
|
||||||
// Probing needs the actual key, so a URL-only save borrows the stored one through the normal read path
|
|
||||||
// rather than reaching for the ciphertext here.
|
|
||||||
const stored = apiKey ? null : await getSlskdConfig(userId);
|
|
||||||
const result = await probe({ base: url, apiKey: apiKey ?? stored?.apiKey ?? null });
|
|
||||||
if (!result.ok) return Response.json({ error: result.error }, { status: 400 });
|
|
||||||
|
|
||||||
const connection = await saveServiceConnection({
|
|
||||||
userId,
|
|
||||||
service: 'slskd',
|
|
||||||
url,
|
|
||||||
secret: apiKey,
|
|
||||||
version: result.version,
|
|
||||||
});
|
|
||||||
invalidateSlskdConfig(userId);
|
|
||||||
return Response.json({ connection, server: result.server });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `/_config` — GET the connection, PUT/POST to save it, DELETE to forget it. */
|
|
||||||
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
|
|
||||||
if (subpath && subpath !== '/') return bad('not found', 404);
|
|
||||||
|
|
||||||
if (req.method === 'GET') return connectionState(userId);
|
|
||||||
if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);
|
|
||||||
|
|
||||||
if (req.method === 'DELETE') {
|
|
||||||
await deleteServiceConnection(userId, 'slskd');
|
|
||||||
invalidateSlskdConfig(userId);
|
|
||||||
return connectionState(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return bad('method not allowed', 405);
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { getSoulseekBrowseDownload } from 'officerdb';
|
|
||||||
import { authHeaders, getSlskdConfig } from './upstream';
|
|
||||||
|
|
||||||
// Enqueueing downloads out of a cached share tree.
|
|
||||||
//
|
|
||||||
// slskd's own enqueue endpoint takes a flat list of {filename, size}, which is fine for search results —
|
|
||||||
// they arrive as individual files with full paths. A browsed folder is neither: it's a path, and what the
|
|
||||||
// user means by clicking it is "everything under here". Expanding that into a file list needs the cache,
|
|
||||||
// so it happens HERE rather than in the browser, which would otherwise have to walk the tree one level
|
|
||||||
// request at a time and reconstruct paths it only half knows.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A folder can be enormous — the root of one measured peer is 284,166 files. Enqueueing that would be a
|
|
||||||
* misclick that takes the slskd instance down with it, so it's refused with the count rather than
|
|
||||||
* truncated to a silent partial download.
|
|
||||||
*/
|
|
||||||
export const MAX_ENQUEUE = 10_000;
|
|
||||||
|
|
||||||
export class DownloadError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
readonly status: number,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type EnqueueParams = { userId: number; username: string; path: string; file?: string };
|
|
||||||
|
|
||||||
/** Queue one file, or every file beneath one folder, from the cached tree. Returns how many were sent. */
|
|
||||||
export async function enqueueFromCache({ userId, username, path, file }: EnqueueParams): Promise<number> {
|
|
||||||
const cfg = await getSlskdConfig(userId);
|
|
||||||
if (!cfg) throw new DownloadError('slskd upstream not configured', 503);
|
|
||||||
|
|
||||||
const files = await getSoulseekBrowseDownload({ userId, username, path, file });
|
|
||||||
if (!files.length) throw new DownloadError('nothing to download at that path', 404);
|
|
||||||
if (files.length > MAX_ENQUEUE) {
|
|
||||||
throw new DownloadError(
|
|
||||||
`${files.length.toLocaleString()} files is over the ${MAX_ENQUEUE.toLocaleString()} limit`,
|
|
||||||
413,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${cfg.base}/api/v0/transfers/downloads/${encodeURIComponent(username)}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json', ...authHeaders(cfg) },
|
|
||||||
body: JSON.stringify(files),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const detail = (await res.text().catch(() => '')).slice(0, 200);
|
|
||||||
throw new DownloadError(`slskd returned ${res.status}${detail ? `: ${detail}` : ''}`, 502);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[slskd] queued ${files.length} file(s) from ${username}: ${file ?? path}`);
|
|
||||||
return files.length;
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
|
||||||
import { createSidecarConnector } from '../connect';
|
|
||||||
import { resetStaleSoulseekBrowses } from 'officerdb';
|
|
||||||
import { getSlskdConfig, stripHopByHop } from './upstream';
|
|
||||||
import { handleConfigRoute, probe } from './config';
|
|
||||||
import { handleOfficerRoute } from './officer';
|
|
||||||
import { API_URL } from '../../officer-url.mjs';
|
|
||||||
|
|
||||||
// The officer-slskd sidecar. Same philosophy as officer-vault / officer-music: a singleton process that
|
|
||||||
// registers with the API server and OWNS a contract — here, a reverse-proxy to a self-hosted slskd
|
|
||||||
// (Soulseek daemon). ALL knowledge of slskd (its URL, its API key) lives here; the platform API is a
|
|
||||||
// thin, auth-gated forwarder that never sees the slskd credential. This lets the Soulseek client grow
|
|
||||||
// end-to-end without the main server accreting any slskd-specific logic.
|
|
||||||
//
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
// HTTP CONTRACT — the platform strips its /api/slskd mount prefix before forwarding, so requests arrive
|
|
||||||
// here as slskd-root paths (e.g. /api/v0/searches, /api/v0/transfers, /api/v0/session). We inject
|
|
||||||
// `X-API-Key` and pass method, path, query, headers, status and BOTH body streams through verbatim.
|
|
||||||
// The server listens on a random loopback port, reported to the API on connect so it can route here.
|
|
||||||
//
|
|
||||||
// GET /_health ours (probes slskd's /health), not part of the slskd contract
|
|
||||||
// GET /_config { configured, connection } — the URL and whether a key is stored, never the key
|
|
||||||
// PUT /_config { url, apiKey? } — validated against the daemon, then stored encrypted. A blank
|
|
||||||
// apiKey on an existing connection keeps the stored one
|
|
||||||
// DEL /_config forget the connection
|
|
||||||
//
|
|
||||||
// Paths under `/_officer/` are also ours and are NOT forwarded: they're the features slskd has no concept
|
|
||||||
// of (favourite peers, …), served straight from Postgres. See officer.ts for that contract. Keeping them
|
|
||||||
// here rather than in the platform API is what lets the main server stay a pure proxy forever.
|
|
||||||
//
|
|
||||||
// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind
|
|
||||||
// loopback only, so its presence is the trust signal — a request without it did not come through the
|
|
||||||
// platform, and the connection is per-owner data.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// TODO (next iteration): slskd streams live search results + transfer progress over SignalR hubs at
|
|
||||||
// /hub/*. Add a WebSocket upgrade path here mirroring the officer-vault notifications proxy
|
|
||||||
// (src/servers/sidecar/vault/index.ts) once the client needs real-time updates. SignalR carries its
|
|
||||||
// credential as an `?access_token=` query param on the socket, so the key injection differs from HTTP.
|
|
||||||
|
|
||||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
|
||||||
function getFreePort(): number {
|
|
||||||
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
|
||||||
const p = probeServer.port;
|
|
||||||
probeServer.stop(true);
|
|
||||||
if (p == null) throw new Error('failed to acquire a free port');
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
const port = getFreePort();
|
|
||||||
|
|
||||||
const server = Bun.serve({
|
|
||||||
port,
|
|
||||||
hostname: '127.0.0.1',
|
|
||||||
idleTimeout: 255, // browse/search can idle; Bun caps this at 255s
|
|
||||||
maxRequestBodySize: 1024 * 1024 * 1024, // room for uploads / large browse responses
|
|
||||||
async fetch(req) {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
|
|
||||||
const officerUser = req.headers.get('X-Officer-User');
|
|
||||||
const userId = Number(officerUser);
|
|
||||||
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
|
|
||||||
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
|
|
||||||
try {
|
|
||||||
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[slskd] ${req.method} ${url.pathname} failed`, err);
|
|
||||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = await getSlskdConfig(userId);
|
|
||||||
|
|
||||||
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
|
|
||||||
// configured-but-broken daemon is the whole reason the flag is on the response.
|
|
||||||
if (url.pathname === '/_health') {
|
|
||||||
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
|
|
||||||
const started = Date.now();
|
|
||||||
const result = await probe(cfg);
|
|
||||||
const ms = Date.now() - started;
|
|
||||||
if (!result.ok) return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 });
|
|
||||||
return Response.json({ ok: true, configured: true, version: result.version, server: result.server, ms });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Officer-owned routes — answered locally, never proxied (so they work even with slskd down).
|
|
||||||
if (url.pathname.startsWith('/_officer/')) {
|
|
||||||
try {
|
|
||||||
const res = await handleOfficerRoute(req, url);
|
|
||||||
if (res) return res;
|
|
||||||
return new Response('not found', { status: 404 });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[slskd] ${req.method} ${url.pathname} failed`, err);
|
|
||||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cfg) return Response.json({ error: 'slskd not connected', configured: false }, { status: 503 });
|
|
||||||
|
|
||||||
const target = `${cfg.base}${url.pathname}${url.search}`;
|
|
||||||
const method = req.method;
|
|
||||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|
||||||
const started = Date.now();
|
|
||||||
|
|
||||||
const headers = stripHopByHop(req.headers);
|
|
||||||
if (cfg.apiKey) headers.set('X-API-Key', cfg.apiKey);
|
|
||||||
|
|
||||||
// Bun/undici require half-duplex to stream a request body straight through.
|
|
||||||
const init: RequestInit & { duplex?: 'half' } = {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body: hasBody ? req.body : undefined,
|
|
||||||
redirect: 'manual', // a transparent proxy passes 3xx through rather than following them
|
|
||||||
};
|
|
||||||
if (hasBody) init.duplex = 'half';
|
|
||||||
|
|
||||||
let upstream: Response;
|
|
||||||
try {
|
|
||||||
upstream = await fetch(target, init);
|
|
||||||
} catch {
|
|
||||||
console.error(`[slskd] ${method} ${url.pathname} -> upstream unreachable`);
|
|
||||||
return new Response('slskd upstream unreachable', { status: 502 });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[slskd] ${method} ${url.pathname} -> ${upstream.status} ${Date.now() - started}ms`);
|
|
||||||
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// No upstream in the banner: where slskd lives is now per-owner state read from the database per request,
|
|
||||||
// not a constant this process knows at boot.
|
|
||||||
console.log(`[slskd] reverse-proxy listening on 127.0.0.1:${port}`);
|
|
||||||
|
|
||||||
// A background share-tree fetch dies with this process, so anything left 'pending' from the previous life
|
|
||||||
// would spin in the UI forever. Clear it once, at boot, before serving.
|
|
||||||
resetStaleSoulseekBrowses()
|
|
||||||
.then((n) => n && console.log(`[slskd] failed ${n} browse snapshot(s) interrupted by restart`))
|
|
||||||
.catch((err) => console.error('[slskd] could not reset stale browse snapshots', err));
|
|
||||||
|
|
||||||
// ── Command handlers ──
|
|
||||||
|
|
||||||
type ReplyFn = (msg: SidecarEvent) => void;
|
|
||||||
|
|
||||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|
||||||
switch (cmd.type) {
|
|
||||||
case 'ping':
|
|
||||||
reply({ type: 'pong', id: cmd.id });
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
reply({
|
|
||||||
type: 'error',
|
|
||||||
id: (cmd as SidecarCommand).id,
|
|
||||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Connect to API server ──
|
|
||||||
|
|
||||||
const connection = createSidecarConnector({
|
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
|
||||||
name: 'slskd',
|
|
||||||
handles: ['slskd'],
|
|
||||||
onCommand(cmd, reply) {
|
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
|
||||||
},
|
|
||||||
onConnected() {
|
|
||||||
// Tell the API where our proxy is listening, so it can forward /api/slskd/* here.
|
|
||||||
connection.send({ type: 'slskd:server', port });
|
|
||||||
console.log(`[slskd] reported proxy port ${port} to API`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Graceful shutdown ──
|
|
||||||
|
|
||||||
function shutdown(signal: string) {
|
|
||||||
console.log(`[slskd] ${signal} received, shutting down...`);
|
|
||||||
try {
|
|
||||||
server.stop(true);
|
|
||||||
} catch {
|
|
||||||
/* already stopped */
|
|
||||||
}
|
|
||||||
connection.destroy();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import {
|
|
||||||
getSoulseekFavorites,
|
|
||||||
addSoulseekFavorite,
|
|
||||||
removeSoulseekFavorite,
|
|
||||||
getSoulseekBrowseSnapshots,
|
|
||||||
getSoulseekBrowseSnapshot,
|
|
||||||
getSoulseekBrowseLevel,
|
|
||||||
searchSoulseekBrowseTree,
|
|
||||||
getSoulseekBrowseDirFiles,
|
|
||||||
deleteSoulseekBrowse,
|
|
||||||
} from 'officerdb';
|
|
||||||
import { startBrowse } from './browse';
|
|
||||||
import { enqueueFromCache, DownloadError } from './download';
|
|
||||||
|
|
||||||
// Officer-owned Soulseek routes — everything slskd itself has no concept of. These are served HERE, by
|
|
||||||
// the sidecar, not forwarded upstream: the platform API stays a pure auth-and-forward proxy forever, and
|
|
||||||
// slskd stays unforked. They live under the `/_officer/` prefix, mirroring `/_health`, so they can never
|
|
||||||
// collide with slskd's own surface (all of which is under `/api/v0/`).
|
|
||||||
//
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
// CONTRACT (reached from the browser as /api/slskd/_officer/…)
|
|
||||||
// GET /_officer/favorites → string[] favourited peers, alphabetical
|
|
||||||
// POST /_officer/favorites { username } → { ok: true } add (idempotent)
|
|
||||||
// DELETE /_officer/favorites?username=<u> → { ok: true } remove (no-op if absent)
|
|
||||||
//
|
|
||||||
// GET /_officer/browse → Snapshot[] every cached share tree
|
|
||||||
// GET /_officer/browse/<u> → Snapshot | null one peer's cache state
|
|
||||||
// POST /_officer/browse/<u> → 202 { started } kick off a background fetch
|
|
||||||
// DELETE /_officer/browse/<u> → { ok: true } drop the cache
|
|
||||||
// GET /_officer/browse/<u>/tree?parent=&limit=&offset= → { nodes, total } one level (roots if no parent)
|
|
||||||
// GET /_officer/browse/<u>/tree/search?q=&limit= → { nodes, matched, total, truncated }
|
|
||||||
// GET /_officer/browse/<u>/dirs/<id>/files → BrowsedFile[] one folder's files
|
|
||||||
// POST /_officer/browse/<u>/download { path, file? } → { queued } enqueue a folder, or one file
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// The authenticated user id arrives in X-Officer-User, injected by the platform proxy after auth. We
|
|
||||||
// bind loopback only, so it's trusted — same arrangement as the music sidecar's per-user state routes.
|
|
||||||
const userIdOf = (req: Request): number | null => {
|
|
||||||
const n = Number(req.headers.get('x-officer-user'));
|
|
||||||
return Number.isInteger(n) && n > 0 ? n : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Soulseek usernames are opaque to us and may contain spaces, so the only shapes we reject are ones
|
|
||||||
// no nick can have: empty, absurdly long, or containing control characters.
|
|
||||||
const USERNAME_MAX = 255;
|
|
||||||
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
||||||
const cleanUsername = (v: unknown): string | null => {
|
|
||||||
if (typeof v !== 'string') return null;
|
|
||||||
const u = v.trim();
|
|
||||||
if (!u || u.length > USERNAME_MAX) return null;
|
|
||||||
return CONTROL_CHARS.test(u) ? null : u;
|
|
||||||
};
|
|
||||||
|
|
||||||
// A level, not a page: once you're inside a folder there's nothing to leaf through, so the UI asks for
|
|
||||||
// the whole level when it's wide. The cap is set above the widest fan-out measured on a real peer (1,181).
|
|
||||||
const DIR_PAGE_MAX = 2000;
|
|
||||||
const DIR_PAGE_DEFAULT = 200;
|
|
||||||
// A match cap, not a page: every match drags its ancestors along, so the response is up to ~depth times
|
|
||||||
// this. Truncation is reported in the payload rather than passed off as the whole answer.
|
|
||||||
const SEARCH_MATCH_MAX = 1000;
|
|
||||||
const SEARCH_MATCH_DEFAULT = 300;
|
|
||||||
const clampInt = (raw: string | null, fallback: number, min: number, max: number): number => {
|
|
||||||
// An absent param has to be caught before Number(): `Number(null)` is 0, not NaN, so it passed the
|
|
||||||
// finite check and clamped to `min` — which ran the tree search with LIMIT 1 and returned a single
|
|
||||||
// match, making its siblings look like non-matches in the UI.
|
|
||||||
if (raw === null || raw.trim() === '') return fallback;
|
|
||||||
const n = Number(raw);
|
|
||||||
if (!Number.isFinite(n)) return fallback;
|
|
||||||
return Math.min(max, Math.max(min, Math.trunc(n)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const badRequest = (error: string) => Response.json({ error }, { status: 400 });
|
|
||||||
const notFound = () => new Response('not found', { status: 404 });
|
|
||||||
const methodNotAllowed = () => new Response('method not allowed', { status: 405 });
|
|
||||||
|
|
||||||
async function handleFavorites(req: Request, url: URL, userId: number): Promise<Response> {
|
|
||||||
if (req.method === 'GET') return Response.json(await getSoulseekFavorites(userId));
|
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
const body = (await req.json().catch(() => null)) as { username?: unknown } | null;
|
|
||||||
const username = cleanUsername(body?.username);
|
|
||||||
if (!username) return badRequest('username required');
|
|
||||||
await addSoulseekFavorite(userId, username);
|
|
||||||
return Response.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'DELETE') {
|
|
||||||
const username = cleanUsername(url.searchParams.get('username'));
|
|
||||||
if (!username) return badRequest('username required');
|
|
||||||
await removeSoulseekFavorite(userId, username);
|
|
||||||
return Response.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return methodNotAllowed();
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrowseRouteParams = { req: Request; url: URL; userId: number; segments: string[] };
|
|
||||||
|
|
||||||
async function handleBrowse({ req, url, userId, segments }: BrowseRouteParams): Promise<Response> {
|
|
||||||
// /_officer/browse — every cached tree, for badging the favourites list in one request.
|
|
||||||
if (segments.length === 1) {
|
|
||||||
if (req.method !== 'GET') return methodNotAllowed();
|
|
||||||
return Response.json(await getSoulseekBrowseSnapshots(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const username = cleanUsername(decodeURIComponent(segments[1] ?? ''));
|
|
||||||
if (!username) return badRequest('username required');
|
|
||||||
|
|
||||||
// /_officer/browse/<username>
|
|
||||||
if (segments.length === 2) {
|
|
||||||
if (req.method === 'GET') return Response.json(await getSoulseekBrowseSnapshot(userId, username));
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
// 202: the fetch takes minutes and outlives this request. Poll the GET for status.
|
|
||||||
const started = await startBrowse(userId, username);
|
|
||||||
return Response.json({ started }, { status: 202 });
|
|
||||||
}
|
|
||||||
if (req.method === 'DELETE') {
|
|
||||||
await deleteSoulseekBrowse(userId, username);
|
|
||||||
return Response.json({ ok: true });
|
|
||||||
}
|
|
||||||
return methodNotAllowed();
|
|
||||||
}
|
|
||||||
|
|
||||||
// /_officer/browse/<username>/tree — one level of folders, paged. No `parent` means the roots.
|
|
||||||
if (segments[2] === 'tree' && segments.length === 3) {
|
|
||||||
if (req.method !== 'GET') return methodNotAllowed();
|
|
||||||
const level = await getSoulseekBrowseLevel({
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
parent: url.searchParams.get('parent') ?? undefined,
|
|
||||||
limit: clampInt(url.searchParams.get('limit'), DIR_PAGE_DEFAULT, 1, DIR_PAGE_MAX),
|
|
||||||
offset: clampInt(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER),
|
|
||||||
});
|
|
||||||
return Response.json(level);
|
|
||||||
}
|
|
||||||
|
|
||||||
// /_officer/browse/<username>/tree/search — matches plus their ancestors, as one small tree. Kept off
|
|
||||||
// the level route because the shape differs: this one is a skeleton spanning depths, not a page.
|
|
||||||
if (segments[2] === 'tree' && segments.length === 4 && segments[3] === 'search') {
|
|
||||||
if (req.method !== 'GET') return methodNotAllowed();
|
|
||||||
const result = await searchSoulseekBrowseTree({
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
q: url.searchParams.get('q') ?? '',
|
|
||||||
limit: clampInt(url.searchParams.get('limit'), SEARCH_MATCH_DEFAULT, 1, SEARCH_MATCH_MAX),
|
|
||||||
});
|
|
||||||
return Response.json(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// /_officer/browse/<username>/download — a folder's whole subtree, or one file inside it.
|
|
||||||
if (segments[2] === 'download' && segments.length === 3) {
|
|
||||||
if (req.method !== 'POST') return methodNotAllowed();
|
|
||||||
const body = (await req.json().catch(() => null)) as { path?: unknown; file?: unknown } | null;
|
|
||||||
const path = typeof body?.path === 'string' ? body.path.trim() : '';
|
|
||||||
if (!path) return badRequest('path required');
|
|
||||||
const file = typeof body?.file === 'string' && body.file.trim() ? body.file : undefined;
|
|
||||||
try {
|
|
||||||
const queued = await enqueueFromCache({ userId, username, path, file });
|
|
||||||
return Response.json({ queued });
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof DownloadError) return Response.json({ error: err.message }, { status: err.status });
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segments[2] !== 'dirs') return notFound();
|
|
||||||
|
|
||||||
// /_officer/browse/<username>/dirs/<id>/files
|
|
||||||
if (segments.length === 5 && segments[4] === 'files') {
|
|
||||||
if (req.method !== 'GET') return methodNotAllowed();
|
|
||||||
const dirId = Number(segments[3]);
|
|
||||||
if (!Number.isInteger(dirId) || dirId <= 0) return badRequest('invalid directory id');
|
|
||||||
const files = await getSoulseekBrowseDirFiles({ userId, username, dirId });
|
|
||||||
return files ? Response.json(files) : notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handles a `/_officer/*` request, or returns null if the path isn't one of ours. */
|
|
||||||
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
|
|
||||||
const segments = url.pathname
|
|
||||||
.replace(/^\/_officer\//, '')
|
|
||||||
.split('/')
|
|
||||||
.filter(Boolean);
|
|
||||||
if (!segments.length) return null;
|
|
||||||
|
|
||||||
const userId = userIdOf(req);
|
|
||||||
if (userId === null) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 });
|
|
||||||
|
|
||||||
if (segments[0] === 'favorites' && segments.length === 1) return handleFavorites(req, url, userId);
|
|
||||||
if (segments[0] === 'browse') return handleBrowse({ req, url, userId, segments });
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { getServiceCredentials } from 'officerdb';
|
|
||||||
|
|
||||||
// slskd upstream config + header hygiene for the officer-slskd sidecar.
|
|
||||||
//
|
|
||||||
// All knowledge of the slskd instance — its URL and its API key — lives here. The platform API is a thin
|
|
||||||
// auth+forward proxy and holds NO slskd credentials.
|
|
||||||
//
|
|
||||||
// The daemon is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `service_connections` (see
|
|
||||||
// databases/officer_db/src/service-connections/queries.ts). It is deliberately no longer read from the
|
|
||||||
// environment: Bun auto-loads `.env` into every process started in the platform directory, so an
|
|
||||||
// `SLSKD_API_KEY` there was also sitting in `officer`'s own process.env — a credential that drives the whole
|
|
||||||
// Soulseek daemon, held by the one process with no code to use it. Nothing in this file reads process.env.
|
|
||||||
|
|
||||||
/** Everything needed to reach the daemon. A candidate being validated has this and nothing else yet. */
|
|
||||||
export type SlskdConfig = { base: string; apiKey: string | null };
|
|
||||||
|
|
||||||
/** Trailing slashes off, so `${base}/api/v0/...` never doubles the separator. */
|
|
||||||
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
|
||||||
|
|
||||||
// The transfers view polls every couple of seconds and every request needs the key, so the row is cached
|
|
||||||
// rather than re-read per request. Writes invalidate immediately; the TTL only covers someone editing the
|
|
||||||
// row in psql, which then takes effect within a minute instead of needing a restart.
|
|
||||||
const TTL_MS = 60_000;
|
|
||||||
const cache = new Map<number, { cfg: SlskdConfig | null; at: number }>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The owner's slskd connection, or null when nothing is configured — the sidecar then answers 503, and the
|
|
||||||
* UI turns that into the setup form rather than a wall of empty panels.
|
|
||||||
*/
|
|
||||||
export async function getSlskdConfig(userId: number): Promise<SlskdConfig | null> {
|
|
||||||
const hit = cache.get(userId);
|
|
||||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
|
|
||||||
|
|
||||||
const creds = await getServiceCredentials(userId, 'slskd');
|
|
||||||
// `url` became nullable when services that inherit one instance landed (see the schema column note).
|
|
||||||
// slskd fronts a single daemon and always stores its own, so null here is a malformed row, not an
|
|
||||||
// inheriting one — treat it as unconfigured rather than reaching for somebody else's URL.
|
|
||||||
const cfg = creds?.url ? { base: normalizeBase(creds.url), apiKey: creds.secret } : null;
|
|
||||||
cache.set(userId, { cfg, at: Date.now() });
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop the cached row — called by the config routes after any save or removal. */
|
|
||||||
export function invalidateSlskdConfig(userId: number): void {
|
|
||||||
cache.delete(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The auth header slskd expects, ready to spread. Empty when the daemon has no key configured. */
|
|
||||||
export const authHeaders = (cfg: SlskdConfig): Record<string, string> =>
|
|
||||||
cfg.apiKey ? { 'X-API-Key': cfg.apiKey } : {};
|
|
||||||
|
|
||||||
// Hop-by-hop headers must not cross a proxy hop (RFC 7230 §6.1). `host` is dropped so the outgoing
|
|
||||||
// fetch sets the upstream authority itself; everything else — including our injected X-API-Key — passes.
|
|
||||||
const HOP_BY_HOP = new Set([
|
|
||||||
'connection',
|
|
||||||
'keep-alive',
|
|
||||||
'proxy-authenticate',
|
|
||||||
'proxy-authorization',
|
|
||||||
'te',
|
|
||||||
'trailer',
|
|
||||||
'transfer-encoding',
|
|
||||||
'upgrade',
|
|
||||||
'host',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Copy headers, dropping only hop-by-hop / proxy-* ones. Used for both request and response directions. */
|
|
||||||
export function stripHopByHop(src: Headers): Headers {
|
|
||||||
const out = new Headers();
|
|
||||||
src.forEach((value, key) => {
|
|
||||||
const k = key.toLowerCase();
|
|
||||||
if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return;
|
|
||||||
out.set(key, value);
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards';
|
|||||||
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||||
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
|
||||||
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
|
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
|
||||||
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
|
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
|
||||||
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
|
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
|
||||||
@@ -33,7 +32,6 @@ export const apps = [
|
|||||||
...chatHistoryMetas,
|
...chatHistoryMetas,
|
||||||
...widgetMetas,
|
...widgetMetas,
|
||||||
...desktopMetas,
|
...desktopMetas,
|
||||||
...soulseekMetas,
|
|
||||||
...jellyfinMetas,
|
...jellyfinMetas,
|
||||||
...giteaMetas,
|
...giteaMetas,
|
||||||
...invoicesMetas,
|
...invoicesMetas,
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
|
|||||||
) : isGenerating ? (
|
) : isGenerating ? (
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
|
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
|
<span className="inline-block h-2 w-2 rounded-full bg-success" />
|
||||||
)}
|
)}
|
||||||
{isConnected && onDisconnect && (
|
{isConnected && onDisconnect && (
|
||||||
<button
|
<button
|
||||||
@@ -191,7 +191,7 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
|
|||||||
</span>
|
</span>
|
||||||
{commander.watcherArmed ? (
|
{commander.watcherArmed ? (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${commander.running ? 'bg-green-500' : 'bg-amber-500'}`} />
|
<span className={`h-1.5 w-1.5 rounded-full ${commander.running ? 'bg-success' : 'bg-warning'}`} />
|
||||||
watcher armed
|
watcher armed
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { CommandHqCategoryFilter, CommandHqCommander } from './types';
|
|||||||
* alone cannot tell "briefly between fire and re-arm" (normal) from "silently dead". Only the pair says it.
|
* alone cannot tell "briefly between fire and re-arm" (normal) from "silently dead". Only the pair says it.
|
||||||
*/
|
*/
|
||||||
function StatusDot({ running, watcherArmed }: { running: boolean; watcherArmed: boolean }) {
|
function StatusDot({ running, watcherArmed }: { running: boolean; watcherArmed: boolean }) {
|
||||||
const color = running ? 'bg-green-500' : watcherArmed ? 'bg-amber-500' : 'bg-muted-foreground/40';
|
const color = running ? 'bg-success' : watcherArmed ? 'bg-warning' : 'bg-muted-foreground/40';
|
||||||
const title = running ? 'Running' : watcherArmed ? 'Armed, not currently running' : 'Not running';
|
const title = running ? 'Running' : watcherArmed ? 'Armed, not currently running' : 'Not running';
|
||||||
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} title={title} />;
|
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} title={title} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
|
|
||||||
// Shared visual language for the /soulseek grouped views (search results + downloads). Each top-level
|
|
||||||
// grouping (a peer/user) is an almost-black Card; each folder within it is a lifted SubCard. Kept here
|
|
||||||
// so search and downloads stay pixel-identical — change the look in one place.
|
|
||||||
|
|
||||||
export const Card = ({ children }: { children: ReactNode }) => (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-white/10 bg-zinc-950 shadow-sm">{children}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Supply exactly one of `title` (inert text — the whole row becomes the collapse toggle) or `titleMenu`
|
|
||||||
// (an interactive node such as the peer dropdown). A trigger can't live inside the toggle button, so with
|
|
||||||
// `titleMenu` the row splits: the chevron and the empty space after the node toggle, the node itself doesn't.
|
|
||||||
type CardHeaderProps = {
|
|
||||||
open: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
title?: ReactNode;
|
|
||||||
meta?: ReactNode;
|
|
||||||
titleMenu?: ReactNode;
|
|
||||||
};
|
|
||||||
export const CardHeader = ({ open, onToggle, title, meta, titleMenu }: CardHeaderProps) => {
|
|
||||||
const chevron = (
|
|
||||||
<ChevronRight className={`h-4 w-4 shrink-0 text-zinc-500 transition-transform ${open ? 'rotate-90' : ''}`} />
|
|
||||||
);
|
|
||||||
const metaBlock = meta && <div className="flex shrink-0 items-center gap-2 text-xs text-zinc-400">{meta}</div>;
|
|
||||||
|
|
||||||
if (!titleMenu)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggle}
|
|
||||||
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-white/5"
|
|
||||||
>
|
|
||||||
{chevron}
|
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-zinc-100">{title}</span>
|
|
||||||
{metaBlock}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full items-center gap-2.5 px-3 py-2.5 transition-colors hover:bg-white/5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggle}
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-label={open ? 'Collapse' : 'Expand'}
|
|
||||||
className="flex shrink-0 items-center"
|
|
||||||
>
|
|
||||||
{chevron}
|
|
||||||
</button>
|
|
||||||
{titleMenu}
|
|
||||||
<button type="button" onClick={onToggle} tabIndex={-1} aria-hidden className="h-6 min-w-0 flex-1" />
|
|
||||||
{metaBlock}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CardBody = ({ children }: { children: ReactNode }) => (
|
|
||||||
<div className="flex flex-col gap-1.5 border-t border-white/10 bg-black/30 p-2">{children}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const SubCard = ({ children }: { children: ReactNode }) => (
|
|
||||||
<div className="overflow-hidden rounded-lg border border-white/5 bg-white/[0.02]">{children}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// A folder header. When `onToggle` is supplied it becomes a collapse control (chevron + clickable
|
|
||||||
// label); `action` stays a sibling so it never nests inside the toggle button.
|
|
||||||
type SubCardHeaderProps = {
|
|
||||||
icon?: ReactNode;
|
|
||||||
label: ReactNode;
|
|
||||||
title?: string;
|
|
||||||
meta?: ReactNode;
|
|
||||||
action?: ReactNode;
|
|
||||||
open?: boolean;
|
|
||||||
onToggle?: () => void;
|
|
||||||
};
|
|
||||||
export const SubCardHeader = ({ icon, label, title, meta, action, open, onToggle }: SubCardHeaderProps) => {
|
|
||||||
const inner = (
|
|
||||||
<>
|
|
||||||
{onToggle && (
|
|
||||||
<ChevronRight className={`h-3 w-3 shrink-0 text-zinc-600 transition-transform ${open ? 'rotate-90' : ''}`} />
|
|
||||||
)}
|
|
||||||
{icon}
|
|
||||||
<span className="min-w-0 flex-1 truncate text-xs text-zinc-400" title={title}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
{meta && <span className="shrink-0 text-xs text-zinc-500">{meta}</span>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 bg-white/[0.03] px-2.5 py-1.5">
|
|
||||||
{onToggle ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggle}
|
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 text-left transition-colors hover:text-zinc-200"
|
|
||||||
>
|
|
||||||
{inner}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
inner
|
|
||||||
)}
|
|
||||||
{action}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// File rows inside a SubCard, hairline-divided.
|
|
||||||
export const RowList = ({ children }: { children: ReactNode }) => (
|
|
||||||
<div className="divide-y divide-white/5">{children}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
type PillProps = { active: boolean; onClick: () => void; children: ReactNode };
|
|
||||||
export const Pill = ({ active, onClick, children }: PillProps) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition ${
|
|
||||||
active
|
|
||||||
? 'border-primary/40 bg-primary/10 text-primary'
|
|
||||||
: 'border-white/10 bg-white/[0.02] text-zinc-400 hover:bg-white/5 hover:text-zinc-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
@@ -1,614 +0,0 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Download,
|
|
||||||
Lock,
|
|
||||||
Loader2,
|
|
||||||
Folder,
|
|
||||||
Zap,
|
|
||||||
Users,
|
|
||||||
Search,
|
|
||||||
X,
|
|
||||||
RefreshCw,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList, Pill } from './Cards';
|
|
||||||
import { UserMenu } from './UserMenu';
|
|
||||||
import {
|
|
||||||
SLSKD_REFRESH_CHANNEL,
|
|
||||||
basename,
|
|
||||||
formatSize,
|
|
||||||
formatSpeed,
|
|
||||||
formatDuration,
|
|
||||||
groupResponses,
|
|
||||||
type ResultUser,
|
|
||||||
type ResultFolder,
|
|
||||||
type ResultFile,
|
|
||||||
type SlskdResponse,
|
|
||||||
type SlskdSearchSummary,
|
|
||||||
} from './shared';
|
|
||||||
|
|
||||||
// The results subpanel for one past search — loads its STORED responses (GET /searches/{id}/responses),
|
|
||||||
// never re-runs the search. Presented the way slskd does: grouped by responder (user) → folder → files,
|
|
||||||
// each user a collapsible card with its upload speed / free-slot / queue / file-count, and per-file plus
|
|
||||||
// per-folder download actions.
|
|
||||||
//
|
|
||||||
// Filtering is a way of *finding* a folder as much as of hiding files, so nothing it hides is out of
|
|
||||||
// reach: a matching folder expands to the whole album in place, and a matching peer expands to the rest
|
|
||||||
// of its folders. Download buttons always queue exactly what is on screen.
|
|
||||||
//
|
|
||||||
// Caching is stale-while-revalidate: the cached grouping paints immediately, then we always re-fetch in
|
|
||||||
// the background. Never serve the cache alone — opening a search before its first responses land used to
|
|
||||||
// freeze that empty snapshot forever. While the search is still running we also poll, so results fill in
|
|
||||||
// live; the header's refresh re-reads on demand.
|
|
||||||
|
|
||||||
const MAX_USERS = 200;
|
|
||||||
const POLL_MS = 2500;
|
|
||||||
// Safety net: a search whose completion we can't confirm shouldn't poll forever (~2 min of polling).
|
|
||||||
const MAX_POLLS = 48;
|
|
||||||
const resultsCache = new Map<string, ResultUser[]>();
|
|
||||||
|
|
||||||
export const dropCachedResults = (searchId: string) => resultsCache.delete(searchId);
|
|
||||||
|
|
||||||
const fileKey = (username: string, filename: string) => `${username}::${filename}`;
|
|
||||||
|
|
||||||
// Client-side filtering over already-loaded results (mirrors slskd's filter tokens, no re-query).
|
|
||||||
const LOSSLESS = new Set(['flac', 'wav', 'aiff', 'aif', 'ape', 'alac', 'wv']);
|
|
||||||
const EXT_PILLS = ['flac', 'mp3', 'wav', 'm4a', 'ogg'];
|
|
||||||
const BITRATE_PILLS = [256, 320];
|
|
||||||
|
|
||||||
type Filters = {
|
|
||||||
text: string;
|
|
||||||
freeOnly: boolean;
|
|
||||||
hideLocked: boolean;
|
|
||||||
exts: Set<string>;
|
|
||||||
minBitrate: number | null;
|
|
||||||
};
|
|
||||||
const EMPTY_FILTERS: Filters = { text: '', freeOnly: false, hideLocked: false, exts: new Set(), minBitrate: null };
|
|
||||||
|
|
||||||
const filtersActive = (f: Filters) =>
|
|
||||||
f.text.trim() !== '' || f.freeOnly || f.hideLocked || f.exts.size > 0 || f.minBitrate !== null;
|
|
||||||
|
|
||||||
// Filtering narrows *files*, but the reason to filter is usually to find a folder: you type one track
|
|
||||||
// name you know to pick your album out of 300 unrelated ones. So a filtered folder keeps a reference to
|
|
||||||
// the whole folder alongside its matching files, and can be expanded back to the full album in place —
|
|
||||||
// otherwise finding it would be the same act as hiding the rest of it.
|
|
||||||
type FolderMatch = { folder: ResultFolder; matched: ResultFile[]; matchedSize: number };
|
|
||||||
type UserMatch = { user: ResultUser; folders: FolderMatch[]; matchedCount: number };
|
|
||||||
|
|
||||||
const folderKey = (username: string, path: string) => `${username}::${path}`;
|
|
||||||
// Same reveal set, for "this peer's folders that matched nothing" — the discography around the album.
|
|
||||||
const userKey = (username: string) => `user::${username}`;
|
|
||||||
|
|
||||||
const applyFilters = (users: ResultUser[], f: Filters): UserMatch[] => {
|
|
||||||
const terms = f.text.toLowerCase().split(/\s+/).filter(Boolean);
|
|
||||||
const pos = terms.filter((t) => !t.startsWith('-'));
|
|
||||||
const neg = terms
|
|
||||||
.filter((t) => t.startsWith('-'))
|
|
||||||
.map((t) => t.slice(1))
|
|
||||||
.filter(Boolean);
|
|
||||||
const fileOk = (file: ResultFile) => {
|
|
||||||
if (f.hideLocked && file.isLocked) return false;
|
|
||||||
if (f.exts.size && !f.exts.has(file.extension)) return false;
|
|
||||||
if (f.minBitrate !== null && !((file.bitRate ?? 0) >= f.minBitrate || LOSSLESS.has(file.extension))) return false;
|
|
||||||
const hay = file.filename.toLowerCase();
|
|
||||||
if (pos.some((t) => !hay.includes(t))) return false;
|
|
||||||
if (neg.some((t) => hay.includes(t))) return false;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
const out: UserMatch[] = [];
|
|
||||||
for (const user of users) {
|
|
||||||
if (f.freeOnly && !user.hasFreeUploadSlot) continue;
|
|
||||||
const folders: FolderMatch[] = [];
|
|
||||||
for (const folder of user.folders) {
|
|
||||||
const matched = folder.files.filter(fileOk);
|
|
||||||
if (matched.length) folders.push({ folder, matched, matchedSize: matched.reduce((n, x) => n + x.size, 0) });
|
|
||||||
}
|
|
||||||
if (folders.length) {
|
|
||||||
out.push({ user, folders, matchedCount: folders.reduce((n, fd) => n + fd.matched.length, 0) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
// `initial` is the history row we came from, when there is one. Opening by URL — or landing here the
|
|
||||||
// moment a search is created — has no row yet, so the summary is fetched like everything else and the
|
|
||||||
// header fills in a beat later.
|
|
||||||
type SearchResultsProps = { searchId: string; initial?: SlskdSearchSummary | null; onBack: () => void };
|
|
||||||
|
|
||||||
export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps) => {
|
|
||||||
const client = useClient();
|
|
||||||
const [, bumpRefresh] = usePanelChannel<number>(SLSKD_REFRESH_CHANNEL, 0);
|
|
||||||
const cachedInit = resultsCache.get(searchId) ?? null;
|
|
||||||
const [users, setUsers] = useState<ResultUser[] | null>(cachedInit);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [summary, setSummary] = useState<SlskdSearchSummary | null>(initial ?? null);
|
|
||||||
const [isComplete, setIsComplete] = useState(initial?.isComplete ?? false);
|
|
||||||
// Users start expanded; toggling collapses them.
|
|
||||||
const [open, setOpen] = useState<Set<string>>(() => new Set(cachedInit?.map((u) => u.username)));
|
|
||||||
const [queued, setQueued] = useState<Set<string>>(new Set());
|
|
||||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
|
||||||
// Folders the reader has expanded past the filter, keyed username::path.
|
|
||||||
const [revealed, setRevealed] = useState<Set<string>>(new Set());
|
|
||||||
// Usernames we've already auto-expanded — lets a revalidation expand newcomers without re-opening
|
|
||||||
// the cards the reader deliberately collapsed.
|
|
||||||
const seenRef = useRef<Set<string>>(new Set(cachedInit?.map((u) => u.username) ?? []));
|
|
||||||
const aliveRef = useRef(true);
|
|
||||||
const pollsRef = useRef(0);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => (users ? applyFilters(users, filters) : null), [users, filters]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
aliveRef.current = true;
|
|
||||||
return () => {
|
|
||||||
aliveRef.current = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refresh = useCallback(
|
|
||||||
async (quiet: boolean) => {
|
|
||||||
if (!quiet) setLoading(true);
|
|
||||||
try {
|
|
||||||
// The summary tells us whether to keep polling; tolerate its absence rather than fail the load.
|
|
||||||
const [responses, meta] = await Promise.all([
|
|
||||||
client.get<SlskdResponse[]>(`/slskd/api/v0/searches/${searchId}/responses`),
|
|
||||||
client.get<SlskdSearchSummary>(`/slskd/api/v0/searches/${searchId}`).catch(() => null),
|
|
||||||
]);
|
|
||||||
if (!aliveRef.current) return;
|
|
||||||
const grouped = groupResponses(responses);
|
|
||||||
resultsCache.set(searchId, grouped);
|
|
||||||
setUsers(grouped);
|
|
||||||
setError(null);
|
|
||||||
setOpen((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
for (const u of grouped) {
|
|
||||||
if (!seenRef.current.has(u.username)) {
|
|
||||||
seenRef.current.add(u.username);
|
|
||||||
next.add(u.username);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
if (meta) {
|
|
||||||
setSummary(meta);
|
|
||||||
setIsComplete(meta.isComplete);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (aliveRef.current) setError(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
if (aliveRef.current) setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// useClient() returns a fresh object each render, so depend only on the search id.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[searchId],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cached = resultsCache.get(searchId) ?? null;
|
|
||||||
seenRef.current = new Set(cached?.map((u) => u.username) ?? []);
|
|
||||||
pollsRef.current = 0;
|
|
||||||
setUsers(cached);
|
|
||||||
setOpen(new Set(cached?.map((u) => u.username)));
|
|
||||||
setRevealed(new Set());
|
|
||||||
setError(null);
|
|
||||||
// Assume running until the summary says otherwise — a search opened straight after being created has
|
|
||||||
// no row to tell us, and guessing "complete" would mean never polling for its first responses.
|
|
||||||
setIsComplete(initial?.id === searchId ? initial.isComplete : false);
|
|
||||||
// Quiet when something is already on screen — no spinner flash over usable results.
|
|
||||||
refresh(Boolean(cached));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [searchId, refresh]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isComplete) return;
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
pollsRef.current += 1;
|
|
||||||
if (pollsRef.current > MAX_POLLS) {
|
|
||||||
clearInterval(timer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
refresh(true);
|
|
||||||
}, POLL_MS);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [isComplete, refresh]);
|
|
||||||
|
|
||||||
const toggle = (username: string) =>
|
|
||||||
setOpen((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(username)) next.delete(username);
|
|
||||||
else next.add(username);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleReveal = (key: string) =>
|
|
||||||
setRevealed((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(key)) next.delete(key);
|
|
||||||
else next.add(key);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
const queue = async (username: string, files: ResultFile[]) => {
|
|
||||||
const usable = files.filter((f) => !f.isLocked);
|
|
||||||
if (usable.length === 0) return;
|
|
||||||
try {
|
|
||||||
await client.post(
|
|
||||||
`/slskd/api/v0/transfers/downloads/${encodeURIComponent(username)}`,
|
|
||||||
usable.map((f) => ({ filename: f.filename, size: f.size })),
|
|
||||||
);
|
|
||||||
setQueued((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
usable.forEach((f) => next.add(fileKey(username, f.filename)));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
bumpRefresh(Date.now());
|
|
||||||
toast.success(usable.length === 1 ? `Queued ${basename(usable[0]!.filename)}` : `Queued ${usable.length} files`);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleExt = (ext: string) =>
|
|
||||||
setFilters((f) => {
|
|
||||||
const exts = new Set(f.exts);
|
|
||||||
if (exts.has(ext)) exts.delete(ext);
|
|
||||||
else exts.add(ext);
|
|
||||||
return { ...f, exts };
|
|
||||||
});
|
|
||||||
const setMinBitrate = (n: number) => setFilters((f) => ({ ...f, minBitrate: f.minBitrate === n ? null : n }));
|
|
||||||
|
|
||||||
const shown = filtered ? filtered.slice(0, MAX_USERS) : [];
|
|
||||||
const totalFiles = filtered?.reduce((n, u) => n + u.matchedCount, 0) ?? 0;
|
|
||||||
const anyFilter = filtersActive(filters);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
|
||||||
<div className="flex shrink-0 items-center gap-3 border-b bg-background/60 px-4 py-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onBack}
|
|
||||||
title="Back to history"
|
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="truncate text-sm font-semibold">{summary?.searchText ?? initial?.searchText ?? '…'}</span>
|
|
||||||
{!isComplete && (
|
|
||||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" /> searching
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{users === null && !error
|
|
||||||
? 'Loading results…'
|
|
||||||
: `${totalFiles.toLocaleString()} files from ${(filtered?.length ?? 0).toLocaleString()} users${
|
|
||||||
anyFilter ? ' (filtered)' : ''
|
|
||||||
}${filtered && filtered.length > shown.length ? ` — showing top ${shown.length}` : ''}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => refresh(false)}
|
|
||||||
disabled={loading}
|
|
||||||
title="Reload results"
|
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter bar — text tokens plus one-click pills, applied client-side to loaded results */}
|
|
||||||
{users && users.length > 0 && (
|
|
||||||
<div className="flex shrink-0 flex-col gap-2 border-b bg-muted/20 px-3 py-2.5">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
value={filters.text}
|
|
||||||
onChange={(ev) => setFilters((f) => ({ ...f, text: ev.target.value }))}
|
|
||||||
placeholder="Filter files… (space-separated, prefix - to exclude)"
|
|
||||||
className="h-9 w-full rounded-lg border bg-background pl-9 pr-8 text-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
|
|
||||||
/>
|
|
||||||
{filters.text && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFilters((f) => ({ ...f, text: '' }))}
|
|
||||||
className="absolute right-2 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<Pill active={filters.freeOnly} onClick={() => setFilters((f) => ({ ...f, freeOnly: !f.freeOnly }))}>
|
|
||||||
<Zap className="h-3 w-3" /> Free slots
|
|
||||||
</Pill>
|
|
||||||
<Pill active={filters.hideLocked} onClick={() => setFilters((f) => ({ ...f, hideLocked: !f.hideLocked }))}>
|
|
||||||
<Lock className="h-3 w-3" /> Hide locked
|
|
||||||
</Pill>
|
|
||||||
<span className="mx-0.5 h-4 w-px bg-border" />
|
|
||||||
{EXT_PILLS.map((ext) => (
|
|
||||||
<Pill key={ext} active={filters.exts.has(ext)} onClick={() => toggleExt(ext)}>
|
|
||||||
{ext.toUpperCase()}
|
|
||||||
</Pill>
|
|
||||||
))}
|
|
||||||
<span className="mx-0.5 h-4 w-px bg-border" />
|
|
||||||
{BITRATE_PILLS.map((br) => (
|
|
||||||
<Pill key={br} active={filters.minBitrate === br} onClick={() => setMinBitrate(br)}>
|
|
||||||
≥{br}
|
|
||||||
</Pill>
|
|
||||||
))}
|
|
||||||
{anyFilter && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFilters(EMPTY_FILTERS)}
|
|
||||||
className="ml-1 inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" /> Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
||||||
{error && <p className="p-1 text-sm text-red-500">Could not load results: {error}</p>}
|
|
||||||
{!error && users === null && (
|
|
||||||
<div className="flex items-center gap-2 p-1 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading results…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!error && users && users.length === 0 && (
|
|
||||||
<p className="flex items-center gap-2 p-1 text-sm text-muted-foreground">
|
|
||||||
{isComplete ? (
|
|
||||||
'No results stored.'
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" /> Waiting for responses…
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!error && users && users.length > 0 && filtered && filtered.length === 0 && (
|
|
||||||
<p className="p-1 text-sm text-muted-foreground">No files match the current filters.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{shown.map((match) => (
|
|
||||||
<UserCard
|
|
||||||
key={match.user.username}
|
|
||||||
match={match}
|
|
||||||
open={open.has(match.user.username)}
|
|
||||||
onToggle={() => toggle(match.user.username)}
|
|
||||||
queued={queued}
|
|
||||||
onDownload={queue}
|
|
||||||
revealed={revealed}
|
|
||||||
onToggleReveal={toggleReveal}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type UserCardProps = {
|
|
||||||
match: UserMatch;
|
|
||||||
open: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
queued: Set<string>;
|
|
||||||
onDownload: (username: string, files: ResultFile[]) => void;
|
|
||||||
revealed: Set<string>;
|
|
||||||
onToggleReveal: (key: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserCard = ({ match, open, onToggle, queued, onDownload, revealed, onToggleReveal }: UserCardProps) => {
|
|
||||||
const { user, folders, matchedCount } = match;
|
|
||||||
// A peer with the album usually has more of the artist, and the filter hid all of it. Expanding brings
|
|
||||||
// the non-matching folders back, each already whole.
|
|
||||||
const allFolders = revealed.has(userKey(user.username));
|
|
||||||
const hiddenFolders = user.folders.length - folders.length;
|
|
||||||
const visible: FolderMatch[] = allFolders
|
|
||||||
? user.folders.map(
|
|
||||||
(folder) =>
|
|
||||||
folders.find((fm) => fm.folder.path === folder.path) ?? {
|
|
||||||
folder,
|
|
||||||
matched: folder.files,
|
|
||||||
matchedSize: folder.size,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: folders;
|
|
||||||
// Every action queues what is on screen, expanded folders included — never the hidden remainder.
|
|
||||||
const shownFiles = visible.flatMap((fm) =>
|
|
||||||
revealed.has(folderKey(user.username, fm.folder.path)) ? fm.folder.files : fm.matched,
|
|
||||||
);
|
|
||||||
const meta = (
|
|
||||||
<>
|
|
||||||
{user.hasFreeUploadSlot ? (
|
|
||||||
<span className="inline-flex items-center gap-1 text-green-500">
|
|
||||||
<Zap className="h-3 w-3" /> free
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<Users className="h-3 w-3" /> {user.queueLength}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{user.uploadSpeed > 0 && <span>{formatSpeed(user.uploadSpeed)}</span>}
|
|
||||||
<span className="tabular-nums">
|
|
||||||
{matchedCount < user.fileCount
|
|
||||||
? `${matchedCount.toLocaleString()} of ${user.fileCount.toLocaleString()} files`
|
|
||||||
: `${user.fileCount.toLocaleString()} files`}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader open={open} onToggle={onToggle} titleMenu={<UserMenu username={user.username} />} meta={meta} />
|
|
||||||
{open && (
|
|
||||||
<CardBody>
|
|
||||||
{visible.map((fm) => (
|
|
||||||
<FolderBlock
|
|
||||||
key={fm.folder.path}
|
|
||||||
username={user.username}
|
|
||||||
match={fm}
|
|
||||||
revealed={revealed.has(folderKey(user.username, fm.folder.path))}
|
|
||||||
onToggleReveal={() => onToggleReveal(folderKey(user.username, fm.folder.path))}
|
|
||||||
queued={queued}
|
|
||||||
onDownload={onDownload}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{hiddenFolders > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onToggleReveal(userKey(user.username))}
|
|
||||||
className="flex items-center justify-center gap-1.5 rounded-lg border border-dashed border-white/10 py-1.5 text-xs text-zinc-400 transition hover:bg-white/5 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
{allFolders ? (
|
|
||||||
<>
|
|
||||||
<ChevronUp className="h-3.5 w-3.5" /> Show only the {folders.length} matching{' '}
|
|
||||||
{folders.length === 1 ? 'folder' : 'folders'}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" /> Show this peer's other {hiddenFolders}{' '}
|
|
||||||
{hiddenFolders === 1 ? 'folder' : 'folders'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{shownFiles.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onDownload(user.username, shownFiles)}
|
|
||||||
className="flex items-center justify-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.02] py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" /> Download all ({shownFiles.filter((f) => !f.isLocked).length})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FolderBlockProps = {
|
|
||||||
username: string;
|
|
||||||
match: FolderMatch;
|
|
||||||
revealed: boolean;
|
|
||||||
onToggleReveal: () => void;
|
|
||||||
queued: Set<string>;
|
|
||||||
onDownload: (username: string, files: ResultFile[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FolderBlock = ({ username, match, revealed, onToggleReveal, queued, onDownload }: FolderBlockProps) => {
|
|
||||||
const { folder, matched, matchedSize } = match;
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
const files = revealed ? folder.files : matched;
|
|
||||||
const hidden = folder.files.length - matched.length;
|
|
||||||
const matchedNames = revealed && hidden > 0 ? new Set(matched.map((f) => f.filename)) : null;
|
|
||||||
const unlocked = files.filter((f) => !f.isLocked).length;
|
|
||||||
return (
|
|
||||||
<SubCard>
|
|
||||||
<SubCardHeader
|
|
||||||
open={open}
|
|
||||||
onToggle={() => setOpen((v) => !v)}
|
|
||||||
icon={<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-500" />}
|
|
||||||
label={folder.label}
|
|
||||||
title={folder.path}
|
|
||||||
meta={`${hidden > 0 && !revealed ? `${files.length} of ${folder.files.length}` : files.length} · ${formatSize(
|
|
||||||
revealed ? folder.size : matchedSize,
|
|
||||||
)}`}
|
|
||||||
action={
|
|
||||||
files.length > 1 ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onDownload(username, files)}
|
|
||||||
title={revealed || hidden === 0 ? 'Download folder' : `Download the ${files.length} matching files`}
|
|
||||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-400 transition hover:bg-white/10 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{open && (
|
|
||||||
<>
|
|
||||||
{/* The point of the whole exercise: the track you filtered for has found the album, so open the
|
|
||||||
album. Revealing also widens every download button below to the full folder. */}
|
|
||||||
{hidden > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggleReveal}
|
|
||||||
className="flex w-full items-center justify-center gap-1.5 border-b border-white/5 bg-white/[0.02] py-1.5 text-xs text-zinc-400 transition hover:bg-white/5 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
{revealed ? (
|
|
||||||
<>
|
|
||||||
<ChevronUp className="h-3.5 w-3.5" /> Show only the {matched.length} matching
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" /> Show the whole folder ({folder.files.length} files)
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<RowList>
|
|
||||||
{files.map((file) => {
|
|
||||||
const isQueued = queued.has(fileKey(username, file.filename));
|
|
||||||
const duration = formatDuration(file.length);
|
|
||||||
// In an expanded folder, keep the file you filtered for legible among its neighbours.
|
|
||||||
const isMatch = !matchedNames || matchedNames.has(file.filename);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={file.filename}
|
|
||||||
className={`flex items-center gap-3 px-3 py-1.5 pl-8 text-sm ${
|
|
||||||
isMatch ? 'text-zinc-200' : 'text-zinc-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate">
|
|
||||||
{file.isLocked && <Lock className="h-3 w-3 shrink-0 text-zinc-500" />}
|
|
||||||
<span className={`truncate ${isMatch && matchedNames ? 'font-medium text-zinc-100' : ''}`}>
|
|
||||||
{file.name}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div className="flex shrink-0 items-center gap-3 text-xs tabular-nums text-zinc-500">
|
|
||||||
{file.bitRate ? <span>{file.bitRate} kbps</span> : null}
|
|
||||||
{duration && <span>{duration}</span>}
|
|
||||||
<span className="w-16 text-right">{formatSize(file.size)}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onDownload(username, [file])}
|
|
||||||
disabled={file.isLocked || isQueued}
|
|
||||||
title={isQueued ? 'Queued' : 'Download'}
|
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-400 transition hover:bg-white/10 hover:text-zinc-100 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{isQueued ? <Loader2 className="h-3.5 w-3.5" /> : <Download className="h-3.5 w-3.5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</RowList>
|
|
||||||
{unlocked > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onDownload(username, files)}
|
|
||||||
className="flex w-full items-center justify-center gap-1.5 border-t border-white/5 py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
{revealed || hidden === 0 ? `Download folder (${unlocked})` : `Download ${unlocked} matching`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SubCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { Link, useSearchParams } from 'react-router';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Search, Loader2, RefreshCw, X, History, FileAudio, Users, ChevronRight, Trash2 } from 'lucide-react';
|
|
||||||
import { SearchResults, dropCachedResults } from './SearchResults';
|
|
||||||
import { formatWhen, SEARCH_PARAM, type SlskdSearchSummary } from './shared';
|
|
||||||
import { randomId } from 'helpers/random-id';
|
|
||||||
|
|
||||||
// The 'search' section — a search input plus the history of past searches (GET /searches). Which search
|
|
||||||
// is open lives in `?search=<id>`, so rows are real links and the back button returns to the history.
|
|
||||||
//
|
|
||||||
// Submitting opens the new search's results straight away, the way slskd's own web UI does. That is not
|
|
||||||
// only a nicety: a history row is only as fresh as the last list load, so a search you had just started
|
|
||||||
// sat at 0 responses looking stalled, while the results view polls for as long as the search runs.
|
|
||||||
|
|
||||||
const HISTORY_POLL_MS = 4000;
|
|
||||||
// A running search nobody is watching shouldn't poll forever (~3 minutes).
|
|
||||||
const MAX_HISTORY_POLLS = 45;
|
|
||||||
|
|
||||||
const byNewest = (list: SlskdSearchSummary[]) => [...list].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
||||||
|
|
||||||
// slskd accepts a client-supplied id (its own UI depends on that), but don't bet the navigation on it:
|
|
||||||
// prefer an id the server has actually stored, then fall back to the newest search with this text.
|
|
||||||
type CreatedIdParams = { list: SlskdSearchSummary[] | null; sentId: string; returnedId?: string; text: string };
|
|
||||||
const createdSearchId = ({ list, sentId, returnedId, text }: CreatedIdParams) => {
|
|
||||||
if (!list) return returnedId ?? sentId;
|
|
||||||
const known = new Set(list.map((s) => s.id));
|
|
||||||
if (returnedId && known.has(returnedId)) return returnedId;
|
|
||||||
if (known.has(sentId)) return sentId;
|
|
||||||
return list.find((s) => s.searchText === text)?.id ?? returnedId ?? sentId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SearchView = () => {
|
|
||||||
const client = useClient();
|
|
||||||
const [params, setParams] = useSearchParams();
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [history, setHistory] = useState<SlskdSearchSummary[] | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [clearing, setClearing] = useState(false);
|
|
||||||
const [confirmClear, setConfirmClear] = useState(false);
|
|
||||||
|
|
||||||
const selectedId = params.get(SEARCH_PARAM);
|
|
||||||
|
|
||||||
const openSearch = useCallback(
|
|
||||||
(id: string | null) =>
|
|
||||||
setParams((prev) => {
|
|
||||||
const next = new URLSearchParams(prev);
|
|
||||||
if (id) next.set(SEARCH_PARAM, id);
|
|
||||||
else next.delete(SEARCH_PARAM);
|
|
||||||
return next;
|
|
||||||
}),
|
|
||||||
[setParams],
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchHistory = useCallback(async () => {
|
|
||||||
const list = await client.get<SlskdSearchSummary[]>('/slskd/api/v0/searches');
|
|
||||||
return byNewest(list ?? []);
|
|
||||||
// useClient() is a fresh object each render — keep this callback stable.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const load = useCallback(() => {
|
|
||||||
setError(null);
|
|
||||||
fetchHistory()
|
|
||||||
.then(setHistory)
|
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
|
||||||
}, [fetchHistory]);
|
|
||||||
|
|
||||||
// Re-read whenever the history is what's on screen — including a browser Back out of a search, which
|
|
||||||
// no callback of ours sees.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedId) load();
|
|
||||||
}, [selectedId, load]);
|
|
||||||
|
|
||||||
// A row's counters only move when the list is re-read, so keep re-reading while a search is running —
|
|
||||||
// but not while its results are open, since that view polls on its own.
|
|
||||||
const running = !!history?.some((s) => !s.isComplete);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!running || selectedId) return;
|
|
||||||
let polls = 0;
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
polls += 1;
|
|
||||||
if (polls > MAX_HISTORY_POLLS) {
|
|
||||||
clearInterval(timer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, HISTORY_POLL_MS);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [running, selectedId, load]);
|
|
||||||
|
|
||||||
const submit = async (ev: React.FormEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const text = query.trim();
|
|
||||||
if (!text || submitting) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
const sentId = randomId();
|
|
||||||
try {
|
|
||||||
const created = await client.post<Partial<SlskdSearchSummary>>('/slskd/api/v0/searches', {
|
|
||||||
id: sentId,
|
|
||||||
searchText: text,
|
|
||||||
});
|
|
||||||
// A failed reload shouldn't read as a failed search — fall through with what the POST told us.
|
|
||||||
const list = await fetchHistory().catch(() => null);
|
|
||||||
if (list) setHistory(list);
|
|
||||||
setQuery('');
|
|
||||||
openSearch(createdSearchId({ list, sentId, returnedId: created?.id, text }));
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(`Search failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
|
||||||
setHistory((prev) => prev?.filter((s) => s.id !== id) ?? prev);
|
|
||||||
dropCachedResults(id);
|
|
||||||
if (selectedId === id) openSearch(null);
|
|
||||||
client.delete(`/slskd/api/v0/searches/${id}`).catch(() => load());
|
|
||||||
};
|
|
||||||
|
|
||||||
// slskd has no bulk delete (0.26.0 exposes only DELETE /searches/{id}), so clearing fans out one
|
|
||||||
// request per search and reconciles with a reload — partial failures come back on their own.
|
|
||||||
const clearAll = async () => {
|
|
||||||
const ids = history?.map((s) => s.id) ?? [];
|
|
||||||
if (!ids.length || clearing) return;
|
|
||||||
setConfirmClear(false);
|
|
||||||
setClearing(true);
|
|
||||||
setHistory([]);
|
|
||||||
openSearch(null);
|
|
||||||
ids.forEach(dropCachedResults);
|
|
||||||
const settled = await Promise.allSettled(ids.map((id) => client.delete(`/slskd/api/v0/searches/${id}`)));
|
|
||||||
const failed = settled.filter((r) => r.status === 'rejected').length;
|
|
||||||
if (failed) toast.error(`${failed} of ${ids.length} searches could not be removed.`);
|
|
||||||
setClearing(false);
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
// The destructive click is two-step rather than a modal (matching the other Soulseek panels, which
|
|
||||||
// confirm nothing) — and the armed state disarms itself so it can't sit there waiting to be hit.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!confirmClear) return;
|
|
||||||
const timer = setTimeout(() => setConfirmClear(false), 4000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [confirmClear]);
|
|
||||||
|
|
||||||
// The row we came from, when we have it — a search opened by URL (or just created) resolves its own.
|
|
||||||
const selected = selectedId ? (history?.find((s) => s.id === selectedId) ?? null) : null;
|
|
||||||
const hrefFor = (id: string) => {
|
|
||||||
const next = new URLSearchParams(params);
|
|
||||||
next.set(SEARCH_PARAM, id);
|
|
||||||
return `?${next.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (selectedId) return <SearchResults searchId={selectedId} initial={selected} onBack={() => openSearch(null)} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
|
||||||
{/* Header + search field */}
|
|
||||||
<div className="shrink-0 border-b bg-background/60 px-6 py-5">
|
|
||||||
<h1 className="mb-3 text-lg font-semibold">Search</h1>
|
|
||||||
<form onSubmit={submit} className="flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
|
|
||||||
<input
|
|
||||||
value={query}
|
|
||||||
onChange={(ev) => setQuery(ev.target.value)}
|
|
||||||
placeholder="Search artists, albums, tracks…"
|
|
||||||
className="h-11 w-full rounded-xl border border-white/10 bg-zinc-950 pl-10 pr-3 text-sm text-zinc-100 placeholder:text-zinc-500 shadow-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={submitting || !query.trim()}
|
|
||||||
className="flex h-11 items-center gap-2 rounded-xl bg-primary px-5 text-sm font-medium text-primary-foreground shadow-sm transition hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* History */}
|
|
||||||
<div className="flex shrink-0 items-center justify-between px-6 pb-2 pt-4">
|
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-white">
|
|
||||||
<History className="h-3.5 w-3.5" />
|
|
||||||
Recent searches
|
|
||||||
{history && <span>· {history.length}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{!!history?.length && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => (confirmClear ? clearAll() : setConfirmClear(true))}
|
|
||||||
onBlur={() => setConfirmClear(false)}
|
|
||||||
disabled={clearing}
|
|
||||||
title={confirmClear ? `Delete all ${history.length} searches` : 'Clear all searches'}
|
|
||||||
className={`flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-white transition disabled:opacity-50 ${
|
|
||||||
confirmClear ? 'bg-red-500/20 hover:bg-red-500/30' : 'hover:bg-white/10'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{clearing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
|
|
||||||
{clearing ? 'Clearing…' : confirmClear ? `Delete ${history.length}?` : 'Clear all'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={load}
|
|
||||||
title="Refresh"
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md text-white transition hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
|
|
||||||
{error && <p className="px-2 py-3 text-sm text-red-500">Could not load history: {error}</p>}
|
|
||||||
{!error && history === null && (
|
|
||||||
<div className="flex items-center gap-2 px-2 py-6 text-sm text-zinc-500">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!error && history?.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center gap-2 px-2 py-16 text-center text-sm text-zinc-500">
|
|
||||||
<History className="h-8 w-8 opacity-40" />
|
|
||||||
No searches yet — try one above.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{history && history.length > 0 && (
|
|
||||||
<ul className="flex flex-col gap-2">
|
|
||||||
{history.map((s) => (
|
|
||||||
<li
|
|
||||||
key={s.id}
|
|
||||||
className="flex items-center gap-3 rounded-xl border border-white/10 bg-zinc-950 px-3 py-2.5 shadow-sm transition-colors hover:bg-white/5"
|
|
||||||
>
|
|
||||||
{/* The row is an anchor so it opens with cmd-click and keyboard like any link; the remove
|
|
||||||
button is its sibling, never nested inside it. */}
|
|
||||||
<Link to={hrefFor(s.id)} className="flex min-w-0 flex-1 items-center gap-3">
|
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/5 text-zinc-400">
|
|
||||||
{s.isComplete ? <Search className="h-4 w-4" /> : <Loader2 className="h-4 w-4 animate-spin" />}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-sm font-medium text-zinc-100">{s.searchText}</div>
|
|
||||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-zinc-500">
|
|
||||||
<span>{formatWhen(s.startedAt)}</span>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<Users className="h-3 w-3" />
|
|
||||||
{s.responseCount.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<FileAudio className="h-3 w-3" />
|
|
||||||
{s.fileCount.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(s.id)}
|
|
||||||
title="Remove search"
|
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-500 transition hover:bg-red-500/10 hover:text-red-400"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<ChevronRight className="h-4 w-4 shrink-0 text-zinc-600" />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
FolderOpen,
|
|
||||||
Folder,
|
|
||||||
Loader2,
|
|
||||||
Search,
|
|
||||||
RefreshCw,
|
|
||||||
Download,
|
|
||||||
ChevronRight,
|
|
||||||
ChevronDown,
|
|
||||||
TriangleAlert,
|
|
||||||
Trash2,
|
|
||||||
FileAudio,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
|
||||||
useSoulseekBrowseSnapshots,
|
|
||||||
useSoulseekBrowseLevel,
|
|
||||||
useSoulseekBrowseSearch,
|
|
||||||
useSoulseekBrowseFiles,
|
|
||||||
useSoulseekEnqueue,
|
|
||||||
} from './useSoulseekBrowse';
|
|
||||||
import { formatSize, formatWhen, type SoulseekBrowseNode, type SoulseekBrowseSnapshot } from './shared';
|
|
||||||
|
|
||||||
// A peer's shared folders, read from Officer's cache rather than browsed live, and rendered as a TREE.
|
|
||||||
//
|
|
||||||
// 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 levels.
|
|
||||||
//
|
|
||||||
// slskd's response is flat — every folder is a full backslash-delimited path — which made the old list
|
|
||||||
// unreadable: 26 rows all beginning with the same 31 characters. The sidecar links the paths into a tree
|
|
||||||
// at ingest, so a row here shows one segment and one level loads at a time.
|
|
||||||
//
|
|
||||||
// Filtering never flattens that. The search route returns matches WITH their ancestors, and this renders
|
|
||||||
// that skeleton pre-expanded: you see where a hit lives, not just that it exists. Because matching runs
|
|
||||||
// against the whole path, a folder matching implies all its descendants match too — so a matched subtree
|
|
||||||
// arrives complete.
|
|
||||||
|
|
||||||
// One level's page size. Fan-out is not gentle: the widest folder measured on a real peer has 1,181
|
|
||||||
// children, so a level can ask for more (up to the route's cap) rather than paging with Previous/Next —
|
|
||||||
// there's nothing to page through once you're inside one folder.
|
|
||||||
const LEVEL_LIMIT = 200;
|
|
||||||
const LEVEL_LIMIT_MAX = 2000;
|
|
||||||
|
|
||||||
/** 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Queue-this button, shown on row hover. Kept out of the row's own button — nesting the two is invalid. */
|
|
||||||
type QueueButtonProps = { onQueue: () => void; busy: boolean; title: string };
|
|
||||||
|
|
||||||
const QueueButton = ({ onQueue, busy, title }: QueueButtonProps) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onQueue}
|
|
||||||
disabled={busy}
|
|
||||||
title={title}
|
|
||||||
className="mr-3 flex h-6 w-6 shrink-0 items-center justify-center rounded text-zinc-600 opacity-0 transition hover:bg-white/10 hover:text-primary focus:opacity-100 disabled:opacity-40 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** The filtered tree, indexed for rendering: children by parent path, plus which rows actually matched. */
|
|
||||||
type Filtered = { childrenOf: Map<string, SoulseekBrowseNode[]>; matched: Set<string> };
|
|
||||||
|
|
||||||
export const SharesBrowser = ({ username }: { username: string }) => {
|
|
||||||
const { snapshotOf, fetchShares, dropShares } = useSoulseekBrowseSnapshots();
|
|
||||||
const [filter, setFilter] = useState('');
|
|
||||||
const q = useDebounced(filter.trim(), 250);
|
|
||||||
|
|
||||||
const snapshot = snapshotOf(username);
|
|
||||||
// Folder rows survive a failed or in-flight refresh, so drive the tree 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 roots = useSoulseekBrowseLevel({ username, parent: null, limit: LEVEL_LIMIT, enabled: hasCache && !q });
|
|
||||||
const search = useSoulseekBrowseSearch({ username, q, enabled: hasCache });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFilter('');
|
|
||||||
}, [username]);
|
|
||||||
|
|
||||||
const filtered = useMemo<Filtered | null>(() => {
|
|
||||||
if (!q || !search.data) return null;
|
|
||||||
const childrenOf = new Map<string, SoulseekBrowseNode[]>();
|
|
||||||
for (const node of search.data.nodes) {
|
|
||||||
if (!node.parentPath) continue;
|
|
||||||
const siblings = childrenOf.get(node.parentPath);
|
|
||||||
if (siblings) siblings.push(node);
|
|
||||||
else childrenOf.set(node.parentPath, [node]);
|
|
||||||
}
|
|
||||||
return { childrenOf, matched: new Set(search.data.matched) };
|
|
||||||
}, [q, search.data]);
|
|
||||||
|
|
||||||
const topNodes = q ? (search.data?.nodes.filter((n) => !n.parentPath) ?? []) : (roots.data?.nodes ?? []);
|
|
||||||
const pending = snapshot?.status === 'pending';
|
|
||||||
const busy = q ? search.isFetching : roots.isFetching;
|
|
||||||
|
|
||||||
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 outline-none placeholder:text-zinc-600"
|
|
||||||
/>
|
|
||||||
{busy && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-zinc-500" />}
|
|
||||||
{q && (
|
|
||||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
|
|
||||||
{(search.data?.total ?? 0).toLocaleString()} matching
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Never a silent cap: a truncated search still renders a valid tree, just not all of it. */}
|
|
||||||
{search.data?.truncated && q && (
|
|
||||||
<div className="border-b border-white/10 bg-amber-500/5 px-4 py-1.5 text-xs text-amber-300/80">
|
|
||||||
Showing the first {search.data.matched.length.toLocaleString()} of {search.data.total.toLocaleString()}{' '}
|
|
||||||
matches — narrow the filter to see the rest.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{q && search.data && search.data.total === 0 ? (
|
|
||||||
<p className="px-4 py-6 text-center text-sm text-zinc-500">No folders match “{q}”.</p>
|
|
||||||
) : (
|
|
||||||
// Keyed by the term so switching between browsing and a filtered view remounts the tree:
|
|
||||||
// every row's open state is local, and reusing instances across modes would strand it.
|
|
||||||
<div key={q} className="max-h-[32rem] overflow-y-auto py-1">
|
|
||||||
{topNodes.map((node) => (
|
|
||||||
<TreeNode
|
|
||||||
key={node.id}
|
|
||||||
username={username}
|
|
||||||
node={node}
|
|
||||||
filtered={filtered}
|
|
||||||
indent={0}
|
|
||||||
initialOpen={!!filtered || topNodes.length === 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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 TreeNodeProps = {
|
|
||||||
username: string;
|
|
||||||
node: SoulseekBrowseNode;
|
|
||||||
/** Non-null in filter mode: children come from this set, not from a request, and rows start open. */
|
|
||||||
filtered: Filtered | null;
|
|
||||||
indent: number;
|
|
||||||
initialOpen: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TreeNode = ({ username, node, filtered, indent, initialOpen }: TreeNodeProps) => {
|
|
||||||
const [limit, setLimit] = useState(LEVEL_LIMIT);
|
|
||||||
const [revealed, setRevealed] = useState(false);
|
|
||||||
const enqueue = useSoulseekEnqueue(username);
|
|
||||||
|
|
||||||
// Filtering stops at a revealed row: from here down it's ordinary lazy browsing, so opening a matched
|
|
||||||
// folder shows what's really inside it rather than the filtered skeleton — which is why you searched.
|
|
||||||
const scoped = revealed ? null : filtered;
|
|
||||||
const scopedChildren = scoped?.childrenOf.get(node.name) ?? [];
|
|
||||||
|
|
||||||
// A filtered row starts open only when the filter matched something below it. One that matched on its own
|
|
||||||
// name has nothing of its own to show, so it stays shut and behaves like any other collapsed folder.
|
|
||||||
const [open, setOpen] = useState(() => (filtered ? scopedChildren.length > 0 : initialOpen));
|
|
||||||
|
|
||||||
const expandable = node.childCount > 0 || node.fileCount > 0;
|
|
||||||
const level = useSoulseekBrowseLevel({
|
|
||||||
username,
|
|
||||||
parent: node.name,
|
|
||||||
limit,
|
|
||||||
enabled: !scoped && open && node.childCount > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const children = scoped ? scopedChildren : (level.data?.nodes ?? []);
|
|
||||||
// In the filtered skeleton no level was fetched, so the row's own count is what says how many exist.
|
|
||||||
const total = scoped ? node.childCount : (level.data?.total ?? node.childCount);
|
|
||||||
const capped = !scoped && limit >= LEVEL_LIMIT_MAX;
|
|
||||||
// Not while a level is in flight: `total` falls back to the row's own count then, so every loading row
|
|
||||||
// would flash a "show all" button for children it's already fetching.
|
|
||||||
const more = open && total > children.length && (!!scoped || !level.isPending);
|
|
||||||
const isMatch = filtered?.matched.has(node.name) ?? false;
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
// Opening a matched row with nothing matched below it leaves the filter behind, rather than opening
|
|
||||||
// into an empty folder.
|
|
||||||
if (!open && scoped && !scopedChildren.length) setRevealed(true);
|
|
||||||
setOpen((v) => !v);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="group flex items-center transition-colors hover:bg-white/[0.03]">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => expandable && toggle()}
|
|
||||||
style={{ paddingLeft: 12 + indent * 14 }}
|
|
||||||
className={`flex min-w-0 flex-1 items-center gap-1.5 py-1.5 text-left text-sm ${
|
|
||||||
expandable ? '' : 'cursor-default'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{expandable ? (
|
|
||||||
open ? (
|
|
||||||
<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="h-3.5 w-3.5 shrink-0" />
|
|
||||||
)}
|
|
||||||
{open ? (
|
|
||||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
|
||||||
) : (
|
|
||||||
<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-600" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={`min-w-0 flex-1 truncate ${isMatch ? 'font-medium text-primary' : 'text-zinc-200'}`}
|
|
||||||
title={node.name}
|
|
||||||
>
|
|
||||||
{node.label}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">
|
|
||||||
{node.childCount > 0 && `${node.childCount.toLocaleString()} folders · `}
|
|
||||||
{node.subtreeFileCount.toLocaleString()} files · {formatSize(node.subtreeSize)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{node.subtreeFileCount > 0 && (
|
|
||||||
<QueueButton
|
|
||||||
onQueue={() => enqueue.mutate({ path: node.name })}
|
|
||||||
busy={enqueue.isPending}
|
|
||||||
title={`Queue all ${node.subtreeFileCount.toLocaleString()} files below ${node.label}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<>
|
|
||||||
{!scoped && level.isPending && node.childCount > 0 && (
|
|
||||||
<p
|
|
||||||
style={{ paddingLeft: 12 + (indent + 1) * 14 }}
|
|
||||||
className="flex items-center gap-1.5 py-1 text-xs text-zinc-500"
|
|
||||||
>
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading folders…
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{children.map((child) => (
|
|
||||||
<TreeNode
|
|
||||||
key={child.id}
|
|
||||||
username={username}
|
|
||||||
node={child}
|
|
||||||
filtered={scoped}
|
|
||||||
indent={indent + 1}
|
|
||||||
initialOpen={!!scoped}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{/* One button for both modes: in the filtered skeleton it drops this row out of the filter, in
|
|
||||||
browse mode it widens the level. Wide levels load in one go — there's nothing to page through. */}
|
|
||||||
{more && !capped && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => (scoped ? setRevealed(true) : setLimit(Math.min(total, LEVEL_LIMIT_MAX)))}
|
|
||||||
style={{ paddingLeft: 12 + (indent + 1) * 14 }}
|
|
||||||
className="py-1 text-xs text-primary/80 transition hover:text-primary"
|
|
||||||
>
|
|
||||||
Show all {total.toLocaleString()} folders
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{more && capped && (
|
|
||||||
<p style={{ paddingLeft: 12 + (indent + 1) * 14 }} className="py-1 text-xs text-zinc-600">
|
|
||||||
Showing {children.length.toLocaleString()} of {total.toLocaleString()} folders.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{node.fileCount > 0 && <DirFiles username={username} dirId={node.id} path={node.name} indent={indent + 1} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type DirFilesProps = { username: string; dirId: number; path: string; indent: number };
|
|
||||||
|
|
||||||
const DirFiles = ({ username, dirId, path, indent }: DirFilesProps) => {
|
|
||||||
const { data, isPending, error } = useSoulseekBrowseFiles(username, dirId);
|
|
||||||
const enqueue = useSoulseekEnqueue(username);
|
|
||||||
const pad = { paddingLeft: 12 + indent * 14 };
|
|
||||||
|
|
||||||
if (isPending)
|
|
||||||
return (
|
|
||||||
<p style={pad} className="flex items-center gap-1.5 py-1 text-xs text-zinc-500">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading files…
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
if (error)
|
|
||||||
return (
|
|
||||||
<p style={pad} className="py-1 text-xs text-red-400">
|
|
||||||
Could not load this folder.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
if (!data?.length) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-h-72 overflow-y-auto">
|
|
||||||
{data.map((file) => (
|
|
||||||
<div
|
|
||||||
key={file.name}
|
|
||||||
style={pad}
|
|
||||||
className="group flex items-center gap-1.5 py-1 text-xs transition-colors hover:bg-white/[0.03]"
|
|
||||||
>
|
|
||||||
<span className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<FileAudio className="h-3 w-3 shrink-0 text-zinc-600" />
|
|
||||||
<span className="min-w-0 flex-1 truncate text-zinc-400" title={file.name}>
|
|
||||||
{file.name}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 tabular-nums text-zinc-500">{formatSize(file.size)}</span>
|
|
||||||
<QueueButton
|
|
||||||
onQueue={() => enqueue.mutate({ path, file: file.name })}
|
|
||||||
busy={enqueue.isPending}
|
|
||||||
title={`Queue ${file.name}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { Link, useSearchParams } from 'react-router';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { MessageCircle, Send, Plus, RefreshCw, X } from 'lucide-react';
|
|
||||||
import { formatClock, soulseekPeerPath, PEER_PARAM, type SlskdConversation, type SlskdPrivateMessage } from './shared';
|
|
||||||
|
|
||||||
// Chat panel — private (1:1) messaging. GET /conversations lists the peers we have threads with; GET
|
|
||||||
// /conversations/{user}/messages returns a thread (polled while it's open). Sending POSTs a bare JSON
|
|
||||||
// string body to /conversations/{user}, matching slskd's [FromBody] string endpoint; viewing a thread
|
|
||||||
// PUTs /conversations/{user} to acknowledge (clear the unread badge). Basic version: a peer rail on the
|
|
||||||
// left, transcript + composer on the right.
|
|
||||||
|
|
||||||
const POLL_MS = 2500;
|
|
||||||
|
|
||||||
const isMine = (m: SlskdPrivateMessage) => (m.direction ?? '').toLowerCase() === 'out';
|
|
||||||
|
|
||||||
export const SoulseekChat = () => {
|
|
||||||
const client = useClient();
|
|
||||||
const [params, setParams] = useSearchParams();
|
|
||||||
const selected = params.get(PEER_PARAM)?.trim() || null;
|
|
||||||
const [conversations, setConversations] = useState<SlskdConversation[]>([]);
|
|
||||||
const [messages, setMessages] = useState<SlskdPrivateMessage[]>([]);
|
|
||||||
const [draft, setDraft] = useState('');
|
|
||||||
const [peerName, setPeerName] = useState('');
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const loadConversations = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setConversations(await client.get<SlskdConversation[]>('/slskd/api/v0/conversations'));
|
|
||||||
} catch {
|
|
||||||
/* status panel surfaces connection errors */
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadConversations();
|
|
||||||
}, [loadConversations]);
|
|
||||||
|
|
||||||
// Poll the selected thread's messages while it's open, and acknowledge to clear its unread badge.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selected) {
|
|
||||||
setMessages([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
const tick = () =>
|
|
||||||
client
|
|
||||||
.get<SlskdPrivateMessage[]>(`/slskd/api/v0/conversations/${encodeURIComponent(selected)}/messages`)
|
|
||||||
.then((m) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setMessages(m);
|
|
||||||
setConversations((prev) =>
|
|
||||||
prev.map((c) => (c.username === selected ? { ...c, unAcknowledgedMessageCount: 0 } : c)),
|
|
||||||
);
|
|
||||||
client.put(`/slskd/api/v0/conversations/${encodeURIComponent(selected)}`).catch(() => {});
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
tick();
|
|
||||||
const timer = setInterval(tick, POLL_MS);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearInterval(timer);
|
|
||||||
};
|
|
||||||
// useClient() is a fresh object each render — re-poll only when the selected peer changes.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
// Stick to the bottom as new messages arrive / when switching threads.
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
|
||||||
}, [messages.length, selected]);
|
|
||||||
|
|
||||||
// Starts a thread that isn't in the list yet, so it stays a form submit rather than a link: it adds the
|
|
||||||
// peer locally *and then* opens it, and a link cannot express the "and then".
|
|
||||||
const openPeer = (name: string) => {
|
|
||||||
const target = name.trim();
|
|
||||||
if (!target) return;
|
|
||||||
setPeerName('');
|
|
||||||
setConversations((prev) => (prev.some((c) => c.username === target) ? prev : [...prev, { username: target }]));
|
|
||||||
setParams((prev) => {
|
|
||||||
const next = new URLSearchParams(prev);
|
|
||||||
next.set(PEER_PARAM, target);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const close = async (name: string) => {
|
|
||||||
try {
|
|
||||||
await client.delete(`/slskd/api/v0/conversations/${encodeURIComponent(name)}`);
|
|
||||||
} catch {
|
|
||||||
/* optimistic — drop it locally regardless */
|
|
||||||
}
|
|
||||||
setConversations((prev) => prev.filter((c) => c.username !== name));
|
|
||||||
// Closing the open thread leaves nothing open. `replace`, because the thread you just deleted is not
|
|
||||||
// somewhere Back should return you to.
|
|
||||||
if (name === selected) {
|
|
||||||
setParams(
|
|
||||||
(prev) => {
|
|
||||||
const next = new URLSearchParams(prev);
|
|
||||||
next.delete(PEER_PARAM);
|
|
||||||
return next;
|
|
||||||
},
|
|
||||||
{ replace: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const send = async () => {
|
|
||||||
const text = draft.trim();
|
|
||||||
if (!text || !selected) return;
|
|
||||||
setDraft('');
|
|
||||||
try {
|
|
||||||
await client.post(`/slskd/api/v0/conversations/${encodeURIComponent(selected)}`, text);
|
|
||||||
} catch (err) {
|
|
||||||
setDraft(text);
|
|
||||||
toast.error(`Send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full overflow-hidden">
|
|
||||||
{/* Peer rail */}
|
|
||||||
<div className="flex w-56 shrink-0 flex-col border-r bg-muted/20">
|
|
||||||
<div className="flex items-center justify-between px-3 py-2.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
<span>Conversations</span>
|
|
||||||
<button type="button" onClick={loadConversations} title="Refresh" className="rounded p-1 hover:bg-muted">
|
|
||||||
<RefreshCw className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-0.5 px-2">
|
|
||||||
{conversations.length === 0 && (
|
|
||||||
<p className="px-2 py-1 text-xs text-muted-foreground">No conversations yet.</p>
|
|
||||||
)}
|
|
||||||
{conversations.map((c) => {
|
|
||||||
const active = c.username === selected;
|
|
||||||
const unread = c.unAcknowledgedMessageCount ?? 0;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={c.username}
|
|
||||||
className={`group flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm transition-colors ${
|
|
||||||
active ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* The close button is a sibling, never nested inside the anchor. */}
|
|
||||||
<Link
|
|
||||||
to={soulseekPeerPath(c.username)}
|
|
||||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
|
||||||
aria-current={active ? 'true' : undefined}
|
|
||||||
>
|
|
||||||
<MessageCircle className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{c.username}</span>
|
|
||||||
</Link>
|
|
||||||
{unread > 0 && !active && (
|
|
||||||
<span className="shrink-0 rounded-full bg-primary px-1.5 text-[10px] font-medium tabular-nums text-primary-foreground">
|
|
||||||
{unread}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => close(c.username)}
|
|
||||||
title={`Close ${c.username}`}
|
|
||||||
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-black/10 hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Start a conversation */}
|
|
||||||
<div className="mt-auto border-t p-2">
|
|
||||||
<form
|
|
||||||
onSubmit={(ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
openPeer(peerName);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
value={peerName}
|
|
||||||
onChange={(ev) => setPeerName(ev.target.value)}
|
|
||||||
placeholder="Message a user…"
|
|
||||||
className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus:border-primary/50"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!peerName.trim()}
|
|
||||||
title="Open conversation"
|
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transcript + composer */}
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
|
||||||
{!selected ? (
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
|
||||||
<MessageCircle className="h-8 w-8" />
|
|
||||||
<p className="text-sm">Pick a conversation, or message a user to start one.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
|
|
||||||
<MessageCircle className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate font-medium">{selected}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No messages yet. Say hello.</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
{messages.map((m, i) => {
|
|
||||||
const mine = isMine(m);
|
|
||||||
return (
|
|
||||||
<div key={m.id || `${m.timestamp}-${i}`} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
|
|
||||||
<div
|
|
||||||
className={`max-w-[80%] rounded-2xl px-3 py-1.5 text-sm ${
|
|
||||||
mine ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="break-words">{m.message}</div>
|
|
||||||
<div className={`mt-0.5 text-right text-[10px] tabular-nums ${mine ? 'text-primary-foreground/70' : 'text-muted-foreground'}`}>
|
|
||||||
{formatClock(m.timestamp)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={(ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
send();
|
|
||||||
}}
|
|
||||||
className="flex shrink-0 items-center gap-2 border-t p-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
value={draft}
|
|
||||||
onChange={(ev) => setDraft(ev.target.value)}
|
|
||||||
placeholder={`Message ${selected}…`}
|
|
||||||
className="h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!draft.trim()}
|
|
||||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition hover:bg-primary/90 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import type { ServiceConnection } from '../../hooks/useServiceConnection';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import {
|
|
||||||
serviceErrorMessage,
|
|
||||||
useServiceConnection,
|
|
||||||
useServiceConnectionActions,
|
|
||||||
useServiceHealth,
|
|
||||||
} from '../../hooks/useServiceConnection';
|
|
||||||
|
|
||||||
// Connecting a slskd daemon to Officer, from the app.
|
|
||||||
//
|
|
||||||
// Both the setup wizard and the permanent settings page: SoulseekView renders it in place of whatever
|
|
||||||
// section the nav asked for while nothing is connected, and the Connection section renders it for good.
|
|
||||||
// One component, so re-pointing at a different daemon later goes through exactly the code path that
|
|
||||||
// stored the first one.
|
|
||||||
//
|
|
||||||
// The API key is write-only — the GET it reads has no field that could carry one back, so the input is
|
|
||||||
// always blank and an empty input means "keep the stored key".
|
|
||||||
|
|
||||||
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
|
|
||||||
|
|
||||||
/** slskd's own default HTTP port. Officer dials it from the server, not from this browser. */
|
|
||||||
const DEFAULT_URL = 'http://localhost:5030';
|
|
||||||
|
|
||||||
const URL_HINT =
|
|
||||||
"The daemon's base URL. Officer reaches it from the server, not from this browser — so localhost here " +
|
|
||||||
'means the machine Officer runs on.';
|
|
||||||
|
|
||||||
const KEY_HINT =
|
|
||||||
'An API key from slskd.yml (web.authentication.api_keys). Officer sends it as X-API-Key on every call.';
|
|
||||||
|
|
||||||
type SaveInput = Record<string, unknown> & { url: string };
|
|
||||||
|
|
||||||
export const SoulseekConnection = () => {
|
|
||||||
const { data, isLoading } = useServiceConnection('slskd');
|
|
||||||
const { data: health } = useServiceHealth('slskd');
|
|
||||||
const { save, forget } = useServiceConnectionActions<SaveInput>('slskd');
|
|
||||||
|
|
||||||
const connection = data?.connection ?? null;
|
|
||||||
|
|
||||||
const [url, setUrl] = useState('');
|
|
||||||
const [apiKey, setApiKey] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
// Seed from the stored row once it arrives. Keyed on its id so a save (same id) doesn't stomp what the
|
|
||||||
// owner is still typing, while forgetting and re-adding does reset the form.
|
|
||||||
useEffect(() => {
|
|
||||||
setUrl(connection?.url ?? '');
|
|
||||||
setApiKey('');
|
|
||||||
}, [connection?.id]);
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await save.mutateAsync({ url: url.trim(), apiKey: apiKey.trim() });
|
|
||||||
setApiKey('');
|
|
||||||
} catch (err) {
|
|
||||||
setError(serviceErrorMessage(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = async () => {
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await forget.mutateAsync();
|
|
||||||
} catch (err) {
|
|
||||||
setError(serviceErrorMessage(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full overflow-y-auto">
|
|
||||||
<div className="mx-auto flex max-w-xl flex-col gap-6 p-6">
|
|
||||||
<header className="flex items-start gap-3">
|
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-sky-500/15 text-sky-500">
|
|
||||||
<Plug className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold">slskd daemon</h2>
|
|
||||||
<p className={HINT}>
|
|
||||||
{connection
|
|
||||||
? 'Where Officer talks to slskd. Saving re-checks the daemon before storing anything.'
|
|
||||||
: 'Point Officer at your slskd daemon. It needs the URL and one API key.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{connection && <StatusRow connection={connection} health={health} />}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-4">
|
|
||||||
<label className="flex flex-col gap-1.5">
|
|
||||||
<span className="text-xs font-medium">Server URL</span>
|
|
||||||
<Input
|
|
||||||
value={url}
|
|
||||||
onChange={(ev) => setUrl(ev.target.value)}
|
|
||||||
placeholder={DEFAULT_URL}
|
|
||||||
autoFocus={!connection}
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
<span className={HINT}>{URL_HINT}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex flex-col gap-1.5">
|
|
||||||
<span className="text-xs font-medium">API key</span>
|
|
||||||
<Input
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(ev) => setApiKey(ev.target.value)}
|
|
||||||
placeholder={connection?.hasSecret ? '•••••••• (unchanged)' : 'slskd API key'}
|
|
||||||
type="password"
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
<span className={HINT}>{KEY_HINT}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-2.5 text-xs text-destructive">
|
|
||||||
<TriangleAlert className="mt-px h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={submit}
|
|
||||||
disabled={!url.trim() || (!apiKey.trim() && !connection?.hasSecret) || save.isPending}
|
|
||||||
>
|
|
||||||
{save.isPending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
|
||||||
{connection ? 'Save' : 'Connect'}
|
|
||||||
</Button>
|
|
||||||
{connection && (
|
|
||||||
<Button size="sm" variant="ghost" onClick={remove} disabled={forget.isPending}>
|
|
||||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
|
||||||
Disconnect
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type StatusRowProps = { connection: ServiceConnection; health: { ok: boolean; error?: string } | undefined };
|
|
||||||
|
|
||||||
const StatusRow = ({ connection, health }: StatusRowProps) => (
|
|
||||||
<div className="flex items-start gap-2 rounded-xl border p-3 text-xs">
|
|
||||||
{health?.ok ? (
|
|
||||||
<CheckCircle2 className="mt-px h-4 w-4 shrink-0 text-emerald-500" />
|
|
||||||
) : (
|
|
||||||
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-amber-500" />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="font-medium">{health?.ok ? 'Connected' : 'Not responding'}</div>
|
|
||||||
<div className="truncate text-muted-foreground">
|
|
||||||
{connection.url}
|
|
||||||
{connection.version ? ` · slskd ${connection.version}` : ''}
|
|
||||||
</div>
|
|
||||||
{!health?.ok && health?.error && <div className="text-destructive">{health.error}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { ArrowDownToLine, ArrowUpFromLine, Search, RefreshCw, Radio } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
SEARCH_PARAM,
|
|
||||||
formatWhen,
|
|
||||||
soulseekSectionPath,
|
|
||||||
transferPhase,
|
|
||||||
type SlskdApplication,
|
|
||||||
type SlskdSearchSummary,
|
|
||||||
type SlskdTransferUser,
|
|
||||||
type TransferPhase,
|
|
||||||
} from './shared';
|
|
||||||
|
|
||||||
// Dashboard — the at-a-glance overview for the /soulseek workspace: connection health, live
|
|
||||||
// download/upload tallies, and the most recent searches. Read-only aggregation over the same slskd
|
|
||||||
// endpoints the other panels use; every tile and row is a real link into the section it summarises, and
|
|
||||||
// a recent search links to that search rather than to the search screen's front page.
|
|
||||||
|
|
||||||
const POLL_MS = 2000;
|
|
||||||
|
|
||||||
type Tally = Record<TransferPhase, number>;
|
|
||||||
const emptyTally = (): Tally => ({ downloading: 0, queued: 0, done: 0, failed: 0 });
|
|
||||||
const tally = (users: SlskdTransferUser[]): Tally => {
|
|
||||||
const t = emptyTally();
|
|
||||||
for (const u of users) for (const d of u.directories ?? []) for (const f of d.files ?? []) t[transferPhase(f.state)]++;
|
|
||||||
return t;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SoulseekDashboard = () => {
|
|
||||||
const client = useClient();
|
|
||||||
const [app, setApp] = useState<SlskdApplication | null>(null);
|
|
||||||
const [downloads, setDownloads] = useState<Tally>(emptyTally);
|
|
||||||
const [uploads, setUploads] = useState<Tally>(emptyTally);
|
|
||||||
const [searches, setSearches] = useState<SlskdSearchSummary[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const tick = async () => {
|
|
||||||
const [a, d, u, s] = await Promise.allSettled([
|
|
||||||
client.get<SlskdApplication>('/slskd/api/v0/application'),
|
|
||||||
client.get<SlskdTransferUser[]>('/slskd/api/v0/transfers/downloads'),
|
|
||||||
client.get<SlskdTransferUser[]>('/slskd/api/v0/transfers/uploads'),
|
|
||||||
client.get<SlskdSearchSummary[]>('/slskd/api/v0/searches'),
|
|
||||||
]);
|
|
||||||
if (cancelled) return;
|
|
||||||
if (a.status === 'fulfilled') setApp(a.value);
|
|
||||||
if (d.status === 'fulfilled') setDownloads(tally(d.value));
|
|
||||||
if (u.status === 'fulfilled') setUploads(tally(u.value));
|
|
||||||
if (s.status === 'fulfilled')
|
|
||||||
setSearches([...s.value].sort((x, y) => new Date(y.startedAt).getTime() - new Date(x.startedAt).getTime()));
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
const timer = setInterval(tick, POLL_MS);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearInterval(timer);
|
|
||||||
};
|
|
||||||
// useClient() is a fresh object each render — poll on a stable interval only.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const server = app?.server;
|
|
||||||
const connected = server?.isConnected ?? false;
|
|
||||||
const recent = useMemo(() => searches.slice(0, 6), [searches]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full overflow-y-auto p-4">
|
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-4">
|
|
||||||
{/* Connection */}
|
|
||||||
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-zinc-950 px-4 py-3">
|
|
||||||
<Radio className="h-4 w-4 shrink-0 text-zinc-400" />
|
|
||||||
<span className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-sm font-medium text-zinc-100">{server?.state ?? 'Unknown'}</div>
|
|
||||||
{server?.address && <div className="truncate text-xs text-zinc-500">{server.address}</div>}
|
|
||||||
</div>
|
|
||||||
{app?.version?.current && (
|
|
||||||
<span className="shrink-0 text-xs text-zinc-500">
|
|
||||||
v{app.version.current}
|
|
||||||
{app.version.isUpdateAvailable ? ' · update available' : ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Downloads */}
|
|
||||||
<Section title="Downloads" icon={<ArrowDownToLine className="h-4 w-4" />} to={soulseekSectionPath('downloads')}>
|
|
||||||
<Tile label="Downloading" value={downloads.downloading} dot="bg-blue-500" />
|
|
||||||
<Tile label="Queued" value={downloads.queued} dot="bg-amber-500" />
|
|
||||||
<Tile label="Completed" value={downloads.done} dot="bg-green-500" />
|
|
||||||
<Tile label="Failed" value={downloads.failed} dot="bg-red-500" />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Uploads */}
|
|
||||||
<Section title="Uploads" icon={<ArrowUpFromLine className="h-4 w-4" />} to={soulseekSectionPath('uploads')}>
|
|
||||||
<Tile label="Uploading" value={uploads.downloading} dot="bg-blue-500" />
|
|
||||||
<Tile label="Queued" value={uploads.queued} dot="bg-amber-500" />
|
|
||||||
<Tile label="Completed" value={uploads.done} dot="bg-green-500" />
|
|
||||||
<Tile label="Failed" value={uploads.failed} dot="bg-red-500" />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Recent searches */}
|
|
||||||
<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">
|
|
||||||
<Search className="h-4 w-4 text-zinc-400" />
|
|
||||||
<span className="text-sm font-medium text-zinc-100">Recent searches</span>
|
|
||||||
<Link
|
|
||||||
to={soulseekSectionPath('search')}
|
|
||||||
className="ml-auto text-xs text-zinc-500 transition hover:text-zinc-200"
|
|
||||||
>
|
|
||||||
View all
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{recent.length === 0 ? (
|
|
||||||
<p className="px-4 py-3 text-sm text-zinc-500">No searches yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-white/5">
|
|
||||||
{recent.map((s) => (
|
|
||||||
<Link
|
|
||||||
key={s.id}
|
|
||||||
to={`${soulseekSectionPath('search')}?${SEARCH_PARAM}=${s.id}`}
|
|
||||||
className="flex w-full items-center gap-3 px-4 py-2 text-left transition hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 truncate text-sm text-zinc-200">{s.searchText}</span>
|
|
||||||
<span className="shrink-0 text-xs tabular-nums text-zinc-500">{s.responseCount} responses</span>
|
|
||||||
<span className="shrink-0 text-xs text-zinc-600">{formatWhen(s.startedAt)}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type SectionProps = { title: string; icon: React.ReactNode; to: string; children: React.ReactNode };
|
|
||||||
|
|
||||||
const Section = ({ title, icon, to, children }: SectionProps) => (
|
|
||||||
<div className="rounded-xl border border-white/10 bg-zinc-950">
|
|
||||||
<Link
|
|
||||||
to={to}
|
|
||||||
className="flex w-full items-center gap-2 border-b border-white/10 px-4 py-2.5 text-left transition hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<span className="text-zinc-400">{icon}</span>
|
|
||||||
<span className="text-sm font-medium text-zinc-100">{title}</span>
|
|
||||||
<RefreshCw className="ml-auto h-3 w-3 text-zinc-600" />
|
|
||||||
</Link>
|
|
||||||
<div className="grid grid-cols-2 gap-2 p-3 sm:grid-cols-4">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
type TileProps = { label: string; value: number; dot: string };
|
|
||||||
|
|
||||||
const Tile = ({ label, value, dot }: TileProps) => (
|
|
||||||
<div className="flex flex-col gap-1 rounded-lg border border-white/5 bg-white/[0.02] px-3 py-2.5">
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
|
||||||
<span className={`h-2 w-2 rounded-full ${dot}`} />
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<div className="text-xl font-semibold tabular-nums text-zinc-100">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
|
||||||
import { NavLink } from 'react-router';
|
|
||||||
import {
|
|
||||||
LayoutGrid,
|
|
||||||
Search,
|
|
||||||
ArrowDownToLine,
|
|
||||||
ArrowUpFromLine,
|
|
||||||
Hash,
|
|
||||||
MessageCircle,
|
|
||||||
Users,
|
|
||||||
Server,
|
|
||||||
Plug,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { SOULSEEK_SECTIONS, soulseekSectionPath, type SoulseekSectionId } from './shared';
|
|
||||||
|
|
||||||
// Left panel of the /soulseek workspace — a vertical section menu mirroring slskd's top nav. Entries are
|
|
||||||
// react-router NavLinks to /soulseek/<section>, so active state comes from the router and SoulseekView
|
|
||||||
// (right) learns which section is open by reading the same URL rather than by being told.
|
|
||||||
|
|
||||||
const ICONS: Record<SoulseekSectionId, LucideIcon> = {
|
|
||||||
dashboard: LayoutGrid,
|
|
||||||
search: Search,
|
|
||||||
downloads: ArrowDownToLine,
|
|
||||||
uploads: ArrowUpFromLine,
|
|
||||||
rooms: Hash,
|
|
||||||
chat: MessageCircle,
|
|
||||||
users: Users,
|
|
||||||
system: Server,
|
|
||||||
connection: Plug,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SoulseekNav = () => {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
|
||||||
<div className="flex items-center gap-3 px-4 py-4">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-white shadow-sm ring-1 ring-black/5">
|
|
||||||
<img src="/slskd.png" alt="" className="h-5 w-5 object-contain" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-semibold leading-tight">Soulseek</div>
|
|
||||||
<div className="truncate text-xs text-muted-foreground">slskd client</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
|
||||||
{SOULSEEK_SECTIONS.map(({ id, label }) => {
|
|
||||||
const Icon = ICONS[id];
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={id}
|
|
||||||
to={soulseekSectionPath(id)}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
|
||||||
isActive
|
|
||||||
? 'bg-primary/10 font-medium text-primary'
|
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<>
|
|
||||||
{isActive && (
|
|
||||||
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
|
||||||
)}
|
|
||||||
<Icon
|
|
||||||
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
|
||||||
/>
|
|
||||||
{label}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
|
||||||
import { Link, useSearchParams } from 'react-router';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Hash, Users, LogOut, Send, Plus, RefreshCw } from 'lucide-react';
|
|
||||||
import { formatClock, soulseekRoomPath, ROOM_PARAM, type SlskdRoom, type SlskdRoomInfo } from './shared';
|
|
||||||
|
|
||||||
// Rooms panel — join Soulseek chat rooms and talk in them. GET /rooms/joined lists the room names we're
|
|
||||||
// in; GET /rooms/joined/{name} inlines that room's users + messages (polled while it's open); GET
|
|
||||||
// /rooms/available lists everything else to join. Messages/joins POST a bare JSON string body, matching
|
|
||||||
// slskd's [FromBody] string endpoints. Basic version: a room rail on the left, transcript + composer on
|
|
||||||
// the right.
|
|
||||||
|
|
||||||
const POLL_MS = 2500;
|
|
||||||
const AVAILABLE_LIMIT = 250;
|
|
||||||
|
|
||||||
export const SoulseekRooms = () => {
|
|
||||||
const client = useClient();
|
|
||||||
const [joined, setJoined] = useState<string[]>([]);
|
|
||||||
const [available, setAvailable] = useState<SlskdRoomInfo[]>([]);
|
|
||||||
const [params, setParams] = useSearchParams();
|
|
||||||
const selected = params.get(ROOM_PARAM)?.trim() || null;
|
|
||||||
const [room, setRoom] = useState<SlskdRoom | null>(null);
|
|
||||||
const [draft, setDraft] = useState('');
|
|
||||||
const [joinName, setJoinName] = useState('');
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const loadJoined = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setJoined(await client.get<string[]>('/slskd/api/v0/rooms/joined'));
|
|
||||||
} catch {
|
|
||||||
/* status panel surfaces connection errors */
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadAvailable = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setAvailable(await client.get<SlskdRoomInfo[]>('/slskd/api/v0/rooms/available'));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadJoined();
|
|
||||||
loadAvailable();
|
|
||||||
}, [loadJoined, loadAvailable]);
|
|
||||||
|
|
||||||
// Poll the selected room's transcript + user list while it's open.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selected) {
|
|
||||||
setRoom(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
const tick = () =>
|
|
||||||
client
|
|
||||||
.get<SlskdRoom>(`/slskd/api/v0/rooms/joined/${encodeURIComponent(selected)}`)
|
|
||||||
.then((r) => !cancelled && setRoom(r))
|
|
||||||
.catch(() => {});
|
|
||||||
tick();
|
|
||||||
const timer = setInterval(tick, POLL_MS);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearInterval(timer);
|
|
||||||
};
|
|
||||||
// useClient() is a fresh object each render — re-poll only when the selected room changes.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
const messages = room?.messages ?? [];
|
|
||||||
// Stick to the bottom as new messages arrive / when switching rooms.
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
|
||||||
}, [messages.length, selected]);
|
|
||||||
|
|
||||||
const notJoined = useMemo(() => {
|
|
||||||
const have = new Set(joined);
|
|
||||||
return available
|
|
||||||
.filter((r) => !have.has(r.name))
|
|
||||||
.sort((a, b) => b.userCount - a.userCount)
|
|
||||||
.slice(0, AVAILABLE_LIMIT);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [available, joined]);
|
|
||||||
|
|
||||||
const join = async (name: string) => {
|
|
||||||
const target = name.trim();
|
|
||||||
if (!target || busy) return;
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
await client.post('/slskd/api/v0/rooms/joined', target);
|
|
||||||
setJoinName('');
|
|
||||||
setJoined((prev) => (prev.includes(target) ? prev : [...prev, target]));
|
|
||||||
// Joins the room *and then* opens it, so this stays a button — a link cannot express the "and then".
|
|
||||||
setParams((prev) => {
|
|
||||||
const next = new URLSearchParams(prev);
|
|
||||||
next.set(ROOM_PARAM, target);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
toast.success(`Joined ${target}`);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(`Couldn't join ${target}: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const leave = async (name: string) => {
|
|
||||||
try {
|
|
||||||
await client.delete(`/slskd/api/v0/rooms/joined/${encodeURIComponent(name)}`);
|
|
||||||
setJoined((prev) => prev.filter((n) => n !== name));
|
|
||||||
// Leaving the open room leaves nothing open. `replace`, because a room you are no longer in is not
|
|
||||||
// somewhere Back should return you to.
|
|
||||||
if (name === selected) {
|
|
||||||
setParams(
|
|
||||||
(prev) => {
|
|
||||||
const next = new URLSearchParams(prev);
|
|
||||||
next.delete(ROOM_PARAM);
|
|
||||||
return next;
|
|
||||||
},
|
|
||||||
{ replace: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(`Couldn't leave ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const send = async () => {
|
|
||||||
const text = draft.trim();
|
|
||||||
if (!text || !selected) return;
|
|
||||||
setDraft('');
|
|
||||||
try {
|
|
||||||
await client.post(`/slskd/api/v0/rooms/joined/${encodeURIComponent(selected)}/messages`, text);
|
|
||||||
} catch (err) {
|
|
||||||
setDraft(text);
|
|
||||||
toast.error(`Send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full overflow-hidden">
|
|
||||||
{/* Room rail */}
|
|
||||||
<div className="flex w-56 shrink-0 flex-col border-r bg-muted/20">
|
|
||||||
<div className="flex items-center justify-between px-3 py-2.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
<span>Joined</span>
|
|
||||||
<button type="button" onClick={loadJoined} title="Refresh" className="rounded p-1 hover:bg-muted">
|
|
||||||
<RefreshCw className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-0.5 px-2">
|
|
||||||
{joined.length === 0 && <p className="px-2 py-1 text-xs text-muted-foreground">No rooms joined yet.</p>}
|
|
||||||
{joined.map((name) => {
|
|
||||||
const active = name === selected;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={name}
|
|
||||||
className={`group flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm transition-colors ${
|
|
||||||
active ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* The leave button is a sibling, never nested inside the anchor. */}
|
|
||||||
<Link
|
|
||||||
to={soulseekRoomPath(name)}
|
|
||||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
|
||||||
aria-current={active ? 'true' : undefined}
|
|
||||||
>
|
|
||||||
<Hash className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{name}</span>
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => leave(name)}
|
|
||||||
title={`Leave ${name}`}
|
|
||||||
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-black/10 hover:text-foreground"
|
|
||||||
>
|
|
||||||
<LogOut className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Join a room */}
|
|
||||||
<div className="mt-auto border-t p-2">
|
|
||||||
<form
|
|
||||||
onSubmit={(ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
join(joinName);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
value={joinName}
|
|
||||||
onChange={(ev) => setJoinName(ev.target.value)}
|
|
||||||
list="slskd-available-rooms"
|
|
||||||
placeholder="Join a room…"
|
|
||||||
className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus:border-primary/50"
|
|
||||||
/>
|
|
||||||
<datalist id="slskd-available-rooms">
|
|
||||||
{notJoined.map((r) => (
|
|
||||||
<option key={r.name} value={r.name}>
|
|
||||||
{r.userCount} users
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!joinName.trim() || busy}
|
|
||||||
title="Join"
|
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transcript + composer */}
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
|
||||||
{!selected ? (
|
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
|
||||||
<Hash className="h-8 w-8" />
|
|
||||||
<p className="text-sm">
|
|
||||||
{joined.length === 0 ? 'Join a room to start chatting.' : 'Pick a room to read it.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
|
|
||||||
<Hash className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate font-medium">{selected}</span>
|
|
||||||
<span className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<Users className="h-3.5 w-3.5" /> {room?.users?.length ?? 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No messages yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
{messages.map((m, i) => (
|
|
||||||
<div key={`${m.timestamp}-${i}`} className="flex gap-2 text-sm">
|
|
||||||
<span className="shrink-0 pt-0.5 text-xs tabular-nums text-muted-foreground">{formatClock(m.timestamp)}</span>
|
|
||||||
<span className={`shrink-0 font-medium ${m.self ? 'text-primary' : 'text-foreground'}`}>{m.username}</span>
|
|
||||||
<span className="min-w-0 break-words text-foreground/90">{m.message}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={(ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
send();
|
|
||||||
}}
|
|
||||||
className="flex shrink-0 items-center gap-2 border-t p-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
value={draft}
|
|
||||||
onChange={(ev) => setDraft(ev.target.value)}
|
|
||||||
placeholder={`Message ${selected}…`}
|
|
||||||
className="h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!draft.trim()}
|
|
||||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition hover:bg-primary/90 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Server, Radio, Plug, PlugZap, RefreshCw, Package, Loader2 } from 'lucide-react';
|
|
||||||
import type { SlskdApplication, SlskdServerState } from './shared';
|
|
||||||
|
|
||||||
// System panel — connection health and controls for the slskd daemon. GET /application carries the
|
|
||||||
// server block (state/address/username) and version info; PUT /server connects to Soulseek, DELETE
|
|
||||||
// /server disconnects. Basic version: a connection card with connect/disconnect, and a version card.
|
|
||||||
|
|
||||||
const POLL_MS = 3000;
|
|
||||||
|
|
||||||
export const SoulseekSystem = () => {
|
|
||||||
const client = useClient();
|
|
||||||
const [app, setApp] = useState<SlskdApplication | null>(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setApp(await client.get<SlskdApplication>('/slskd/api/v0/application'));
|
|
||||||
} catch {
|
|
||||||
/* connection card will show the stale/empty state */
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refresh();
|
|
||||||
const timer = setInterval(refresh, POLL_MS);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const server: SlskdServerState | undefined = app?.server;
|
|
||||||
const connected = server?.isConnected ?? false;
|
|
||||||
|
|
||||||
const connect = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
await client.put('/slskd/api/v0/server');
|
|
||||||
toast.success('Connecting to Soulseek…');
|
|
||||||
await refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(`Connect failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const disconnect = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
await client.delete('/slskd/api/v0/server');
|
|
||||||
toast.success('Disconnected from Soulseek.');
|
|
||||||
await refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(`Disconnect failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const version = app?.version;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full overflow-y-auto p-4">
|
|
||||||
<div className="mx-auto flex max-w-2xl flex-col gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Server className="h-4 w-4 text-zinc-400" />
|
|
||||||
<span className="text-sm font-medium text-zinc-100">System</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={refresh}
|
|
||||||
title="Refresh"
|
|
||||||
className="ml-auto rounded p-1 text-zinc-500 transition hover:bg-white/5 hover:text-zinc-200"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connection */}
|
|
||||||
<div className="rounded-xl border border-white/10 bg-zinc-950">
|
|
||||||
<div className="flex items-center gap-3 border-b border-white/10 px-4 py-3">
|
|
||||||
<Radio className="h-4 w-4 shrink-0 text-zinc-400" />
|
|
||||||
<span className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-sm font-medium text-zinc-100">{server?.state ?? 'Unknown'}</div>
|
|
||||||
{server?.address && <div className="truncate text-xs text-zinc-500">{server.address}</div>}
|
|
||||||
</div>
|
|
||||||
{server?.username && <span className="shrink-0 text-xs text-zinc-400">{server.username}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 p-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={connect}
|
|
||||||
disabled={busy || connected}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-200 transition hover:bg-white/10 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <PlugZap className="h-3.5 w-3.5" />}
|
|
||||||
Connect
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={disconnect}
|
|
||||||
disabled={busy || !connected}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-200 transition hover:bg-white/10 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plug className="h-3.5 w-3.5" />}
|
|
||||||
Disconnect
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Version */}
|
|
||||||
<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">
|
|
||||||
<Package className="h-4 w-4 text-zinc-400" />
|
|
||||||
<span className="text-sm font-medium text-zinc-100">Version</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2 p-3">
|
|
||||||
<Field label="Current" value={version?.current ?? '—'} />
|
|
||||||
<Field label="Latest" value={version?.latest ?? '—'} />
|
|
||||||
<div className="col-span-2">
|
|
||||||
{version?.isUpdateAvailable ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-amber-500/10 px-2 py-1 text-xs text-amber-400">
|
|
||||||
<Package className="h-3.5 w-3.5" /> Update available
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-zinc-500">Up to date</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FieldProps = { label: string; value: string };
|
|
||||||
|
|
||||||
const Field = ({ label, value }: FieldProps) => (
|
|
||||||
<div className="flex flex-col gap-1 rounded-lg border border-white/5 bg-white/[0.02] px-3 py-2">
|
|
||||||
<span className="text-xs text-zinc-500">{label}</span>
|
|
||||||
<span className="truncate text-sm font-medium tabular-nums text-zinc-100">{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,494 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
|
||||||
import { RefreshCw, X, Radio, Folder, RotateCcw, Ban, Trash2 } from 'lucide-react';
|
|
||||||
import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList } from './Cards';
|
|
||||||
import { UserMenu } from './UserMenu';
|
|
||||||
import {
|
|
||||||
SLSKD_REFRESH_CHANNEL,
|
|
||||||
basename,
|
|
||||||
folderLabel,
|
|
||||||
formatSize,
|
|
||||||
formatSpeed,
|
|
||||||
transferPhase,
|
|
||||||
type SlskdApplication,
|
|
||||||
type SlskdDownloadUser,
|
|
||||||
type SlskdTransfer,
|
|
||||||
type TransferPhase,
|
|
||||||
} from './shared';
|
|
||||||
|
|
||||||
// Right panel of the /soulseek workspace — the connection status strip plus the live download queue.
|
|
||||||
// Downloads are polled from slskd (GET /transfers/downloads, nested user → directories → files) and
|
|
||||||
// presented the same way as search results: a collapsible card per user, files grouped by folder.
|
|
||||||
// Enqueuing from the search panel bumps the refresh channel so the list updates without waiting for the
|
|
||||||
// next poll tick.
|
|
||||||
|
|
||||||
const POLL_MS = 1500;
|
|
||||||
|
|
||||||
type Row = SlskdTransfer & { phase: TransferPhase };
|
|
||||||
type DlDir = { directory: string; label: string; files: Row[]; size: number };
|
|
||||||
type DlUser = { username: string; dirs: DlDir[]; counts: Record<TransferPhase, number>; total: number };
|
|
||||||
|
|
||||||
const PHASE_ORDER: Record<TransferPhase, number> = { downloading: 0, queued: 1, failed: 2, done: 3 };
|
|
||||||
const PHASES: TransferPhase[] = ['downloading', 'queued', 'failed', 'done'];
|
|
||||||
|
|
||||||
const phaseStyle: Record<TransferPhase, { label: string; dot: string; bar: string }> = {
|
|
||||||
downloading: { label: 'Downloading', dot: 'bg-blue-500', bar: 'bg-blue-500' },
|
|
||||||
queued: { label: 'Queued', dot: 'bg-amber-500', bar: 'bg-amber-400' },
|
|
||||||
done: { label: 'Done', dot: 'bg-green-500', bar: 'bg-green-500' },
|
|
||||||
failed: { label: 'Failed', dot: 'bg-red-500', bar: 'bg-red-500' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const isRemovable = (phase: TransferPhase) => phase === 'done' || phase === 'failed';
|
|
||||||
|
|
||||||
// Group raw download users → dirs → files, keeping only files whose phase passes `keep`. Users/dirs
|
|
||||||
// that end up empty are dropped, so passing a done/not-done predicate splits the queue into sections.
|
|
||||||
const group = (users: SlskdDownloadUser[], keep: (phase: TransferPhase) => boolean): DlUser[] => {
|
|
||||||
const out: DlUser[] = [];
|
|
||||||
for (const user of users) {
|
|
||||||
const dirs: DlDir[] = [];
|
|
||||||
for (const dir of user.directories ?? []) {
|
|
||||||
const files: Row[] = (dir.files ?? [])
|
|
||||||
.map((f) => ({ ...f, phase: transferPhase(f.state) }))
|
|
||||||
.filter((f) => keep(f.phase));
|
|
||||||
if (files.length === 0) continue;
|
|
||||||
files.sort(
|
|
||||||
(a, b) =>
|
|
||||||
PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || basename(a.filename).localeCompare(basename(b.filename)),
|
|
||||||
);
|
|
||||||
dirs.push({
|
|
||||||
directory: dir.directory,
|
|
||||||
label: folderLabel(dir.directory),
|
|
||||||
files,
|
|
||||||
size: files.reduce((n, f) => n + f.size, 0),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (dirs.length === 0) continue;
|
|
||||||
dirs.sort((a, b) => a.directory.localeCompare(b.directory));
|
|
||||||
const counts: Record<TransferPhase, number> = { downloading: 0, queued: 0, failed: 0, done: 0 };
|
|
||||||
let total = 0;
|
|
||||||
for (const dir of dirs) for (const f of dir.files) (counts[f.phase]++, total++);
|
|
||||||
out.push({ username: user.username, dirs, counts, total });
|
|
||||||
}
|
|
||||||
// Active users first, then alphabetical.
|
|
||||||
out.sort(
|
|
||||||
(a, b) =>
|
|
||||||
Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username),
|
|
||||||
);
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SoulseekTransfers = () => {
|
|
||||||
const client = useClient();
|
|
||||||
const [refresh] = usePanelChannel<number>(SLSKD_REFRESH_CHANNEL, 0);
|
|
||||||
|
|
||||||
const [app, setApp] = useState<SlskdApplication | null>(null);
|
|
||||||
const [statusError, setStatusError] = useState<string | null>(null);
|
|
||||||
const [statusLoading, setStatusLoading] = useState(true);
|
|
||||||
const [data, setData] = useState<SlskdDownloadUser[]>([]);
|
|
||||||
// Users are open by default; collapsing a live-updating list is opt-in so new users appear expanded.
|
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Two sections: everything still in flight (downloading / queued / failed) on top, finished
|
|
||||||
// (succeeded) collected at the bottom. A user with a mix appears in both.
|
|
||||||
const active = useMemo(() => group(data, (p) => p !== 'done'), [data]);
|
|
||||||
const completed = useMemo(() => group(data, (p) => p === 'done'), [data]);
|
|
||||||
const doneCount = useMemo(() => completed.reduce((n, u) => n + u.total, 0), [completed]);
|
|
||||||
|
|
||||||
// Queue-wide phase tallies drive the bulk toolbar. Cancelled transfers collapse to `failed`, so the
|
|
||||||
// three actions target disjoint sets: retry → failed, cancel → in-flight, remove → done.
|
|
||||||
const totals = useMemo(() => {
|
|
||||||
const t: Record<TransferPhase, number> = { downloading: 0, queued: 0, failed: 0, done: 0 };
|
|
||||||
for (const u of data)
|
|
||||||
for (const d of u.directories ?? []) for (const f of d.files ?? []) t[transferPhase(f.state)]++;
|
|
||||||
return t;
|
|
||||||
}, [data]);
|
|
||||||
const inFlight = totals.downloading + totals.queued;
|
|
||||||
|
|
||||||
const loadStatus = useCallback(() => {
|
|
||||||
setStatusLoading(true);
|
|
||||||
setStatusError(null);
|
|
||||||
client
|
|
||||||
.get<SlskdApplication>('/slskd/api/v0/application')
|
|
||||||
.then((d) => setApp(d))
|
|
||||||
.catch((err) => setStatusError(err instanceof Error ? err.message : String(err)))
|
|
||||||
.finally(() => setStatusLoading(false));
|
|
||||||
// useClient() is a fresh object each render — keep this callback stable.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadStatus();
|
|
||||||
}, [loadStatus]);
|
|
||||||
|
|
||||||
// Poll the download queue; also refetch immediately when the search panel bumps the refresh nonce.
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const tick = () =>
|
|
||||||
client
|
|
||||||
.get<SlskdDownloadUser[]>('/slskd/api/v0/transfers/downloads')
|
|
||||||
.then((u) => !cancelled && setData(u))
|
|
||||||
.catch(() => {});
|
|
||||||
tick();
|
|
||||||
const timer = setInterval(tick, POLL_MS);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearInterval(timer);
|
|
||||||
};
|
|
||||||
// useClient() is a fresh object each render — poll on the refresh nonce only.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
// Collapse keys are section-qualified ("a:<user>" / "c:<user>") so the same peer can be open in the
|
|
||||||
// active section and collapsed in completed independently.
|
|
||||||
const toggle = (key: string) =>
|
|
||||||
setCollapsed((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(key)) next.delete(key);
|
|
||||||
else next.add(key);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
const dropIds = (ids: Set<string>) =>
|
|
||||||
setData((prev) =>
|
|
||||||
prev.map((u) => ({
|
|
||||||
...u,
|
|
||||||
directories: (u.directories ?? []).map((d) => ({ ...d, files: (d.files ?? []).filter((f) => !ids.has(f.id)) })),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
// For an active transfer this cancels it; for a finished/failed one it clears it from the queue.
|
|
||||||
const remove = async (row: Row) => {
|
|
||||||
const done = isRemovable(row.phase);
|
|
||||||
try {
|
|
||||||
await client.delete(
|
|
||||||
`/slskd/api/v0/transfers/downloads/${encodeURIComponent(row.username)}/${row.id}${done ? '?remove=true' : ''}`,
|
|
||||||
);
|
|
||||||
dropIds(new Set([row.id]));
|
|
||||||
} catch {
|
|
||||||
/* next poll reconciles */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearCompleted = async (user: DlUser) => {
|
|
||||||
const finished = user.dirs.flatMap((d) => d.files).filter((f) => isRemovable(f.phase));
|
|
||||||
if (finished.length === 0) return;
|
|
||||||
await Promise.allSettled(
|
|
||||||
finished.map((f) =>
|
|
||||||
client.delete(`/slskd/api/v0/transfers/downloads/${encodeURIComponent(user.username)}/${f.id}?remove=true`),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
dropIds(new Set(finished.map((f) => f.id)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAllCompleted = async () => {
|
|
||||||
const finished = completed.flatMap((u) =>
|
|
||||||
u.dirs.flatMap((d) => d.files.map((f) => ({ username: u.username, id: f.id }))),
|
|
||||||
);
|
|
||||||
if (finished.length === 0) return;
|
|
||||||
await Promise.allSettled(
|
|
||||||
finished.map((f) =>
|
|
||||||
client.delete(`/slskd/api/v0/transfers/downloads/${encodeURIComponent(f.username)}/${f.id}?remove=true`),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
dropIds(new Set(finished.map((f) => f.id)));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cancel every in-flight transfer (no ?remove — they become cancelled/failed and the poll reconciles).
|
|
||||||
const cancelAll = async () => {
|
|
||||||
const targets: { username: string; id: string }[] = [];
|
|
||||||
for (const u of data)
|
|
||||||
for (const d of u.directories ?? [])
|
|
||||||
for (const f of d.files ?? []) {
|
|
||||||
const p = transferPhase(f.state);
|
|
||||||
if (p === 'downloading' || p === 'queued') targets.push({ username: u.username, id: f.id });
|
|
||||||
}
|
|
||||||
if (targets.length === 0) return;
|
|
||||||
await Promise.allSettled(
|
|
||||||
targets.map((t) => client.delete(`/slskd/api/v0/transfers/downloads/${encodeURIComponent(t.username)}/${t.id}`)),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Re-enqueue every errored/cancelled transfer, grouped into one POST per peer.
|
|
||||||
const retryErrored = async () => {
|
|
||||||
const byUser = new Map<string, { filename: string; size: number }[]>();
|
|
||||||
for (const u of data)
|
|
||||||
for (const d of u.directories ?? [])
|
|
||||||
for (const f of d.files ?? [])
|
|
||||||
if (transferPhase(f.state) === 'failed') {
|
|
||||||
const files = byUser.get(u.username) ?? [];
|
|
||||||
files.push({ filename: f.filename, size: f.size });
|
|
||||||
byUser.set(u.username, files);
|
|
||||||
}
|
|
||||||
if (byUser.size === 0) return;
|
|
||||||
const n = [...byUser.values()].reduce((s, a) => s + a.length, 0);
|
|
||||||
try {
|
|
||||||
await Promise.all(
|
|
||||||
[...byUser].map(([username, files]) =>
|
|
||||||
client.post(`/slskd/api/v0/transfers/downloads/${encodeURIComponent(username)}`, files),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
toast.success(n === 1 ? 'Retrying 1 download' : `Retrying ${n} downloads`);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(`Retry failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Force slskd to re-request this download's place in the peer's queue, then patch the fresh value in.
|
|
||||||
const refreshPosition = async (row: Row) => {
|
|
||||||
try {
|
|
||||||
const t = await client.get<SlskdTransfer>(
|
|
||||||
`/slskd/api/v0/transfers/downloads/${encodeURIComponent(row.username)}/${row.id}/position`,
|
|
||||||
);
|
|
||||||
setData((prev) =>
|
|
||||||
prev.map((u) =>
|
|
||||||
u.username !== row.username
|
|
||||||
? u
|
|
||||||
: {
|
|
||||||
...u,
|
|
||||||
directories: (u.directories ?? []).map((d) => ({
|
|
||||||
...d,
|
|
||||||
files: (d.files ?? []).map((f) => (f.id === row.id ? { ...f, placeInQueue: t.placeInQueue } : f)),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
toast.success(t.placeInQueue ? `Place in queue: #${t.placeInQueue}` : 'Queue position refreshed');
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(`Couldn't fetch position: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const server = app?.server;
|
|
||||||
const connected = server?.isConnected ?? false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-b p-3">
|
|
||||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
|
||||||
<Radio className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
{statusLoading && !app ? (
|
|
||||||
<span className="text-muted-foreground">Checking slskd…</span>
|
|
||||||
) : statusError ? (
|
|
||||||
<span className="truncate text-red-500">slskd unreachable: {statusError}</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-2 w-2 shrink-0 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{server?.state ?? 'unknown'}</span>
|
|
||||||
{server?.address && <span className="truncate text-muted-foreground">· {server.address}</span>}
|
|
||||||
{app?.version?.current && (
|
|
||||||
<span className="shrink-0 text-muted-foreground">· v{app.version.current}</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={loadStatus}
|
|
||||||
title="Refresh status"
|
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-3.5 w-3.5 ${statusLoading ? 'animate-spin' : ''}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(active.length > 0 || completed.length > 0) && (
|
|
||||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-3 py-2">
|
|
||||||
<BulkButton
|
|
||||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
|
||||||
label="Retry errored"
|
|
||||||
count={totals.failed}
|
|
||||||
onClick={retryErrored}
|
|
||||||
/>
|
|
||||||
<BulkButton icon={<Ban className="h-3.5 w-3.5" />} label="Cancel all" count={inFlight} onClick={cancelAll} />
|
|
||||||
<BulkButton
|
|
||||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
|
||||||
label="Remove completed"
|
|
||||||
count={totals.done}
|
|
||||||
onClick={clearAllCompleted}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
||||||
{active.length === 0 && completed.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No downloads yet. Search and queue a file to see it here.</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{active.length > 0 && (
|
|
||||||
<section className="flex flex-col gap-2">
|
|
||||||
<div className="px-0.5 text-xs font-medium uppercase tracking-wide text-zinc-500">
|
|
||||||
Active ({active.length})
|
|
||||||
</div>
|
|
||||||
{active.map((user) => (
|
|
||||||
<UserCard
|
|
||||||
key={user.username}
|
|
||||||
user={user}
|
|
||||||
open={!collapsed.has(`a:${user.username}`)}
|
|
||||||
onToggle={() => toggle(`a:${user.username}`)}
|
|
||||||
onRemove={remove}
|
|
||||||
onPosition={refreshPosition}
|
|
||||||
onClearCompleted={() => clearCompleted(user)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{completed.length > 0 && (
|
|
||||||
<section className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between px-0.5">
|
|
||||||
<div className="text-xs font-medium uppercase tracking-wide text-zinc-500">
|
|
||||||
Completed ({doneCount})
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={clearAllCompleted}
|
|
||||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-zinc-500 transition hover:bg-white/5 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" /> Clear all
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{completed.map((user) => (
|
|
||||||
<UserCard
|
|
||||||
key={user.username}
|
|
||||||
user={user}
|
|
||||||
open={!collapsed.has(`c:${user.username}`)}
|
|
||||||
onToggle={() => toggle(`c:${user.username}`)}
|
|
||||||
onRemove={remove}
|
|
||||||
onPosition={refreshPosition}
|
|
||||||
onClearCompleted={() => clearCompleted(user)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type BulkButtonProps = { icon: ReactNode; label: string; count: number; onClick: () => void };
|
|
||||||
|
|
||||||
const BulkButton = ({ icon, label, count, onClick }: BulkButtonProps) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={count === 0}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/[0.02] px-2.5 py-1 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100 disabled:cursor-default disabled:opacity-40 disabled:hover:bg-white/[0.02] disabled:hover:text-zinc-300"
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<span>{label}</span>
|
|
||||||
{count > 0 && <span className="rounded bg-white/10 px-1 tabular-nums text-zinc-400">{count}</span>}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
type UserCardProps = {
|
|
||||||
user: DlUser;
|
|
||||||
open: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onRemove: (row: Row) => void;
|
|
||||||
onPosition: (row: Row) => void;
|
|
||||||
onClearCompleted: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserCard = ({ user, open, onToggle, onRemove, onPosition, onClearCompleted }: UserCardProps) => {
|
|
||||||
const finished = user.counts.done + user.counts.failed;
|
|
||||||
const meta = PHASES.filter((p) => user.counts[p] > 0).map((p) => (
|
|
||||||
<span key={p} className="inline-flex items-center gap-1" title={phaseStyle[p].label}>
|
|
||||||
<span className={`h-2 w-2 rounded-full ${phaseStyle[p].dot}`} />
|
|
||||||
{user.counts[p]}
|
|
||||||
</span>
|
|
||||||
));
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader open={open} onToggle={onToggle} titleMenu={<UserMenu username={user.username} />} meta={meta} />
|
|
||||||
{open && (
|
|
||||||
<CardBody>
|
|
||||||
{user.dirs.map((dir) => (
|
|
||||||
<FolderBlock key={dir.directory} dir={dir} onRemove={onRemove} onPosition={onPosition} />
|
|
||||||
))}
|
|
||||||
{finished > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClearCompleted}
|
|
||||||
className="flex items-center justify-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.02] py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" /> Clear completed ({finished})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FolderBlockProps = { dir: DlDir; onRemove: (row: Row) => void; onPosition: (row: Row) => void };
|
|
||||||
|
|
||||||
const FolderBlock = ({ dir, onRemove, onPosition }: FolderBlockProps) => {
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
return (
|
|
||||||
<SubCard>
|
|
||||||
<SubCardHeader
|
|
||||||
open={open}
|
|
||||||
onToggle={() => setOpen((v) => !v)}
|
|
||||||
icon={<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-500" />}
|
|
||||||
label={dir.label}
|
|
||||||
title={dir.directory}
|
|
||||||
meta={`${dir.files.length} · ${formatSize(dir.size)}`}
|
|
||||||
/>
|
|
||||||
{open && (
|
|
||||||
<RowList>
|
|
||||||
{dir.files.map((row) => {
|
|
||||||
const style = phaseStyle[row.phase];
|
|
||||||
const pct = row.phase === 'done' ? 100 : Math.max(0, Math.min(100, Math.round(row.percentComplete)));
|
|
||||||
const speed = row.phase === 'downloading' ? formatSpeed(row.averageSpeed) : '';
|
|
||||||
return (
|
|
||||||
<div key={row.id} className="px-3 py-2 pl-8 text-sm text-zinc-200">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`h-2 w-2 shrink-0 rounded-full ${style.dot}`} />
|
|
||||||
<span className="min-w-0 flex-1 truncate">{basename(row.filename)}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onRemove(row)}
|
|
||||||
title={isRemovable(row.phase) ? 'Clear' : 'Cancel'}
|
|
||||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-400 hover:bg-white/10 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-white/10">
|
|
||||||
<div className={`h-full ${style.bar}`} style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-zinc-500">
|
|
||||||
{row.phase === 'queued' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onPosition(row)}
|
|
||||||
title={
|
|
||||||
row.placeInQueue
|
|
||||||
? `Place in queue: #${row.placeInQueue} · click to refresh`
|
|
||||||
: 'Queued · click to fetch place in queue'
|
|
||||||
}
|
|
||||||
className="inline-flex items-center gap-1 rounded text-amber-400/90 transition hover:text-amber-300"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3 w-3" />
|
|
||||||
{row.placeInQueue ? `#${row.placeInQueue} in queue` : 'Queued'}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span>{style.label}</span>
|
|
||||||
)}
|
|
||||||
{row.size > 0 && <span>· {formatSize(row.size)}</span>}
|
|
||||||
{speed && <span>· {speed}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</RowList>
|
|
||||||
)}
|
|
||||||
</SubCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { RefreshCw, X, Folder, ArrowUpFromLine } from 'lucide-react';
|
|
||||||
import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList } from './Cards';
|
|
||||||
import {
|
|
||||||
basename,
|
|
||||||
folderLabel,
|
|
||||||
formatSize,
|
|
||||||
formatSpeed,
|
|
||||||
transferPhase,
|
|
||||||
type SlskdTransfer,
|
|
||||||
type SlskdTransferUser,
|
|
||||||
type TransferPhase,
|
|
||||||
} from './shared';
|
|
||||||
|
|
||||||
// Uploads panel — the mirror image of Downloads: what peers are pulling from us. GET /transfers/uploads
|
|
||||||
// returns the same user → directories → files shape, so we group and render it the same way (collapsible
|
|
||||||
// card per peer, files by folder, a progress bar per file). Basic version: one flat list, per-row
|
|
||||||
// cancel/clear; no retry/queue-position controls (those are download-side concerns).
|
|
||||||
|
|
||||||
const POLL_MS = 1500;
|
|
||||||
|
|
||||||
type Row = SlskdTransfer & { phase: TransferPhase };
|
|
||||||
type UpDir = { directory: string; label: string; files: Row[]; size: number };
|
|
||||||
type UpUser = { username: string; dirs: UpDir[]; counts: Record<TransferPhase, number>; total: number };
|
|
||||||
|
|
||||||
const PHASE_ORDER: Record<TransferPhase, number> = { downloading: 0, queued: 1, failed: 2, done: 3 };
|
|
||||||
const PHASES: TransferPhase[] = ['downloading', 'queued', 'failed', 'done'];
|
|
||||||
|
|
||||||
const phaseStyle: Record<TransferPhase, { label: string; dot: string; bar: string }> = {
|
|
||||||
downloading: { label: 'Uploading', dot: 'bg-blue-500', bar: 'bg-blue-500' },
|
|
||||||
queued: { label: 'Queued', dot: 'bg-amber-500', bar: 'bg-amber-400' },
|
|
||||||
done: { label: 'Done', dot: 'bg-green-500', bar: 'bg-green-500' },
|
|
||||||
failed: { label: 'Failed', dot: 'bg-red-500', bar: 'bg-red-500' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const isRemovable = (phase: TransferPhase) => phase === 'done' || phase === 'failed';
|
|
||||||
|
|
||||||
const group = (users: SlskdTransferUser[]): UpUser[] => {
|
|
||||||
const out: UpUser[] = [];
|
|
||||||
for (const user of users) {
|
|
||||||
const dirs: UpDir[] = [];
|
|
||||||
for (const dir of user.directories ?? []) {
|
|
||||||
const files: Row[] = (dir.files ?? []).map((f) => ({ ...f, phase: transferPhase(f.state) }));
|
|
||||||
if (files.length === 0) continue;
|
|
||||||
files.sort(
|
|
||||||
(a, b) => PHASE_ORDER[a.phase] - PHASE_ORDER[b.phase] || basename(a.filename).localeCompare(basename(b.filename)),
|
|
||||||
);
|
|
||||||
dirs.push({
|
|
||||||
directory: dir.directory,
|
|
||||||
label: folderLabel(dir.directory),
|
|
||||||
files,
|
|
||||||
size: files.reduce((n, f) => n + f.size, 0),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (dirs.length === 0) continue;
|
|
||||||
dirs.sort((a, b) => a.directory.localeCompare(b.directory));
|
|
||||||
const counts: Record<TransferPhase, number> = { downloading: 0, queued: 0, failed: 0, done: 0 };
|
|
||||||
let total = 0;
|
|
||||||
for (const dir of dirs) for (const f of dir.files) (counts[f.phase]++, total++);
|
|
||||||
out.push({ username: user.username, dirs, counts, total });
|
|
||||||
}
|
|
||||||
out.sort(
|
|
||||||
(a, b) => Number(b.counts.downloading > 0) - Number(a.counts.downloading > 0) || a.username.localeCompare(b.username),
|
|
||||||
);
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SoulseekUploads = () => {
|
|
||||||
const client = useClient();
|
|
||||||
const [data, setData] = useState<SlskdTransferUser[]>([]);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const users = useMemo(() => group(data), [data]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const tick = () =>
|
|
||||||
client
|
|
||||||
.get<SlskdTransferUser[]>('/slskd/api/v0/transfers/uploads')
|
|
||||||
.then((u) => !cancelled && (setData(u), setLoaded(true)))
|
|
||||||
.catch(() => {});
|
|
||||||
tick();
|
|
||||||
const timer = setInterval(tick, POLL_MS);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearInterval(timer);
|
|
||||||
};
|
|
||||||
// useClient() is a fresh object each render — poll on a stable interval only.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggle = (name: string) =>
|
|
||||||
setCollapsed((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(name)) next.delete(name);
|
|
||||||
else next.add(name);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
const dropIds = (ids: Set<string>) =>
|
|
||||||
setData((prev) =>
|
|
||||||
prev.map((u) => ({
|
|
||||||
...u,
|
|
||||||
directories: (u.directories ?? []).map((d) => ({ ...d, files: (d.files ?? []).filter((f) => !ids.has(f.id)) })),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Abort an in-flight upload, or clear a finished/failed one from the queue.
|
|
||||||
const remove = async (row: Row) => {
|
|
||||||
const done = isRemovable(row.phase);
|
|
||||||
try {
|
|
||||||
await client.delete(
|
|
||||||
`/slskd/api/v0/transfers/uploads/${encodeURIComponent(row.username)}/${row.id}${done ? '?remove=true' : ''}`,
|
|
||||||
);
|
|
||||||
dropIds(new Set([row.id]));
|
|
||||||
} catch {
|
|
||||||
/* next poll reconciles */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b p-3 text-sm">
|
|
||||||
<ArrowUpFromLine className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="font-medium">Uploads</span>
|
|
||||||
<span className="text-muted-foreground">· {users.length} peer{users.length === 1 ? '' : 's'}</span>
|
|
||||||
{!loaded && <RefreshCw className="ml-auto h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
||||||
{users.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{loaded ? 'No uploads. When a peer downloads a shared file it shows up here.' : 'Loading uploads…'}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{users.map((user) => (
|
|
||||||
<UserCard
|
|
||||||
key={user.username}
|
|
||||||
user={user}
|
|
||||||
open={!collapsed.has(user.username)}
|
|
||||||
onToggle={() => toggle(user.username)}
|
|
||||||
onRemove={remove}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type UserCardProps = { user: UpUser; open: boolean; onToggle: () => void; onRemove: (row: Row) => void };
|
|
||||||
|
|
||||||
const UserCard = ({ user, open, onToggle, onRemove }: UserCardProps) => {
|
|
||||||
const meta = PHASES.filter((p) => user.counts[p] > 0).map((p) => (
|
|
||||||
<span key={p} className="inline-flex items-center gap-1" title={phaseStyle[p].label}>
|
|
||||||
<span className={`h-2 w-2 rounded-full ${phaseStyle[p].dot}`} />
|
|
||||||
{user.counts[p]}
|
|
||||||
</span>
|
|
||||||
));
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader open={open} onToggle={onToggle} title={user.username} meta={meta} />
|
|
||||||
{open && (
|
|
||||||
<CardBody>
|
|
||||||
{user.dirs.map((dir) => (
|
|
||||||
<FolderBlock key={dir.directory} dir={dir} onRemove={onRemove} />
|
|
||||||
))}
|
|
||||||
</CardBody>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type FolderBlockProps = { dir: UpDir; onRemove: (row: Row) => void };
|
|
||||||
|
|
||||||
const FolderBlock = ({ dir, onRemove }: FolderBlockProps) => {
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
return (
|
|
||||||
<SubCard>
|
|
||||||
<SubCardHeader
|
|
||||||
open={open}
|
|
||||||
onToggle={() => setOpen((v) => !v)}
|
|
||||||
icon={<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-500" />}
|
|
||||||
label={dir.label}
|
|
||||||
title={dir.directory}
|
|
||||||
meta={`${dir.files.length} · ${formatSize(dir.size)}`}
|
|
||||||
/>
|
|
||||||
{open && (
|
|
||||||
<RowList>
|
|
||||||
{dir.files.map((row) => {
|
|
||||||
const style = phaseStyle[row.phase];
|
|
||||||
const pct = row.phase === 'done' ? 100 : Math.max(0, Math.min(100, Math.round(row.percentComplete)));
|
|
||||||
const speed = row.phase === 'downloading' ? formatSpeed(row.averageSpeed) : '';
|
|
||||||
return (
|
|
||||||
<div key={row.id} className="px-3 py-2 pl-8 text-sm text-zinc-200">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`h-2 w-2 shrink-0 rounded-full ${style.dot}`} />
|
|
||||||
<span className="min-w-0 flex-1 truncate">{basename(row.filename)}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onRemove(row)}
|
|
||||||
title={isRemovable(row.phase) ? 'Clear' : 'Cancel'}
|
|
||||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-400 hover:bg-white/10 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-white/10">
|
|
||||||
<div className={`h-full ${style.bar}`} style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-zinc-500">
|
|
||||||
<span>{style.label}</span>
|
|
||||||
{row.size > 0 && <span>· {formatSize(row.size)}</span>}
|
|
||||||
{speed && <span>· {speed}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</RowList>
|
|
||||||
)}
|
|
||||||
</SubCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useSearchParams } from 'react-router';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Users, Search, CircleCheck, CircleSlash, Clock, Loader2, Star, X, Download } from 'lucide-react';
|
|
||||||
import { useSoulseekFavorites } from './useSoulseekFavorites';
|
|
||||||
import { useSoulseekUser } from './useSoulseekUser';
|
|
||||||
import { useSoulseekBrowseSnapshots } from './useSoulseekBrowse';
|
|
||||||
import { SharesBrowser, BrowseStateChip } from './SharesBrowser';
|
|
||||||
import { USER_PARAM } from './shared';
|
|
||||||
|
|
||||||
// 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 links here with `?user=<name>`, which is the whole mechanism — a peer profile is now
|
|
||||||
// linkable and survives a reload.
|
|
||||||
// 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.
|
|
||||||
|
|
||||||
const presenceStyle = (p?: string): { label: string; dot: string; icon: typeof CircleCheck } => {
|
|
||||||
switch ((p ?? '').toLowerCase()) {
|
|
||||||
case 'online':
|
|
||||||
return { label: 'Online', dot: 'text-green-500', icon: CircleCheck };
|
|
||||||
case 'away':
|
|
||||||
return { label: 'Away', dot: 'text-amber-500', icon: Clock };
|
|
||||||
default:
|
|
||||||
return { label: 'Offline', dot: 'text-zinc-500', icon: CircleSlash };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SoulseekUsers = () => {
|
|
||||||
const [params, setParams] = useSearchParams();
|
|
||||||
const selected = params.get(USER_PARAM)?.trim() ?? '';
|
|
||||||
const [query, setQuery] = useState(selected);
|
|
||||||
const { favorites, isFavorite, toggle } = useSoulseekFavorites();
|
|
||||||
const { snapshotOf, fetchShares } = useSoulseekBrowseSnapshots();
|
|
||||||
|
|
||||||
// Selecting a peer is now just naming one — the query owns the fetching, and its cache owns the answer.
|
|
||||||
const user = useSoulseekUser(selected);
|
|
||||||
const peer = user.data ?? null;
|
|
||||||
|
|
||||||
// Navigating, not setting state: the peer is `?user=`. Shares are still NOT fetched automatically —
|
|
||||||
// that's a multi-minute server-side job, so it stays an explicit click in the shares card.
|
|
||||||
const select = (name: string) => {
|
|
||||||
const username = name.trim();
|
|
||||||
if (!username) return;
|
|
||||||
setParams((prev) => {
|
|
||||||
const next = new URLSearchParams(prev);
|
|
||||||
next.set(USER_PARAM, username);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Arriving by link (or Back) should show the name in the box that produced it.
|
|
||||||
useEffect(() => {
|
|
||||||
if (selected) setQuery(selected);
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user.error) toast.error(user.error.message);
|
|
||||||
}, [user.error]);
|
|
||||||
|
|
||||||
const pres = presenceStyle(peer?.status?.presence);
|
|
||||||
const PresIcon = pres.icon;
|
|
||||||
// Only a peer we have nothing cached for is worth showing a pending state for.
|
|
||||||
const pending = !!selected && user.isPending;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b p-3">
|
|
||||||
<Users className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<form
|
|
||||||
onSubmit={(ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
select(query);
|
|
||||||
}}
|
|
||||||
className="flex flex-1 items-center gap-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
value={query}
|
|
||||||
onChange={(ev) => setQuery(ev.target.value)}
|
|
||||||
placeholder="Look up a username…"
|
|
||||||
className="h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!query.trim() || user.isFetching}
|
|
||||||
className="flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3 text-sm text-primary-foreground transition hover:bg-primary/90 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{user.isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
|
||||||
Look up
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
|
||||||
<div className="mx-auto flex max-w-2xl flex-col gap-4">
|
|
||||||
{/* Favorites — Officer's own data (slskd has no such concept). Always present, so the section
|
|
||||||
doubles as the panel's landing content before any lookup. */}
|
|
||||||
<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">
|
|
||||||
<Star className="h-4 w-4 fill-amber-400 text-amber-400" />
|
|
||||||
<span className="text-sm font-medium text-zinc-100">Favorites</span>
|
|
||||||
{favorites.length > 0 && <span className="text-xs text-zinc-500">· {favorites.length}</span>}
|
|
||||||
</div>
|
|
||||||
{favorites.length === 0 ? (
|
|
||||||
<p className="px-4 py-3 text-sm text-zinc-500">
|
|
||||||
No favorites yet — add one from a peer's profile below, or from the username menu in search results and
|
|
||||||
downloads.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-64 divide-y divide-white/5 overflow-y-auto">
|
|
||||||
{favorites.map((u) => {
|
|
||||||
const current = selected === u;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={u}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2 text-sm transition-colors ${
|
|
||||||
current ? 'bg-primary/5' : 'hover:bg-white/[0.02]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => select(u)}
|
|
||||||
title={`Look up ${u}`}
|
|
||||||
className={`min-w-0 flex-1 truncate text-left transition ${
|
|
||||||
current ? 'font-medium text-primary' : 'text-zinc-200 hover:text-zinc-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{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)}
|
|
||||||
title={`Remove ${u} from favorites`}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!selected ? (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-muted-foreground">
|
|
||||||
<Users className="h-8 w-8" />
|
|
||||||
<p className="text-sm">Enter a Soulseek username to see their profile and shares.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Profile. Keyed off the selected name, not the loaded peer, so a lookup already in cache
|
|
||||||
paints instantly and a fresh one fills in around a card that's already on screen. */}
|
|
||||||
<div className="rounded-xl border border-white/10 bg-zinc-950 p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-white/5 text-lg font-semibold text-zinc-300">
|
|
||||||
{selected.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-sm font-semibold text-zinc-100">{selected}</div>
|
|
||||||
{pending ? (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
Checking…
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={`flex items-center gap-1.5 text-xs ${pres.dot}`}>
|
|
||||||
<PresIcon className="h-3.5 w-3.5" />
|
|
||||||
{pres.label}
|
|
||||||
{peer?.status?.isPrivileged && <span className="text-amber-400">· privileged</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggle(selected)}
|
|
||||||
title={isFavorite(selected) ? 'Remove from favorites' : 'Add to favorites'}
|
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-zinc-500 transition hover:bg-white/5 hover:text-zinc-200"
|
|
||||||
>
|
|
||||||
<Star className={`h-4 w-4 ${isFavorite(selected) ? 'fill-amber-400 text-amber-400' : ''}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{peer?.info && (
|
|
||||||
<>
|
|
||||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
|
||||||
<Stat label="Upload slots" value={peer.info.uploadSlots ?? 0} />
|
|
||||||
<Stat label="Queue" value={peer.info.queueLength ?? 0} />
|
|
||||||
<Stat
|
|
||||||
label="Free slot"
|
|
||||||
value={peer.info.hasFreeUploadSlot ? 'Yes' : 'No'}
|
|
||||||
accent={peer.info.hasFreeUploadSlot ? 'text-green-400' : 'text-zinc-400'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{peer.info.description && (
|
|
||||||
<p className="mt-3 whitespace-pre-wrap border-t border-white/5 pt-3 text-sm text-zinc-300">
|
|
||||||
{peer.info.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SharesBrowser username={selected} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type StatProps = { label: string; value: string | number; accent?: string };
|
|
||||||
|
|
||||||
const Stat = ({ label, value, accent = 'text-zinc-100' }: StatProps) => (
|
|
||||||
<div className="flex flex-col gap-1 rounded-lg border border-white/5 bg-white/[0.02] px-3 py-2">
|
|
||||||
<span className="text-xs text-zinc-500">{label}</span>
|
|
||||||
<span className={`text-lg font-semibold tabular-nums ${accent}`}>{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { Construction } from 'lucide-react';
|
|
||||||
import { SOULSEEK_SECTIONS, useSoulseekSection, type SoulseekSectionId } from './shared';
|
|
||||||
import { SearchView } from './SearchView';
|
|
||||||
import { SoulseekTransfers } from './SoulseekTransfers';
|
|
||||||
import { SoulseekUploads } from './SoulseekUploads';
|
|
||||||
import { SoulseekRooms } from './SoulseekRooms';
|
|
||||||
import { SoulseekDashboard } from './SoulseekDashboard';
|
|
||||||
import { SoulseekChat } from './SoulseekChat';
|
|
||||||
import { SoulseekUsers } from './SoulseekUsers';
|
|
||||||
import { SoulseekSystem } from './SoulseekSystem';
|
|
||||||
import { SoulseekConnection } from './SoulseekConnection';
|
|
||||||
import { useServiceConnection } from '../../hooks/useServiceConnection';
|
|
||||||
|
|
||||||
// Right panel of the /soulseek workspace — renders the UI for the section the URL names. Content
|
|
||||||
// zoom used to live here; it is now a framework-level per-panel control in PanelSlot.
|
|
||||||
|
|
||||||
const Placeholder = ({ id }: { id: SoulseekSectionId }) => {
|
|
||||||
const label = SOULSEEK_SECTIONS.find((s) => s.id === id)?.label ?? id;
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
|
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
|
||||||
<Construction className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-base font-semibold">{label}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">Coming soon</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sectionView = (section: SoulseekSectionId) => {
|
|
||||||
switch (section) {
|
|
||||||
case 'dashboard':
|
|
||||||
return <SoulseekDashboard />;
|
|
||||||
case 'search':
|
|
||||||
return <SearchView />;
|
|
||||||
case 'downloads':
|
|
||||||
return <SoulseekTransfers />;
|
|
||||||
case 'uploads':
|
|
||||||
return <SoulseekUploads />;
|
|
||||||
case 'rooms':
|
|
||||||
return <SoulseekRooms />;
|
|
||||||
case 'chat':
|
|
||||||
return <SoulseekChat />;
|
|
||||||
case 'users':
|
|
||||||
return <SoulseekUsers />;
|
|
||||||
case 'system':
|
|
||||||
return <SoulseekSystem />;
|
|
||||||
case 'connection':
|
|
||||||
return <SoulseekConnection />;
|
|
||||||
default:
|
|
||||||
return <Placeholder id={section} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SoulseekView = () => {
|
|
||||||
const section = useSoulseekSection();
|
|
||||||
const { data: connection, isLoading } = useServiceConnection('slskd');
|
|
||||||
|
|
||||||
// Nothing connected yet: the setup form takes over every section, because none of them can do anything
|
|
||||||
// without a daemon.
|
|
||||||
if (!isLoading && !connection?.configured) return <SoulseekConnection />;
|
|
||||||
|
|
||||||
return <div className="h-full w-full">{sectionView(section)}</div>;
|
|
||||||
};
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { Link } from 'react-router';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { FolderOpen, Star, StarOff } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { useSoulseekFavorites } from './useSoulseekFavorites';
|
|
||||||
import { soulseekUserPath } from './shared';
|
|
||||||
|
|
||||||
// The peer dropdown, shared by search results and downloads: click a username anywhere in the workspace
|
|
||||||
// and act on that peer. Opening a peer is a real link to /soulseek/users?user=<name> — the Users section
|
|
||||||
// still owns the lookup and the cached share tree, it just reads who from the URL. Favouriting is a
|
|
||||||
// mutation, so it stays a menu item that acts rather than navigates.
|
|
||||||
|
|
||||||
type UserMenuProps = { username: string };
|
|
||||||
|
|
||||||
export const UserMenu = ({ username }: UserMenuProps) => {
|
|
||||||
const { isFavorite, toggle } = useSoulseekFavorites();
|
|
||||||
const favorited = isFavorite(username);
|
|
||||||
|
|
||||||
const toggleFavorite = () => {
|
|
||||||
toggle(username);
|
|
||||||
toast.success(favorited ? `Removed ${username} from favorites` : `Added ${username} to favorites`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={`Actions for ${username}`}
|
|
||||||
className="min-w-0 max-w-full truncate rounded text-sm font-medium text-zinc-100 underline decoration-dotted decoration-zinc-600 underline-offset-4 outline-none transition hover:decoration-zinc-300 focus-visible:ring-2 focus-visible:ring-primary/40"
|
|
||||||
>
|
|
||||||
{username}
|
|
||||||
{favorited && <Star className="ml-1.5 inline h-3 w-3 fill-amber-400 align-baseline text-amber-400" />}
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="w-56">
|
|
||||||
<DropdownMenuLabel className="truncate">{username}</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to={soulseekUserPath(username)}>
|
|
||||||
<FolderOpen className="mr-2 h-4 w-4" />
|
|
||||||
View profile & shares
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onSelect={toggleFavorite}>
|
|
||||||
{favorited ? <StarOff className="mr-2 h-4 w-4" /> : <Star className="mr-2 h-4 w-4" />}
|
|
||||||
{favorited ? 'Remove from favorites' : 'Add user to favorites'}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
|
||||||
import { PanelLeft, LayoutGrid } from 'lucide-react';
|
|
||||||
import { SoulseekNav } from './SoulseekNav';
|
|
||||||
import { SoulseekView } from './SoulseekView';
|
|
||||||
|
|
||||||
export { SoulseekNav, SoulseekView };
|
|
||||||
|
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
|
||||||
{ key: 'soulseek-nav', name: 'Soulseek', icon: PanelLeft, component: SoulseekNav, availableOnPanel: false },
|
|
||||||
{ key: 'soulseek-view', name: 'Soulseek', icon: LayoutGrid, component: SoulseekView, availableOnPanel: false },
|
|
||||||
];
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
// Shared types/helpers for the /soulseek workspace panels, which talk to slskd through the /api/slskd
|
|
||||||
// auth proxy. What is open — the section, the peer, the search — is the URL; the one channel left here
|
|
||||||
// is a refresh signal, which is what channels are for.
|
|
||||||
|
|
||||||
import { useParams } from 'react-router';
|
|
||||||
|
|
||||||
// Bumped (to a fresh nonce) whenever a download is enqueued from the search panel, so the transfers
|
|
||||||
// panel refetches immediately instead of waiting for its next poll tick.
|
|
||||||
export const SLSKD_REFRESH_CHANNEL = 'soulseek:refresh';
|
|
||||||
|
|
||||||
export type SlskdApplication = {
|
|
||||||
version?: { current?: string; full?: string; latest?: string; isUpdateAvailable?: boolean };
|
|
||||||
server?: { state?: string; address?: string; isConnected?: boolean };
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SlskdSearch = {
|
|
||||||
id: string;
|
|
||||||
isComplete: boolean;
|
|
||||||
state: string;
|
|
||||||
fileCount: number;
|
|
||||||
responseCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SlskdFile = {
|
|
||||||
filename: string;
|
|
||||||
size: number;
|
|
||||||
extension?: string;
|
|
||||||
isLocked?: boolean;
|
|
||||||
bitRate?: number;
|
|
||||||
length?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SlskdResponse = {
|
|
||||||
username: string;
|
|
||||||
fileCount: number;
|
|
||||||
files: SlskdFile[];
|
|
||||||
hasFreeUploadSlot: boolean;
|
|
||||||
queueLength: number;
|
|
||||||
uploadSpeed: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// One flat, rankable search result (a single file from a single peer).
|
|
||||||
export type ResultRow = {
|
|
||||||
username: string;
|
|
||||||
filename: string;
|
|
||||||
size: number;
|
|
||||||
extension: string;
|
|
||||||
isLocked: boolean;
|
|
||||||
bitRate?: number;
|
|
||||||
hasFreeUploadSlot: boolean;
|
|
||||||
queueLength: number;
|
|
||||||
uploadSpeed: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// slskd presents results grouped by responder (user) → folder → files. We mirror that shape.
|
|
||||||
export type ResultFile = {
|
|
||||||
filename: string; // full remote path (slskd needs this verbatim to enqueue)
|
|
||||||
name: string; // basename for display
|
|
||||||
size: number;
|
|
||||||
extension: string;
|
|
||||||
bitRate?: number;
|
|
||||||
length?: number;
|
|
||||||
isLocked: boolean;
|
|
||||||
};
|
|
||||||
export type ResultFolder = { path: string; label: string; files: ResultFile[]; size: number };
|
|
||||||
export type ResultUser = {
|
|
||||||
username: string;
|
|
||||||
hasFreeUploadSlot: boolean;
|
|
||||||
queueLength: number;
|
|
||||||
uploadSpeed: number;
|
|
||||||
fileCount: number;
|
|
||||||
folders: ResultFolder[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Group a search's responses into slskd's user → folder → file layout, best peers first.
|
|
||||||
export const groupResponses = (responses: SlskdResponse[]): ResultUser[] => {
|
|
||||||
const users: ResultUser[] = [];
|
|
||||||
for (const resp of responses) {
|
|
||||||
const byFolder = new Map<string, ResultFile[]>();
|
|
||||||
for (const f of resp.files ?? []) {
|
|
||||||
const parts = f.filename.split(/[\\/]/);
|
|
||||||
const name = parts.pop() ?? f.filename;
|
|
||||||
const path = parts.join('\\') || '\\';
|
|
||||||
const file: ResultFile = {
|
|
||||||
filename: f.filename,
|
|
||||||
name,
|
|
||||||
size: f.size,
|
|
||||||
extension: extOf(f),
|
|
||||||
bitRate: f.bitRate,
|
|
||||||
length: f.length,
|
|
||||||
isLocked: !!f.isLocked,
|
|
||||||
};
|
|
||||||
const arr = byFolder.get(path);
|
|
||||||
if (arr) arr.push(file);
|
|
||||||
else byFolder.set(path, [file]);
|
|
||||||
}
|
|
||||||
const folders: ResultFolder[] = [...byFolder.entries()]
|
|
||||||
.map(([path, files]) => {
|
|
||||||
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
return { path, label: folderLabel(path), files, size: files.reduce((n, f) => n + f.size, 0) };
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.path.localeCompare(b.path));
|
|
||||||
users.push({
|
|
||||||
username: resp.username,
|
|
||||||
hasFreeUploadSlot: resp.hasFreeUploadSlot,
|
|
||||||
queueLength: resp.queueLength,
|
|
||||||
uploadSpeed: resp.uploadSpeed,
|
|
||||||
fileCount: resp.fileCount ?? resp.files?.length ?? 0,
|
|
||||||
folders,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Best peer first: free slot, then faster upload, then more files.
|
|
||||||
users.sort(
|
|
||||||
(a, b) =>
|
|
||||||
Number(b.hasFreeUploadSlot) - Number(a.hasFreeUploadSlot) ||
|
|
||||||
b.uploadSpeed - a.uploadSpeed ||
|
|
||||||
b.fileCount - a.fileCount,
|
|
||||||
);
|
|
||||||
return users;
|
|
||||||
};
|
|
||||||
|
|
||||||
// A download transfer. GET /transfers/downloads nests these as user → directories → files.
|
|
||||||
export type SlskdTransfer = {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
direction: string;
|
|
||||||
filename: string;
|
|
||||||
size: number;
|
|
||||||
state: string;
|
|
||||||
bytesTransferred: number;
|
|
||||||
averageSpeed: number;
|
|
||||||
percentComplete: number;
|
|
||||||
placeInQueue?: number;
|
|
||||||
};
|
|
||||||
export type SlskdDownloadDirectory = { directory: string; fileCount: number; files: SlskdTransfer[] };
|
|
||||||
export type SlskdDownloadUser = { username: string; directories: SlskdDownloadDirectory[] };
|
|
||||||
// Uploads (GET /transfers/uploads) use the same user → directories → files shape as downloads.
|
|
||||||
export type SlskdTransferUser = SlskdDownloadUser;
|
|
||||||
|
|
||||||
// Private conversations. GET /conversations lists them (one per peer); GET /conversations/{user}/messages
|
|
||||||
// returns that thread. A message's direction ('Out' = sent by us) drives self-alignment in the UI.
|
|
||||||
export type SlskdPrivateMessage = {
|
|
||||||
id: number;
|
|
||||||
timestamp: string;
|
|
||||||
username: string;
|
|
||||||
direction: string; // 'In' | 'Out'
|
|
||||||
message: string;
|
|
||||||
isAcknowledged?: boolean;
|
|
||||||
};
|
|
||||||
export type SlskdConversation = {
|
|
||||||
username: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
unAcknowledgedMessageCount?: number;
|
|
||||||
hasUnAcknowledgedMessages?: boolean;
|
|
||||||
messages?: SlskdPrivateMessage[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// A peer, as seen through the Users section. GET /users/{u}/info + /status; /browse returns their shares.
|
|
||||||
export type SlskdUserInfo = {
|
|
||||||
description?: string;
|
|
||||||
hasFreeUploadSlot?: boolean;
|
|
||||||
hasPicture?: boolean;
|
|
||||||
queueLength?: number;
|
|
||||||
uploadSlots?: number;
|
|
||||||
};
|
|
||||||
export type SlskdUserStatus = {
|
|
||||||
isPrivileged?: boolean;
|
|
||||||
presence?: string; // 'Offline' | 'Away' | 'Online'
|
|
||||||
};
|
|
||||||
export type SlskdBrowseDirectory = { name: string; fileCount: number; files?: SlskdFile[] };
|
|
||||||
// GET /users/{u}/browse wraps the share tree rather than returning a bare array, and inlines every
|
|
||||||
// file of every folder in one shot (real peers hit 18k folders / tens of MB). Note that a browsed
|
|
||||||
// file's `filename` is only the basename — unlike search results, where it's the full remote path —
|
|
||||||
// so enqueuing one means rejoining it to its directory name with a backslash.
|
|
||||||
export type SlskdBrowseResponse = {
|
|
||||||
directories: SlskdBrowseDirectory[];
|
|
||||||
directoryCount: number;
|
|
||||||
lockedDirectories: SlskdBrowseDirectory[];
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
// A node of the stored tree. The sidecar links slskd's flat backslash paths up at ingest, so `label` is
|
|
||||||
// already just the last segment and the subtree rollups are already summed — this side never derives
|
|
||||||
// either, which is the point: a collapsed row can show what's underneath it without fetching it.
|
|
||||||
export type SoulseekBrowseNode = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
parentPath: string | null;
|
|
||||||
label: string;
|
|
||||||
depth: number;
|
|
||||||
childCount: number;
|
|
||||||
fileCount: number;
|
|
||||||
totalSize: number;
|
|
||||||
subtreeFileCount: number;
|
|
||||||
subtreeSize: number;
|
|
||||||
};
|
|
||||||
export type SoulseekBrowseLevel = { nodes: SoulseekBrowseNode[]; total: number };
|
|
||||||
/** A filtered tree: the matches plus every ancestor holding them up, so the shape survives filtering. */
|
|
||||||
export type SoulseekBrowseSearch = {
|
|
||||||
nodes: SoulseekBrowseNode[];
|
|
||||||
matched: string[];
|
|
||||||
total: number;
|
|
||||||
truncated: boolean;
|
|
||||||
};
|
|
||||||
// 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 };
|
|
||||||
|
|
||||||
// Chat rooms. GET /rooms/joined/{name} inlines users + messages; GET /rooms/available lists the rest.
|
|
||||||
export type SlskdRoomUser = { username: string };
|
|
||||||
export type SlskdRoomMessage = {
|
|
||||||
timestamp: string;
|
|
||||||
username: string;
|
|
||||||
message: string;
|
|
||||||
roomName?: string;
|
|
||||||
self?: boolean;
|
|
||||||
};
|
|
||||||
export type SlskdRoom = { name: string; isPrivate?: boolean; users?: SlskdRoomUser[]; messages?: SlskdRoomMessage[] };
|
|
||||||
export type SlskdRoomInfo = { name: string; userCount: number; isPrivate?: boolean };
|
|
||||||
|
|
||||||
// Which section is open is `/soulseek/:section` — the nav panel and the view panel both read the URL and
|
|
||||||
// neither publishes to the other. Mirrors slskd's own top-menu sections.
|
|
||||||
export type SoulseekSectionId =
|
|
||||||
| 'dashboard'
|
|
||||||
| 'search'
|
|
||||||
| 'downloads'
|
|
||||||
| 'uploads'
|
|
||||||
| 'rooms'
|
|
||||||
| 'chat'
|
|
||||||
| 'users'
|
|
||||||
| 'system'
|
|
||||||
| 'connection';
|
|
||||||
export const SOULSEEK_SECTIONS: { id: SoulseekSectionId; label: string }[] = [
|
|
||||||
{ id: 'dashboard', label: 'Dashboard' },
|
|
||||||
{ id: 'search', label: 'Search' },
|
|
||||||
{ id: 'downloads', label: 'Downloads' },
|
|
||||||
{ id: 'uploads', label: 'Uploads' },
|
|
||||||
{ id: 'rooms', label: 'Rooms' },
|
|
||||||
{ id: 'chat', label: 'Chat' },
|
|
||||||
{ id: 'users', label: 'Users' },
|
|
||||||
{ id: 'system', label: 'System' },
|
|
||||||
{ id: 'connection', label: 'Connection' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Where /soulseek lands, and where an unrecognised section redirects to. */
|
|
||||||
export const DEFAULT_SOULSEEK_SECTION: SoulseekSectionId = 'dashboard';
|
|
||||||
|
|
||||||
export const isSoulseekSection = (value: string | undefined): value is SoulseekSectionId =>
|
|
||||||
SOULSEEK_SECTIONS.some((s) => s.id === value);
|
|
||||||
|
|
||||||
export const soulseekSectionPath = (section: SoulseekSectionId) => `/soulseek/${section}`;
|
|
||||||
|
|
||||||
// SoulseekScreen redirects anything unrecognised, so the fallback here only covers the instant before
|
|
||||||
// that lands.
|
|
||||||
export const useSoulseekSection = (): SoulseekSectionId => {
|
|
||||||
const { section } = useParams();
|
|
||||||
return isSoulseekSection(section) ? section : DEFAULT_SOULSEEK_SECTION;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Which peer the Users section is showing — `/soulseek/users?user=<name>`.
|
|
||||||
//
|
|
||||||
// This was a `soulseek:user` channel carrying `{username, nonce}`: the username dropdown published a
|
|
||||||
// request, the Users panel consumed it once and cleared it, and the nonce existed so that asking for the
|
|
||||||
// SAME peer twice still counted as two requests. All of that machinery was a workaround for the peer not
|
|
||||||
// being in the URL — a link is idempotent, so there is nothing to consume and no nonce to disambiguate.
|
|
||||||
export const USER_PARAM = 'user';
|
|
||||||
|
|
||||||
export const soulseekUserPath = (username: string) =>
|
|
||||||
`${soulseekSectionPath('users')}?${USER_PARAM}=${encodeURIComponent(username)}`;
|
|
||||||
|
|
||||||
// Which room the Rooms section is showing — `/soulseek/rooms?room=<name>` — and which thread the Chat
|
|
||||||
// section is showing — `/soulseek/chat?peer=<name>`. Two params rather than one because they are two
|
|
||||||
// sections: they never appear together, and a shared name would make `?thing=` mean different kinds of
|
|
||||||
// thing depending on the path.
|
|
||||||
//
|
|
||||||
// Both rails used to auto-select the first entry on load, which is why they held the selection locally:
|
|
||||||
// there was nowhere else to put an answer the user had not given. The bare section is now a real state —
|
|
||||||
// "nothing open" — and both panels already had the empty pane to say so.
|
|
||||||
export const ROOM_PARAM = 'room';
|
|
||||||
export const PEER_PARAM = 'peer';
|
|
||||||
|
|
||||||
export const soulseekRoomPath = (name: string) =>
|
|
||||||
`${soulseekSectionPath('rooms')}?${ROOM_PARAM}=${encodeURIComponent(name)}`;
|
|
||||||
|
|
||||||
export const soulseekPeerPath = (username: string) =>
|
|
||||||
`${soulseekSectionPath('chat')}?${PEER_PARAM}=${encodeURIComponent(username)}`;
|
|
||||||
|
|
||||||
// A past search, as listed by GET /searches (no responses inlined).
|
|
||||||
// Which search's results are open — `/soulseek/search?search=<id>`, read from the URL rather than held in a
|
|
||||||
// panel channel or local state. See docs/navigation-audit.md.
|
|
||||||
export const SEARCH_PARAM = 'search';
|
|
||||||
|
|
||||||
export type SlskdSearchSummary = {
|
|
||||||
id: string;
|
|
||||||
searchText: string;
|
|
||||||
startedAt: string;
|
|
||||||
endedAt?: string;
|
|
||||||
fileCount: number;
|
|
||||||
lockedFileCount: number;
|
|
||||||
responseCount: number;
|
|
||||||
isComplete: boolean;
|
|
||||||
state: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const basename = (path: string) => path.split(/[\\/]/).pop() ?? path;
|
|
||||||
|
|
||||||
// Compact folder label for grouped views — the last two path segments (slskd paths use backslashes).
|
|
||||||
export const folderLabel = (path: string) => {
|
|
||||||
const segs = path.split(/[\\/]/).filter(Boolean);
|
|
||||||
return segs.slice(-2).join(' / ') || path;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compact "time ago" for search-history rows.
|
|
||||||
export const formatWhen = (iso?: string): string => {
|
|
||||||
if (!iso) return '';
|
|
||||||
const t = new Date(iso).getTime();
|
|
||||||
if (!Number.isFinite(t)) return '';
|
|
||||||
const s = Math.round((Date.now() - t) / 1000);
|
|
||||||
if (s < 60) return 'just now';
|
|
||||||
const m = Math.floor(s / 60);
|
|
||||||
if (m < 60) return `${m}m ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
return d < 7 ? `${d}d ago` : new Date(iso).toLocaleDateString();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wall-clock HH:MM for chat message timestamps.
|
|
||||||
export const formatClock = (iso?: string): string => {
|
|
||||||
if (!iso) return '';
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isFinite(d.getTime()) ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatSize = (bytes: number) => {
|
|
||||||
if (!bytes) return '0 B';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
|
||||||
return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatSpeed = (bytesPerSec: number) => (bytesPerSec > 0 ? `${formatSize(bytesPerSec)}/s` : '');
|
|
||||||
|
|
||||||
// Track length (seconds) → m:ss, as slskd shows it.
|
|
||||||
export const formatDuration = (sec?: number): string => {
|
|
||||||
if (!sec || sec < 0) return '';
|
|
||||||
const m = Math.floor(sec / 60);
|
|
||||||
const s = Math.floor(sec % 60);
|
|
||||||
return `${m}:${String(s).padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const extOf = (file: SlskdFile) =>
|
|
||||||
(file.extension?.trim() || basename(file.filename).split('.').pop() || '').toLowerCase();
|
|
||||||
|
|
||||||
// slskd serialises TransferStates as a compound flag string ("Completed, Succeeded", "Queued, Remotely",
|
|
||||||
// "InProgress", "Requested", …). Collapse it to the one bucket we render.
|
|
||||||
export type TransferPhase = 'downloading' | 'queued' | 'done' | 'failed';
|
|
||||||
export const transferPhase = (state: string): TransferPhase => {
|
|
||||||
const s = state.toLowerCase();
|
|
||||||
if (s.includes('inprogress') || s.includes('initializing')) return 'downloading';
|
|
||||||
if (s.includes('succeeded')) return 'done';
|
|
||||||
if (
|
|
||||||
s.includes('errored') ||
|
|
||||||
s.includes('cancelled') ||
|
|
||||||
s.includes('rejected') ||
|
|
||||||
s.includes('timedout') ||
|
|
||||||
s.includes('aborted')
|
|
||||||
)
|
|
||||||
return 'failed';
|
|
||||||
return 'queued'; // Requested / Queued / anything not yet resolved
|
|
||||||
};
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
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[]>('/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 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>(`/slskd/_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>(
|
|
||||||
`/slskd/_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 }>(`/slskd/_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[]>(`/slskd/_officer/browse/${encodeURIComponent(username ?? '')}/dirs/${dirId}/files`),
|
|
||||||
enabled: !!username && dirId !== null,
|
|
||||||
staleTime: Infinity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
|
|
||||||
const KEY = ['soulseek', 'favorites'] as const;
|
|
||||||
const EMPTY: string[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The owner's favourited Soulseek peers, backed by the officer-slskd sidecar's own `/_officer/favorites`
|
|
||||||
* route (slskd has no favourites API — Officer owns that data). One shared react-query cache, so every
|
|
||||||
* star in the workspace reflects the same state; toggling is optimistic and rolls back on failure.
|
|
||||||
*/
|
|
||||||
export function useSoulseekFavorites() {
|
|
||||||
const { get, post, delete: del } = useClient();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
const { data } = useQuery({
|
|
||||||
queryKey: KEY,
|
|
||||||
queryFn: () => get<string[]>('/slskd/_officer/favorites'),
|
|
||||||
staleTime: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: ({ on, username }: { on: boolean; username: string }) =>
|
|
||||||
on
|
|
||||||
? post('/slskd/_officer/favorites', { username })
|
|
||||||
: del(`/slskd/_officer/favorites?username=${encodeURIComponent(username)}`),
|
|
||||||
onMutate: async ({ on, username }) => {
|
|
||||||
await qc.cancelQueries({ queryKey: KEY });
|
|
||||||
const prev = qc.getQueryData<string[]>(KEY) ?? EMPTY;
|
|
||||||
const next = on ? [...prev, username].sort((a, b) => a.localeCompare(b)) : prev.filter((u) => u !== username);
|
|
||||||
qc.setQueryData<string[]>(KEY, next);
|
|
||||||
return { prev };
|
|
||||||
},
|
|
||||||
onError: (_err, _vars, ctx) => {
|
|
||||||
if (ctx?.prev) qc.setQueryData(KEY, ctx.prev);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const favorites = data ?? EMPTY;
|
|
||||||
const isFavorite = (username: string) => favorites.includes(username);
|
|
||||||
const toggle = (username: string) => {
|
|
||||||
if (!username) return;
|
|
||||||
mutation.mutate({ on: !isFavorite(username), username });
|
|
||||||
};
|
|
||||||
|
|
||||||
return { favorites, isFavorite, toggle };
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import type { SlskdUserInfo, SlskdUserStatus } from './shared';
|
|
||||||
|
|
||||||
/** A peer's presence and profile, as far as either could be reached. */
|
|
||||||
export type SoulseekUser = {
|
|
||||||
username: string;
|
|
||||||
status: SlskdUserStatus | null;
|
|
||||||
info: SlskdUserInfo | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One peer's presence and profile, cached per username. Selecting a peer you've already looked at paints
|
|
||||||
* from cache instead of blanking the panel back to its empty state and re-asking the network for an
|
|
||||||
* answer it just had — which is what local state did, once per click.
|
|
||||||
*/
|
|
||||||
export function useSoulseekUser(username: string) {
|
|
||||||
const { get } = useClient();
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['soulseek', 'user', username],
|
|
||||||
queryFn: async (): Promise<SoulseekUser> => {
|
|
||||||
const u = encodeURIComponent(username);
|
|
||||||
const [status, info] = await Promise.allSettled([
|
|
||||||
get<SlskdUserStatus>(`/slskd/api/v0/users/${u}/status`),
|
|
||||||
get<SlskdUserInfo>(`/slskd/api/v0/users/${u}/info`),
|
|
||||||
]);
|
|
||||||
// Half an answer is still worth showing — a reachable peer with a private profile answers only one
|
|
||||||
// of the two. Both failing is what actually means "couldn't reach them".
|
|
||||||
if (status.status === 'rejected' && info.status === 'rejected') {
|
|
||||||
throw new Error(`Couldn't reach ${username} — they may be offline.`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
username,
|
|
||||||
status: status.status === 'fulfilled' ? status.value : null,
|
|
||||||
info: info.status === 'fulfilled' ? info.value : null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!username,
|
|
||||||
// Presence drifts, but not fast enough to be worth a round trip every time the panel remounts.
|
|
||||||
staleTime: 60_000,
|
|
||||||
// An unreachable peer IS the answer, not a fault to retry: retrying only delays saying so.
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -37,15 +37,6 @@ export { CodeEditorView } from './apps/CodeEditor';
|
|||||||
export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared';
|
export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared';
|
||||||
export type { JellyfinSectionId } from './apps/Jellyfin/shared';
|
export type { JellyfinSectionId } from './apps/Jellyfin/shared';
|
||||||
|
|
||||||
// Same for /soulseek, which also puts the open peer in `?user=`.
|
|
||||||
export {
|
|
||||||
DEFAULT_SOULSEEK_SECTION,
|
|
||||||
soulseekSectionPath,
|
|
||||||
soulseekUserPath,
|
|
||||||
isSoulseekSection,
|
|
||||||
} from './apps/Soulseek/shared';
|
|
||||||
export type { SoulseekSectionId } from './apps/Soulseek/shared';
|
|
||||||
|
|
||||||
// Same for /system-monitor, where the param is the scope rather than a section.
|
// Same for /system-monitor, where the param is the scope rather than a section.
|
||||||
export { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared';
|
export { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared';
|
||||||
export type { MonitorScope } from './apps/SystemMonitor/shared';
|
export type { MonitorScope } from './apps/SystemMonitor/shared';
|
||||||
|
|||||||
Reference in New Issue
Block a user