Движок 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 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):
Фронтенд периодически вызывает vozduxan_stream_stats для отображения количества peers и скорости загрузки во всплывающем окне статуса потока.
Интеграция со сборкой¶
vozduxan компилируется как статическая библиотека через build.rs посредством CMake. cargo:rerun-if-changed должен перечислять отдельные C++-файлы исходников — macOS не обновляет mtime директории при изменении файла внутри неё, поэтому без отслеживания по отдельным файлам Cargo не обнаружит правки. См. Сборка vozduxan через CMake.