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

Передача файлов

Передача файлов 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:

  1. get_peer_addr(username) — отправляет GetPeerAddress (код 3) серверу, ждёт ответа до 5 секунд.
  2. TcpStream::connect((ip, port)) с таймаутом 8 секунд.
  3. 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 поступает:

  1. Прочитать 4 байта FileTransferInit (сырой u32 token, без обёртки). Если он уже был считан во время handshake F-соединения (хранится в FConnReady.file_transfer_init_consumed), пропустить чтение.
  2. Отправить FileOffset — 8 байт, u64 little-endian, значение 0. Это указывает peer с какого места начинать.
  3. Начать приём сырых байт файла.

Записывающая половина 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.