SoulSeek Search Provider¶
The SoulSeek provider (src/search/providers/soulseek.ts) listens to incremental soulseek-search-batch Tauri events and converts raw peer rows into pipeline entities. Its design is driven by two constraints: peer responses arrive as an event stream (not a single result), and popular queries can produce dense bursts of events that would overwhelm the UI if processed one-by-one.
Source: src/search/providers/soulseek.ts
Incremental event architecture¶
Unlike the RuTracker provider (which drives its own concurrency), the SoulSeek provider is passive on the event side. The Tauri backend fires soulseek-search-batch events as peer responses arrive (see Session search). The provider collects these into a mutable array and re-runs the grouping pass when dirty.
const raw: SlskAudioRow[] = [];
let dirty = false;
const unlisten = await listen("soulseek-search-batch", (e) => {
if (e.payload.requestId !== ctx.requestId) return;
raw.push(...e.payload.rows);
dirty = true; nudge();
armIdleTimer();
});
// Separately, invoke soulseek_search (12s server-side timeout)
// When it resolves, it carries the full final list (deduped by the backend).
// Replace raw[] with the final list for consistency.
soulseekSearch(query, ctx.requestId)
.then((finalRows) => {
raw.length = 0;
raw.push(...finalRows);
dirty = true; nudge();
})
.finally(() => finish(finalError));
ctx.requestId is an incrementing integer from SearchSession. Batches from old searches that arrive after the session was superseded are silently dropped by the requestId guard.
Timing: baseline + idle timers¶
The provider uses two timers to decide when to stop accepting results:
- Baseline timer (5000 ms): fires if no batches arrive within 5 seconds of search start. This handles the case where the network is slow or the query returns zero results.
- Idle timer (1000 ms): reset on each batch. Fires 1 second after the last batch. This stops the generator when the peer flow has settled.
The soulseek_search invoke call also has a 12-second hard timeout on the backend. Whichever of these three signals fires first calls finish(), which is idempotent.
rAF coalescing¶
Popular queries (e.g. a well-known artist) can trigger dozens of peer responses per second. Regrouping all rows on every batch event would thrash the main thread. The generator awaits one animation frame after each yield:
while (true) {
if (dirty) {
dirty = false;
yield groupSlskRowsToEntities(raw);
if (!finished && !ctx.signal.aborted) await _nextFrame();
continue; // re-check dirty immediately — may have batches from the frame
}
if (finished) break;
await new Promise<void>((r) => { pendingResolve = r; });
}
Multiple batch events that arrive while the generator is awaiting _nextFrame() all get folded into the next groupSlskRowsToEntities(raw) call. One regroup per frame instead of one per peer.
groupSlskRowsToEntities¶
This is the core transformation. It converts a flat SlskAudioRow[] to PipelineEntity[].
Step 1: Filter and separate¶
const images = rawRows.filter((r) => r.slsk_is_image);
const audios = rawRows.filter((r) => !r.slsk_is_image && peerIsLive(r));
peerIsLive: drops rows whose peer has queueLength > 25. Peers with deep queues are unlikely to respond before the user gives up; showing them wastes slots in the result list.
Image rows are indexed by (username, folder) for cover matching.
Step 2: Dedup by resolved title¶
SoulSeek has no album concept — peers share individual files. The same song can appear from dozens of peers with different filenames:
Пошлая Молли - Нон стоп.flac02 - Нон стоп.flacPoshlaya Molly - Non Stop.mp3
trackDedupKey resolves the filename to (artist, title) using resolveTrackNames, then normalizes (lowercase, strip non-alphanumeric) and combines with the extension:
function trackDedupKey(row: SlskAudioRow): string {
const resolved = resolveTrackNames({ fileName, artist: null, albumTitle: null, … });
const artistN = normalizeKey(resolved.artist);
const titleN = normalizeKey(resolved.title);
if (!titleN) return ""; // unparseable → dropped
return `${artistN}|${titleN}.${ext}`;
}
Rows with the same dedup key are grouped together.
Step 3: Peer ranking¶
Within each group, the best peer is chosen as primary. Remaining distinct peers become alternatives:
function peerRank(row: SlskAudioRow): number {
let r = 0;
if (row.slotsFree) r += 2;
if ((row.avgSpeed ?? 0) > 0) r += 1;
const q = row.queueLength ?? 0;
if (q < 10) r += 1;
if (q > 50) r -= 1;
if (!row.slotsFree && q > 20) r -= 2;
return r;
}
| Condition | Score change |
|---|---|
slotsFree = true |
+2 |
avgSpeed > 0 |
+1 |
queueLength < 10 |
+1 |
queueLength > 50 |
-1 |
!slotsFree && queueLength > 20 |
-2 (effectively offline) |
Bitrate is used as a tiebreaker between peers with equal rank. Alternatives are capped at 5 per group (ALT_PEER_LIMIT), one per unique username.
Step 4: Cover art¶
The best image from the same peer's folder is attached to each track:
function coverPriority(filename: string): number {
const order = [
"folder.jpg", "folder.jpeg", "cover.jpg", "cover.jpeg",
"front.jpg", "front.jpeg", "album.jpg", "artwork.jpg",
"cover.png", "folder.png", "front.png", "album.png",
];
for (let i = 0; i < order.length; i++) if (name.endsWith(order[i])) return i;
return 40; // unknown image, lowest priority
}
Among images with the same priority score, the largest by file size wins.
Step 5: Final sort¶
Entries are sorted by peerRank(primary) descending — tracks from the most-available peers appear first.
Entity shape produced¶
All SoulSeek entities are type: "track". There are no album entities — SoulSeek has no album concept.
{
type: "track",
id: "slsk:track:<username>|<filepath>",
title: fileName, // just the filename
artist: null, // resolved at display time
albumTitle: folderName, // last path component of the containing folder
fileName,
format: "FLAC" | "MP3" | null,
bitrate: row.bitrate,
duration: row.duration,
size: row.size,
albumId: null, // no album grouping
sources: [{
kind: "soulseek",
refs: { slskUsername, slskFilepath },
raw: {
row, // full SlskAudioRow (has slotsFree, avgSpeed, queueLength)
cover, // best ImageRow from same folder, or null
peers, // 1 + alternatives.length
alternativePeers: [ // up to 5 fallback peers
{ slskUsername, slskFilepath, size }
]
}
}],
score: 0,
mergedFrom: 1,
}
alternativePeers in raw is used by the player for automatic failover: if the primary peer times out or denies the upload, the player retries with the next alternative peer without requiring user interaction.