SoulSeek Search Provider¶
Provider SoulSeek (src/search/providers/soulseek.ts) прослушивает инкрементные Tauri-события soulseek-search-batch и конвертирует необработанные строки peer'ов в pipeline entity. Его архитектура продиктована двумя ограничениями: peer-ответы поступают как поток событий (а не единый результат), а популярные запросы могут генерировать плотные всплески событий, которые перегрузили бы интерфейс при обработке по одному.
Источник: src/search/providers/soulseek.ts
Инкрементная архитектура событий¶
В отличие от provider'а RuTracker (который сам управляет параллельностью), provider SoulSeek пассивен на стороне событий. Tauri-бэкенд генерирует события soulseek-search-batch по мере поступления peer-ответов (см. Session search). Provider собирает их в изменяемый массив и перезапускает проход группировки при наличии изменений.
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();
});
// Отдельно вызывается soulseek_search (серверный таймаут 12 сек)
// При разрешении содержит полный итоговый список (дедуплицированный бэкендом).
// Заменяем raw[] итоговым списком для согласованности.
soulseekSearch(query, ctx.requestId)
.then((finalRows) => {
raw.length = 0;
raw.push(...finalRows);
dirty = true; nudge();
})
.finally(() => finish(finalError));
ctx.requestId — инкрементное целое число из SearchSession. Batch'и от старых поисков, поступающие после того, как session была заменена, молча отбрасываются guard'ом requestId.
Таймеры: baseline и idle¶
Provider использует два таймера для определения момента прекращения приёма результатов:
- Baseline-таймер (5000 мс): срабатывает, если в течение 5 секунд с начала поиска не поступило ни одного batch'а. Обрабатывает случаи медленной сети или нулевых результатов запроса.
- Idle-таймер (1000 мс): сбрасывается при каждом batch'е. Срабатывает через 1 секунду после последнего batch'а. Останавливает генератор, когда поток peer'ов утих.
Вызов soulseek_search также имеет жёсткий таймаут 12 секунд на бэкенде. Тот из трёх сигналов, что сработает первым, вызывает finish(), которая является идемпотентной.
rAF coalescing¶
Популярные запросы (например, известный исполнитель) могут порождать десятки peer-ответов в секунду. Перегруппировка всех строк при каждом batch-событии нагружала бы главный поток. Генератор ожидает один кадр анимации после каждого yield:
while (true) {
if (dirty) {
dirty = false;
yield groupSlskRowsToEntities(raw);
if (!finished && !ctx.signal.aborted) await _nextFrame();
continue; // немедленно проверить dirty — могут быть batch'и из этого кадра
}
if (finished) break;
await new Promise<void>((r) => { pendingResolve = r; });
}
Несколько batch-событий, поступивших пока генератор ожидал _nextFrame(), объединяются в следующий вызов groupSlskRowsToEntities(raw). Одна перегруппировка за кадр вместо одной на peer.
groupSlskRowsToEntities¶
Это основное преобразование. Конвертирует плоский SlskAudioRow[] в PipelineEntity[].
Шаг 1: Фильтрация и разделение¶
const images = rawRows.filter((r) => r.slsk_is_image);
const audios = rawRows.filter((r) => !r.slsk_is_image && peerIsLive(r));
peerIsLive: отбрасывает строки, у peer'ов которых queueLength > 25. Peer'ы с глубокими очередями вряд ли ответят до того, как пользователь сдастся; их отображение занимает слоты в списке результатов.
Строки изображений индексируются по (username, folder) для сопоставления обложек.
Шаг 2: Dedup по разрешённому названию¶
У SoulSeek нет концепции альбома — peer'ы шарят отдельные файлы. Одна и та же песня может появиться от десятков peer'ов с разными именами файлов:
Пошлая Молли - Нон стоп.flac02 - Нон стоп.flacPoshlaya Molly - Non Stop.mp3
trackDedupKey разрешает имя файла в (artist, title) с помощью resolveTrackNames, затем нормализует (нижний регистр, удаление не-алфавитно-цифровых символов) и объединяет с расширением:
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 ""; // не поддаётся разбору → отбрасывается
return `${artistN}|${titleN}.${ext}`;
}
Строки с одинаковым dedup-ключом группируются вместе.
Шаг 3: Ранжирование peer'ов¶
Внутри каждой группы выбирается лучший peer в качестве основного. Остальные отдельные peer'ы становятся альтернативами:
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;
}
| Условие | Изменение score |
|---|---|
slotsFree = true |
+2 |
avgSpeed > 0 |
+1 |
queueLength < 10 |
+1 |
queueLength > 50 |
-1 |
!slotsFree && queueLength > 20 |
-2 (фактически офлайн) |
Битрейт используется как разграничитель между peer'ами с равным рангом. Альтернативы ограничены 5 на группу (ALT_PEER_LIMIT), по одному на уникальный username.
Шаг 4: Обложки¶
К каждому треку прикрепляется лучшее изображение из папки того же peer'а:
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; // неизвестное изображение, наименьший приоритет
}
Среди изображений с одинаковым приоритетным score'ом побеждает наибольшее по размеру файла.
Шаг 5: Итоговая сортировка¶
Записи сортируются по peerRank(primary) по убыванию — треки от наиболее доступных peer'ов появляются первыми.
Форма создаваемых entity¶
Все SoulSeek entity имеют тип type: "track". Album entity отсутствуют — у SoulSeek нет концепции альбома.
{
type: "track",
id: "slsk:track:<username>|<filepath>",
title: fileName, // только имя файла
artist: null, // разрешается во время отображения
albumTitle: folderName, // последний компонент пути содержащей папки
fileName,
format: "FLAC" | "MP3" | null,
bitrate: row.bitrate,
duration: row.duration,
size: row.size,
albumId: null, // группировка по альбому отсутствует
sources: [{
kind: "soulseek",
refs: { slskUsername, slskFilepath },
raw: {
row, // полный SlskAudioRow (содержит slotsFree, avgSpeed, queueLength)
cover, // лучший ImageRow из той же папки или null
peers, // 1 + alternatives.length
alternativePeers: [ // до 5 запасных peer'ов
{ slskUsername, slskFilepath, size }
]
}
}],
score: 0,
mergedFrom: 1,
}
alternativePeers в raw используется плеером для автоматического переключения: если основной peer превысил таймаут или отклонил загрузку, плеер автоматически повторяет попытку со следующим альтернативным peer'ом без взаимодействия с пользователем.