Pipeline Stages¶
The pipeline transforms a flat array of PipelineEntity[] from all providers into the final sorted, deduplicated, filtered result set. It runs inside SearchSession._flushImmediate() on every animation frame when new data is available.
Source: src/search/pipeline/
Pipeline interface¶
export type PipelineEntity = { id: string; score?: number; mergedFrom?: number };
export type PipelineStage<T extends PipelineEntity = PipelineEntity> =
(entities: T[], opts?: Record<string, unknown>) => T[];
Each stage receives the full array and returns a (possibly new) array. Stages are pure functions — they must not mutate entities from other sessions.
export function defaultPipeline(): PipelineStage[] {
return [normalizeStage, dedupStage, scoreStage, filterStage];
}
export function runPipeline<T extends PipelineEntity>(
entities: T[],
stages: PipelineStage<T>[],
): T[] {
let out = entities;
for (const stage of stages) out = stage(out);
return out;
}
Stage 1: normalize¶
Currently a no-op. The stage exists as a designated home for future work: parsing artist/album from filenames when the provider didn't supply them, unifying format names ("mp3" → "MP3"), stripping release-group tags like "[FLAC 24/96]" from album titles.
Providers already emit entities in a normalized-enough shape for the current feature set.
Stage 2: dedup¶
export function dedupStage<T extends { id: string; mergedFrom?: number }>(
entities: T[]
): T[] {
const byId = new Map<string, T>();
for (const e of entities) {
const existing = byId.get(e.id);
if (!existing) { byId.set(e.id, e); continue; }
if ((e.mergedFrom ?? 0) > (existing.mergedFrom ?? 0)) byId.set(e.id, e);
}
return Array.from(byId.values());
}
Deduplication is by entity.id. IDs are provider-prefixed (rt:album:…, rt:track:…, slsk:track:…) so cross-provider collision cannot happen here — the same track from RuTracker and SoulSeek gets two distinct entities.
Within one provider, mergedFrom (set by the provider to the batch count at the time of entity creation) is used as a tiebreaker: a newer snapshot entry for the same id wins.
Note: Cross-provider deduplication (merging the same track from RuTracker and SoulSeek into one entity with both sources) is a planned feature, not implemented yet. Currently the two providers produce separate entities for the same song.
Stage 3: score¶
export function scoreStage<T extends { score?: number }>(entities: T[]): T[] {
for (const e of entities) e.score = 0;
return entities;
}
Placeholder. All scores are set to 0, so the final order is insertion order (which is provider-first: RuTracker results appear before SoulSeek results because RuTracker is listed first in the registry and its snapshot comes first in the Map.values() iteration).
The real scoring algorithm is designed but not yet implemented. It will be weighted on:
- Text match quality between the canonical query and the entity's artist/title
- Format and bitrate (FLAC > MP3 > lossy, higher bitrate > lower bitrate)
- Availability (log(seeders + 1) for RuTracker, peer rank for SoulSeek)
- Cross-source bonus (same song appearing in both providers)
Stage 4: filter¶
Placeholder. Existing UI-level filters (playability check, SoulSeek peer queue filter) still live in Results.vue. They will migrate here when the UI is rewritten to consume the pipeline output directly.
Entity shape¶
Entities produced by providers follow this minimum shape:
{
id: string, // provider-prefixed unique identifier
type: "track" | "album",
title: string,
artist: string | null,
albumTitle: string | null,
format: string | null, // "FLAC", "MP3", etc.
bitrate: number | null,
duration: number | null,
size: number | null,
score: number, // 0 until scoring is implemented
mergedFrom: number, // batch index at creation time (used by dedup)
sources: Array<{
kind: "rutracker" | "soulseek",
refs: { ... }, // provider-specific playback references
raw: { ... }, // original data for debugging / cover art
}>,
}
The sources array is the key extension point. When cross-provider dedup is implemented, a single entity will carry sources from both providers, and the player will pick the best one based on current network conditions.