// Shared types/helpers for the /soulseek workspace panels, which talk to slskd through the /api/soulseek // 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 type { Tone } from '@/components/Data/tone'; 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(); 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=`. // // 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=` — and which thread the Chat // section is showing — `/soulseek/chat?peer=`. 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=`, 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 }; // The one place a transfer phase becomes a colour. Transfers, uploads and the dashboard tiles all read it, // so a phase cannot mean success in one panel and warning in the next. export const PHASE_TONE: Record = { downloading: 'info', queued: 'warning', done: 'success', failed: 'danger', }; // A solid dot or progress bar, which `toneFill` (a /15 tint) deliberately does not cover. export const toneSolid: Record = { neutral: 'bg-muted-foreground', success: 'bg-success', warning: 'bg-warning', danger: 'bg-destructive', info: 'bg-info', busy: 'bg-busy', };