Передача файлов¶
Передача файлов SoulSeek реализована в src-tauri/src/soulseek/transfer.rs. Каждое аудио-воспроизведение начинается с согласования передачи с peer, загрузки файла во временный путь и его отдачи через локальный HTTP-сервер с поддержкой Range-запросов.
Источник: src-tauri/src/soulseek/transfer.rs
Константы¶
const CONNECT_TIMEOUT_SECS: u64 = 8; // P-connection TCP connect
const TRANSFER_TIMEOUT_SECS: u64 = 120; // waiting for TransferRequest or F connection
const HTTP_CHUNK: usize = 32 * 1024; // read/write granularity for HTTP serving
StreamHandle¶
Результат успешного вызова download_and_stream:
pub struct StreamHandle {
pub url: String, // "http://127.0.0.1:<port>/slsk/<token>"
pub token: u32, // release key
pub temp_path: PathBuf, // OS temp dir, e.g. neegde_slsk_42.tmp
pub total_size: u64, // bytes as declared by peer
pub downloaded: Arc<AtomicU64>,// running byte count, updated by download_loop
pub complete: Arc<AtomicBool>, // set when download_loop exits
pub download_abort: AbortHandle,
pub http_abort: AbortHandle,
}
downloaded и complete разделяются между задачей загрузки и задачей HTTP-сервера, чтобы сервер мог отдавать данные по мере поступления, не блокируясь до полной загрузки файла.
Handshake: два варианта¶
SoulSeek имеет два варианта протокола для запроса файлов. download_and_stream сначала пробует современный, при неудаче откатывается на устаревший.
Современный вариант: QueueUpload (код 43)¶
Используется SoulseekQt и Nicotine+.
us peer
| |
|──── PeerInit (code 1) ────────>|
|──── QueueUpload (code 43) ────>| filepath
|<─── TransferRequest (code 40) ─| direction=1, xfer_token, size
|──── TransferResponse (code 41) >| xfer_token, allowed=true, size
| |
|<═══ F connection ══════════════| TCP stream to xfer_token slot
|<─── FileTransferInit (4 bytes)─| raw u32 token (not framed)
|──── FileOffset (8 bytes) ─────>| u64 = 0 (start from beginning)
|<═══ file data ═════════════════| raw bytes, no further framing
После QueueUpload peer может отправить промежуточные сообщения до TransferRequest:
- PlaceInQueue (код 44): файл в очереди у peer — пропустить и ждать.
- UploadDenied (код 50): peer отказывает — передать как ошибку, дать повтору в
download_and_streamпопробовать устаревший вариант. - TransferResponse (код 41) до
TransferRequest: устаревший peer, прервать современный вариант.
Устаревший вариант: TransferRequest (код 40)¶
Старые клиенты ожидают, что запрос отправим мы:
us peer
| |
|──── PeerInit (code 1) ────────>|
|──── TransferRequest (40) ─────>| direction=0 (download), token, filepath
|<─── TransferResponse (41) ─────| token, allowed=bool, size
| |
|<═══ F connection ══════════════| same token routing
|<─── FileTransferInit (4 bytes)─|
|──── FileOffset (8 bytes) ─────>|
|<═══ file data ══════════════════
Установка P-соединения¶
До начала согласования передачи устанавливается исходящее P-type TCP-соединение с peer:
get_peer_addr(username)— отправляет GetPeerAddress (код 3) серверу, ждёт ответа до 5 секунд.TcpStream::connect((ip, port))с таймаутом 8 секунд.send_peer_init— отправляет handshake PeerInit:
// PeerInit wire format:
// [u32 length][u8 code=1][str username][u32 type_len=1][u8 'P'][u32 token=0]
Примечание: PeerInit использует 1-байтовый код, а не 4-байтовый u32.
F-соединение и FileTransferInit¶
Данные файла передаются через отдельное F-type TCP-соединение (подробнее о маршрутизации F-соединений см. в Session). После регистрации ожидания (register_f_waiter) и отправки TransferResponse код ожидает поступления F-socket до TRANSFER_TIMEOUT_SECS.
Когда F-socket поступает:
- Прочитать 4 байта
FileTransferInit(сырой u32 token, без обёртки). Если он уже был считан во время handshake F-соединения (хранится вFConnReady.file_transfer_init_consumed), пропустить чтение. - Отправить
FileOffset— 8 байт, u64 little-endian, значение 0. Это указывает peer с какого места начинать. - Начать приём сырых байт файла.
Записывающая половина F-socket должна оставаться открытой на всё время загрузки. Её сброс после отправки FileOffset отправляет TCP FIN, и многие peer воспринимают это как отмену загрузки — они прекращают отдачу или закрывают соединение. Записывающая половина (_wh) удерживается живой в download_loop как игнорируемый параметр до завершения загрузки.
Цикл загрузки¶
async fn download_loop(
mut rh: OwnedReadHalf,
_wh: OwnedWriteHalf, // must not be dropped until done
temp_path: PathBuf,
downloaded: Arc<AtomicU64>,
complete: Arc<AtomicBool>,
) {
let mut file = tokio::fs::File::create(&temp_path).await?;
let mut buf = vec![0u8; HTTP_CHUNK]; // 32 KB
loop {
match rh.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
file.write_all(&buf[..n]).await?;
downloaded.fetch_add(n as u64, Ordering::Release);
}
Err(_) => break,
}
}
complete.store(true, Ordering::Release);
}
downloaded обновляется с Ordering::Release после завершения каждой записи. HTTP-сервер читает downloaded с Ordering::Acquire, поэтому никогда не обращается к диапазону байт, который ещё не записан на диск.
Временный файл называется neegde_slsk_<token>.tmp и находится в системной временной директории. Он удаляется при вызове soulseek_release_stream (abort + remove_file).
Локальный HTTP-сервер¶
run_download_pipeline привязывает TcpListener к 127.0.0.1:0 (случайный эфемерный порт) и запускает http_server_loop. URL http://127.0.0.1:<port>/slsk/<token> возвращается фронтенду.
Обработка Range-запросов¶
HTTP-сервер реализует Range-запросы, чтобы аудио-элемент мог перематывать:
- Разбирает заголовок
Range: bytes=start-end. - Ожидает в цикле опроса с интервалом 80 мс, пока
downloaded > range_start(илиcomplete). - Затем читает запрошенный диапазон из временного файла через
spawn_blocking+std::fs::File::seek. - Отправляет заголовки
206 Partial Contentили200 OKв зависимости от ситуации. - Потоково отдаёт чанки в цикле, перечитывая с диска на каждой итерации до достижения
range_end.
Если запрошенный диапазон недоступен и проходит 60 секунд, возвращается 503 Service Unavailable.
HTTP/1.1 206 Partial Content
Content-Type: audio/flac
Content-Range: bytes 0-32767/12345678
Content-Length: 32768
Accept-Ranges: bytes
Access-Control-Allow-Origin: *
Заголовки CORS необходимы, потому что dev WebView (localhost) и HTTP-сервер (127.0.0.1) являются разными origin.
Маппинг MIME-типов¶
Расширение файла → Content-Type:
| Расширение | MIME |
|---|---|
| mp3 | audio/mpeg |
| flac | audio/flac |
| ogg | audio/ogg |
| m4a | audio/mp4 |
| wav | audio/wav |
| aac | audio/aac |
| opus | audio/opus |
| wma | audio/x-ms-wma |
| ape | audio/x-monkeys-audio |
Загрузка превью обложки¶
download_cover_preview использует тот же handshake, что и download_and_stream, но читает не более 512 КБ в память вместо сохранения во временный файл и запуска HTTP-сервера. Используется soulseek_cover_preview для получения обложки альбома от peer.
Байты обложки возвращаются обработчику Tauri-команды, который кодирует их в base64 и записывает результат в soulseek/covers/<md5_of_key>.json. Последующие запросы для той же пары (username, filepath) обслуживаются из disk cache без P2P-трафика.
Очистка P-соединения¶
После отправки TransferResponse записывающая половина P-соединения сбрасывается (закрывается). Читающая половина удерживается живой в задаче дренажа, которая читает и отбрасывает данные до EOF. Это необходимо, потому что некоторые peer держат P-соединение открытым и продолжают отправлять сообщения (обновления PlaceInQueue, эхо PeerInit) во время установки F-соединения. Если бы читающая половина была сброшена, ОС отправила бы TCP RST, который может прервать установку F-соединения на стороне peer.