Skip to content

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:

  • Пошлая Молли - Нон стоп.flac
  • 02 - Нон стоп.flac
  • Poshlaya 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.