Перейти к содержанию

Ранжирование peer и формирование результатов

После поиска у neegde есть плоский список строк SlskFileResult от множества peer. Прежде чем фронтенд сможет их отобразить, Rust-слой преобразует их в объекты SlskSearchResultRow, а pipeline фронтенда оценивает и группирует их.

Источники: src-tauri/src/soulseek/mod.rs, src/search/providers/soulseek.ts


Форма строки: SlskSearchResultRow

pub struct SlskSearchResultRow {
    // Standard fields (shared with RuTracker SearchResult shape)
    pub id: String,           // "slsk_<fnv1a_hex(username+filepath)>"
    pub name: String,         // filename only (last path component)
    pub category: String,     // "MP3 320 kbps" / "MP3 128 kbps" / "SoulSeek"
    pub size: u64,
    pub seeders: u64,         // always 1 (used as presence indicator)
    pub leechers: u64,        // always 0
    pub added: String,        // always "—"
    pub source: String,       // "soulseek"
    // SoulSeek-specific extras
    pub slsk_username: String,
    pub slsk_filepath: String,
    pub bitrate: Option<u32>,
    pub duration: Option<u32>,
    pub slsk_is_image: bool,  // true for .jpg/.png rows (folder cover art)
    pub slots_free: bool,     // peer has at least one free upload slot
    pub avg_speed: u32,       // bytes/s declared by peer
    pub queue_length: u64,    // pending uploads ahead of us (0 = free)
}

ID: хеш FNV-1a

ID строки использует 64-битный хеш FNV-1a над конкатенацией байт username + filepath:

fn stable_id(username: &str, filepath: &str) -> String {
    let mut h: u64 = 0xcbf29ce484222325;
    for b in username.bytes().chain(filepath.bytes()) {
        h ^= b as u64;
        h = h.wrapping_mul(0x100000001b3);
    }
    format!("{h:016x}")
}
// id = "slsk_<16-hex-chars>"

Это даёт стабильный, устойчивый к коллизиям ID без отдельного crate для UUID. Одна и та же пара (username, filepath) всегда отображается на один и тот же ID, поэтому стадия дедупликации pipeline может корректно отбрасывать дублирующие строки, поступающие в разных батчах поиска.

Category: метка bitrate

fn bitrate_category(bitrate: Option<u32>) -> String {
    match bitrate {
        Some(b) if b >= 320 => format!("MP3 {b} kbps"),
        Some(b) if b >= 128 => format!("MP3 {b} kbps"),
        Some(b) => format!("{b} kbps"),
        None => "SoulSeek".to_string(),
    }
}

Строка category используется фронтендом для группировки результатов по уровням качества. «SoulSeek» — запасной вариант, когда peer не сообщил bitrate (характерно для FLAC и lossless-файлов — кодирование атрибутов ориентировано на MP3).


Сигналы доступности peer

Три поля из хвоста FileSearchResponse записываются в каждую строку от peer:

Поле Тип Значение
slots_free bool Peer сообщает как минимум об одном свободном slot для загрузки
avg_speed u32 Средняя скорость отдачи peer (байт/с), объявленная им самим
queue_length u64 Количество загрузок в очереди перед новым запросом

Эти поля приходят один раз на peer (после списка файлов), а не на каждый файл. Если тело ответа было усечено (старые клиенты, обрезанный хвост zlib), они по умолчанию принимают значения slots_free=true, avg_speed=0, queue_length=0.


Группировка на фронтенде: groupSlskRowsToEntities

Провайдер SoulSeek (src/search/providers/soulseek.ts) получает батчи SlskSearchResultRow[] и вызывает groupSlskRowsToEntities для преобразования их в сущности pipeline.

Стратегия группировки:

  1. Строки с изображениями (slsk_is_image=true) извлекаются первыми и хранятся отдельно как потенциальные обложки для соседних аудиофайлов.
  2. Аудиострок группируются по папке (всё до последнего \ или / в filepath). Файлы в одной папке считаются треками одного альбома.
  3. Каждая группа папки становится либо:
  4. Сущностью альбома (если в папке ≥ 2 аудиофайлов), либо
  5. Отдельными сущностями треков (если только один файл).
  6. Изображения из той же папки прикрепляются к сущности альбома как coverRef.

Полученные сущности следуют форме сущности pipeline с type: "album" или type: "track" и записью sources с kind: "soulseek".


Выбор peer во время воспроизведения

Когда пользователь нажимает на трек SoulSeek, фронтенд вызывает soulseek_prepare_stream с username и filepath из выбранной SlskSearchResultRow. Автоматического выбора peer нет — клик пользователя определяет, с каким peer устанавливается связь.

Однако результаты поиска в pipeline упорядочены так, чтобы лучшие peer отображались первыми. Сортировка pipeline по умолчанию (порядок вставки → порядок провайдера) означает, что внутри провайдера SoulSeek результаты отсортированы по:

  1. Порядку прихода — peer, ответивший первым, показывается выше.
  2. Порядку батча — внутри батча от одного peer файлы идут в порядке файловой системы peer.

Поля slots_free, avg_speed и queue_length доступны в сырых данных источника сущности для будущего прохода перевзвешивания, но в текущей версии стадия оценки является заглушкой (все оценки равны 0).


Disk cache обложек

Превью обложек SoulSeek кэшируются в soulseek/covers/, чтобы избежать P2P-трафика при повторных открытиях:

fn slsk_cover_disk_cache_key(username: &str, filepath: &str) -> String {
    let norm = filepath.replace('\\', "/");  // normalize separators
    format!("{username}\n{norm}")
}

fn slsk_cover_disk_cache_file(app: &AppHandle, cache_key: &str) -> PathBuf {
    let hex_name = format!("{:x}", md5(cache_key.as_bytes()));
    slsk_covers_dir(app).join(format!("{hex_name}.json"))
}

Cache-файл содержит сериализованный в JSON SlskCoverPreview { mime, base64 }. MD5 ключа используется как имя файла, чтобы избежать символов, недопустимых в файловой системе, в именах пользователей или путях.

При попадании в cache soulseek_cover_preview возвращается немедленно без установки каких-либо peer-соединений. При промахе загружает до 512 КБ от peer и записывает результат на диск.