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

Движок Vozduxan (C++)

vozduxan — это кастомная C++-библиотека, обёртывающая libtorrent для потокового воспроизведения аудио с минимальными задержками. Она берёт на себя всё: от резолюции magnet/torrent до планирования приоритетов кусков и раздачи аудиопотока через локальный HTTP-сервер. Rust-сторона никогда не обращается к libtorrent напрямую — только через C API, объявленный в vozduxan_ffi.rs.


C API

Библиотека подключается как статический архив (libvozduxan.a) и предоставляет плоский C ABI:

#[link(name = "vozduxan", kind = "static")]
unsafe extern "C" {
    pub fn vozduxan_session_create(config: *const VozduxanConfig) -> *mut VozduxanSession;
    pub fn vozduxan_session_destroy(session: *mut VozduxanSession);

    pub fn vozduxan_stream_prepare(
        session: *mut VozduxanSession,
        magnet: *const c_char,
        torrent_data: *const u8,
        torrent_len: usize,
        file_idx: c_int,
        is_main: c_int,
        progress_fn: VozduxanProgressFn,
        userdata: *mut c_void,
    ) -> VozduxanStreamInfo;

    pub fn vozduxan_stream_notify_position(
        session: *mut VozduxanSession,
        token: *const c_char,
        byte_offset: i64,
    );
    pub fn vozduxan_stream_release(session: *mut VozduxanSession, token: *const c_char);
    pub fn vozduxan_list_files(
        session: *mut VozduxanSession,
        magnet: *const c_char,
        torrent_data: *const u8,
        torrent_len: usize,
    ) -> VozduxanFileList;
    pub fn vozduxan_file_list_free(list: *mut VozduxanFileList);
    pub fn vozduxan_session_evict(session: *mut VozduxanSession);
    pub fn vozduxan_session_http_port(session: *mut VozduxanSession) -> u16;
}

VozduxanSession — непрозрачный C++-объект. Rust-сторона хранит его как *mut VozduxanSession внутри VozduxanSessionInner и никогда не разыменовывает — только передаёт обратно в C-функции.


Создание session

vozduxan_session_create принимает VozduxanConfig:

#[repr(C)]
pub struct VozduxanConfig {
    pub storage_path:    *const c_char,  // путь для состояния libtorrent и piece cache
    pub cache_max_bytes: u64,            // 0 = 50 ГБ по умолчанию
    pub cache_ttl_secs:  u32,            // 0 = 3600 с по умолчанию
    pub listen_port:     c_int,          // 0 = случайный (выбирает ОС)
    pub log_fn:          VozduxanLogFn,  // опциональный log callback (NULL = только stderr)
    pub log_userdata:    *mut c_void,    // передаётся в log_fn без изменений
}

В neegde все поля используют значения по умолчанию (нули), кроме storage_path и log callback. Путь хранилища — bt/vozduxan/ относительно исполняемого файла (см. Структура данных).

Log callback подключён к AppDebugLog — каждая строка C++-лога проходит через:

unsafe extern "C" fn on_vozduxan_log(message: *const c_char, userdata: *mut c_void) {
    let debug_log = unsafe { &*(userdata as *const AppDebugLog) };
    let msg = CStr::from_ptr(message).to_string_lossy().into_owned();
    // Подавляем частый шум от peer-соединений
    let lower = msg.to_ascii_lowercase();
    if lower.contains("connecttopeer") || lower.contains("connect to peer") {
        return;
    }
    debug_log.push("vozduxan", msg, None);
}

Сырой указатель в VozduxanConfig.log_userdata указывает внутрь Arc<AppDebugLog>, который VozduxanSessionInner удерживает живым в поле _debug_log_arc. Arc не должен быть сброшен, пока существует C++-session — в противном случае указатель стал бы висячим, и log callback писал бы в освобождённую память.


Подготовка потока

vozduxan_stream_prepare — основная точка входа для воспроизведения. Это блокирующий вызов: он не возвращает управление, пока поток не готов к раздаче. На Rust-стороне он всегда запускается внутри tokio::task::spawn_blocking.

magnet URI  ─┐
байты .torrent ─┤──→ vozduxan_stream_prepare ──→ VozduxanStreamInfo
file_idx    ─┘         (блокирует до заполнения prebuffer)

Два режима входных данных

Режим Условие Поведение
Magnet + DHT torrent_data == NULL vozduxan резолвит метаданные через DHT; при холодном старте может занять 30–90 с
Torrent-файл torrent_data != NULL метаданные доступны сразу; metadata_received_alert не срабатывает

Для контента с RuTracker Rust-слой скачивает файл .torrent и передаёт его как base64. Tauri-команда декодирует его перед вызовом vozduxan:

let torrent_bytes: Option<Vec<u8>> = match torrent_file_b64.as_deref() {
    None | Some("") => None,
    Some(s) => Some(
        base64::engine::general_purpose::STANDARD
            .decode(s.trim())
            .map_err(|e| format!("bad base64: {e}"))?,
    ),
};

Это полностью устраняет ожидание DHT-метаданных.

Известный инвариант

Если torrent_data передан, libtorrent не генерирует metadata_received_alert, поскольку метаданные уже установлены в структуре add_torrent_params. vozduxan обрабатывает этот случай внутренне на стороне C++. Не добавляйте обходное решение на Rust-стороне.

Флаг is_main

is_main = 1 сигнализирует об активном воспроизведении — vozduxan отменяет любой текущий fast-start для этого торрента и начинает новый с запрошенного файла. is_main = 0 используется для фонового prefetch: session прогревает файл, не нарушая текущую загрузку.

Возвращаемое значение

#[repr(C)]
pub struct VozduxanStreamInfo {
    pub url:       [u8; 512],  // "http://127.0.0.1:PORT/stream/TOKEN\0"
    pub token:     [u8; 128],  // непрозрачный идентификатор потока, используется в последующих вызовах
    pub file_size: i64,
    pub mime_type: [u8; 64],
    pub error:     VozduxanError,
    pub error_msg: [u8; 256],
}

При успехе error == VozduxanError::Ok, а url содержит полный localhost URL, который передаётся элементу <audio>. Строка token идентифицирует поток для notify_position, release и stats.

Коды ошибок

pub enum VozduxanError {
    Ok = 0,
    MetadataTimeout = 1,  // DHT-резолюция превысила 90 с
    InvalidFile = 2,      // file_idx вне диапазона или файл не является аудио
    BadInput = 3,         // null magnet, некорректные данные torrent
    Internal = 99,        // неспецифицированное C++-исключение
}

Планирование приоритетов кусков

Внутри C++ vozduxan реализует трёхуровневую схему приоритетов кусков, адаптированную из исследований Tribler по libtorrent:

Уровень Диапазон кусков (от текущей позиции) Механизм Назначение
TIER1 следующие 20 кусков set_piece_deadline(8000 ms) Критично по времени; предотвращение остановок
TIER2 куски 20–60 вперёд высокий приоритет Look-ahead buffer
TIER3 оставшиеся куски приоритет по умолчанию Фоновая загрузка

Окно сдвигается вперёд при каждом вызове vozduxan_notify_position. Фронтенд вызывает его на каждое событие timeupdate от элемента <audio>:

// Player.vue
invoke("vozduxan_notify_position", { token, byteOffset: audioEl.currentTime * bytesPerSecond })

C++ пересчитывает индекс куска из byte_offset и переустанавливает окно приоритетов. Это удерживает куски с дедлайном выровненными по реальной позиции воспроизведения, а не по позиции старта.


Локальный HTTP-сервер

vozduxan запускает внутренний HTTP/1.1-сервер, привязанный к 127.0.0.1 на случайно выбранном порту. Порт фиксирован на всё время жизни session и может быть получен через vozduxan_session_http_port. Каждый VozduxanStreamInfo.url предварительно отформатирован с этим портом:

http://127.0.0.1:PORT/stream/TOKEN

Сервер поддерживает HTTP Range-запросы — именно их браузеры используют для перемотки аудио. Когда <audio> переходит на новую позицию, он отправляет Range: bytes=X-. C++ проверяет счётчик seek_generation между записями чанков. Если во время выполнения старого Range-ответа поступает новый seek (счётчик увеличивается), старая передача немедленно прерывается без ожидания завершения.


Fast-start prebuffer

vozduxan_stream_prepare не возвращает управление, пока не будет буферизован минимальный объём данных. Размер prebuffer адаптируется к условиям соединения:

  • Быстрое соединение (много peers, высокая скорость загрузки): ~32 КБ
  • Медленное соединение: до 512 КБ

Именно поэтому вызов блокирует — ранний возврат вызвал бы немедленную остановку в элементе <audio>. Во время prebuffering callback VozduxanProgressFn срабатывает периодически; Rust-обёртка пробрасывает его как Tauri-event torrent-prepare-progress, чтобы фронтенд мог показывать спиннер с процентом.

unsafe extern "C" fn on_progress(progress: f32, status: *const c_char, userdata: *mut c_void) {
    let ctx = unsafe { &*(userdata as *const ProgressCtx) };
    let _ = ctx.app.emit(
        "torrent-prepare-progress",
        PrepareProgressPayload::buffering((progress as f64) * 100.0, msg),
    );
}

Release и вытеснение

vozduxan_stream_release освобождает поток и ожидает завершения priority-потока внутри C++. Это ожидание занимает примерно 100 мс. Именно поэтому release никогда не должен выполняться синхронно на tokio async executor — это заблокирует поток на указанное время. Rust-обёртка оборачивает каждый release в spawn_blocking:

tokio::task::spawn_blocking(move || {
    let token_c = CString::new(token.as_str()).unwrap();
    unsafe { ffi::vozduxan_stream_release(inner.ptr, token_c.as_ptr()) };
    // ... очищаем token bucket, возможно вызываем evict
})
.await

Неактивные торренты накапливаются в libtorrent-session до вызова vozduxan_session_evict. neegde вызывает его:

  • Каждые 10 release потоков (отслеживается счётчиком evict_counter в VozduxanSessionInner)
  • При каждом явном torrent_dispose_preview (очистка cache)

Листинг файлов

vozduxan_list_files резолвит magnet или torrent и возвращает дерево файлов без запуска воспроизведения. Используется панелью торрента для отображения треков до того, как пользователь выберет один из них.

pub struct VozduxanFileEntry {
    pub name:  [u8; 512],
    pub mime:  [u8; 64],
    pub size:  i64,
    pub index: c_int,  // file_idx для последующей передачи в vozduxan_stream_prepare
}

pub struct VozduxanFileList {
    pub files:     *mut VozduxanFileEntry,
    pub count:     c_int,
    pub error:     VozduxanError,
    pub error_msg: [u8; 256],
}

VozduxanFileList выделяется в куче внутри C++. Rust-обёртка освобождает его через vozduxan_file_list_free сразу после преобразования записей в Vec<TorrentFile>.


Статистика загрузки

vozduxan_stream_stats возвращает живую телеметрию для token потока. Это быстрый, неблокирующий вызов (без I/O):

pub struct VozduxanStreamStats {
    pub download_rate_bytes: i32,
    pub num_peers: i32,
}

Фронтенд периодически вызывает vozduxan_stream_stats для отображения количества peers и скорости загрузки во всплывающем окне статуса потока.


Интеграция со сборкой

vozduxan компилируется как статическая библиотека через build.rs посредством CMake. cargo:rerun-if-changed должен перечислять отдельные C++-файлы исходников — macOS не обновляет mtime директории при изменении файла внутри неё, поэтому без отслеживания по отдельным файлам Cargo не обнаружит правки. См. Сборка vozduxan через CMake.