Обложки¶
Загрузка обложек в neegde многоуровневая: общее реактивное ядро кэша, три провайдер-специфичных пути загрузки (RuTracker, SoulSeek, изображение внутри торрента), извлечение встроенных тегов и запасной путь через MusicBrainz. Каждый путь возвращает data: URL или обычный https:// URL, который рендерит элемент <img>.
Исходники: src/lib/coverCacheCore.ts, src/rutracker/coverCache.ts, src/soulseek/coverCache.ts, src-tauri/src/torrent_image.rs, src-tauri/src/cover_art.rs, src/persistence/coverArtLocal.ts
Общее ядро кэша: makeCoverCache¶
Все три провайдерных кэша строятся одной фабрикой из src/lib/coverCacheCore.ts. Реализует двухпространственный дизайн:
positives — reactive(Map<key, dataUrl>) не вытесняется неудачным повторным запросом
negatives — Map<key, expiryEpochMs> промах с TTL
remember(key, null) никогда не удаляет существующий positive. Это устраняет баг «обложка исчезает»: раньше неудачный повторный запрос (после того как LRU вытеснил запись) обнулял реактивную запись. Теперь результат null устанавливает только negative-TTL-запись, которая истекает и позволяет следующему монтированию компонента повторить попытку.
export interface CoverCacheOptions {
fetch: (key: string) => Promise<string | null>;
maxEntries?: number; // soft-eviction cap по количеству записей (FIFO)
maxBytes?: number; // soft-eviction cap по объёму байт
negativeTtlMs?: number; // как долго кэшируется промах (по умолчанию 90 с)
maxConcurrent?: number; // ограничение параллелизма (0 = без ограничений)
onPositivePersist?: // хук для сохранения в localStorage / на диск
(key: string, dataUrl: string) => void;
}
getOrFetch(key) дедублирует одновременные запросы: если два компонента запрашивают одну обложку одновременно, выполняется только один сетевой вызов; оба ожидают один и тот же Promise.
Ограничение параллелизма (maxConcurrent) предотвращает ситуацию, когда страница со 100 SoulSeek-альбомами инициирует 100 параллельных P2P-соединений.
Обложки RuTracker¶
Формат ключа: "${mirror}\n${topicId}"
Зеркало включается в ключ, чтобы смена зеркала (например, .org → .net) не возвращала устаревший URL, полученный при другой сессии.
Загрузка: rutracker_get_cover(mirror, topicId) — Rust-команда, извлекающая обложку со страницы темы (см. Детали раздачи).
Хранение в localStorage:
neegde.rtCover.manifest.v1 — упорядоченный список FNV-1a идентификаторов слотов
neegde.rtCover.v1.<slotId> — составная запись: logicalKey + \u0001 + dataUrl
RT_MAX = 36 — LRU-лимит (data URL большие: ~50–200 КБ)
FNV-1a по логическому ключу даёт компактный, устойчивый к коллизиям идентификатор слота без дополнительных зависимостей. Манифест хранит порядок вставки для LRU-вытеснения. При старте приложения hydrateRutrackerCoversFromDisk считывает все записи обратно в кэш памяти.
Реакция на вход: watch(rtLoggedIn) — при входе пользователя вызывается clearNegatives(), чтобы миниатюры, не загрузившиеся до авторизации, могли повторить попытку немедленно. Также инкрементируется rutrackerCoverFetchEpoch, чтобы IntersectionObserver в CoverThumb переподключился (observer отключается при первом пересечении и иначе не повторит попытку).
Обложки SoulSeek¶
Формат ключа: "${username}\n${filepath}" (обратные слеши нормализуются в прямые)
Загрузка: soulseek_cover_preview(username, filepath, filesize) — Rust-команда, открывающая P2P-соединение с пиром, скачивающая до 512 КБ файла изображения и возвращающая { mime, base64 }. Результат приходит на фронтенд как data:${mime};base64,${base64}.
Параметр filesize передаётся отдельно через filesizeByKey, поскольку необходим для handshake передачи в Rust, но не является частью ключа кэша.
Дисковый кэш (сторона Rust): Успешные загрузки сохраняются в soulseek/covers/<md5_of_key>.json как SlskCoverPreview { mime, base64 }. При следующем запросе той же пары (username, filepath) Rust-команда читает с диска и возвращает результат без P2P-соединения.
Ограничение параллелизма: maxConcurrent = 4 — не более 4 одновременных peer-соединений при загрузке обложек. Каждое включает P-connection handshake, обмен TransferRequest/TransferResponse и частичную загрузку файла.
Ограничения памяти: maxEntries = 1024, maxBytes = 64 МБ. Вытеснение по принципу FIFO.
Загрузка изображений из торрента (torrent_image.rs)¶
В некоторых RuTracker-раздачах внутри торрента есть отдельный файл обложки (например, folder.jpg, cover.png). TorrentImageState скачивает его через отдельную сессию librqbit, не мешая сессии аудиостриминга.
Зачем отдельная сессия?¶
Сессия vozduxan использует libtorrent (C++). Параллельная сессия librqbit для изображений работает с тем же роем сидеров, но с другой peer-идентификацией. Сидеры часто ограничивают подключения по IP, поэтому два одновременных соединения могут замедлить доставку аудио-кусков. Это осознанный компромисс: он позволяет не трогать C++ API vozduxan ценой периодической конкуренции за слоты сидеров.
Disk shortcut через vozduxan¶
Прежде чем открывать librqbit-поток, read_image_data_url проверяет, не скачал ли vozduxan файл в bt/vozduxan/<relative_path>:
if let Some(ref base) = self.vozduxan_storage {
let full_path = base.join(&rel_path);
if full_path.exists() {
// читаем прямо с диска — второе BT-соединение не нужно
return Some(format!("data:{mime};base64,{b64}"));
}
}
Это типичный случай при активном стриминге альбома: vozduxan уже скачал куски с изображением вместе с аудиокусками.
Подсчёт ссылок на магнет¶
MagnetInner отслеживает одновременные запросы для одного магнета через refcounts: HashMap<usize, usize> (file_idx → количество). Когда приходит второй запрос обложки для другого файла того же торрента, register_file вызывает update_only_files на существующем handle, чтобы добавить новый file_idx к активному набору, вместо того чтобы открывать второй librqbit-handle для того же магнета.
Ограничения¶
| Константа | Значение | Назначение |
|---|---|---|
MAX_IMAGE_BYTES |
3 МБ | Пропуск файлов крупнее этого размера |
FETCH_TIMEOUT_SECS |
15 с | Таймаут скачивания изображения |
CACHE_MAX_ENTRIES |
128 | In-memory LRU для data-URL (FIFO) |
Извлечение встроенной обложки¶
Когда в RuTracker-раздаче нет отдельного файла обложки (coverFileIdx == null), фронтенд может запросить извлечение обложки, встроенной в сам аудиофайл (ID3v2 APIC, FLAC PICTURE, MP4 cover atom).
Три Tauri-команды в порядке возрастания стоимости:
| Команда | Метод | Читает | Таймаут |
|---|---|---|---|
torrent_embedded_cover |
read_embedded_cover_prefix |
Первые 768 КБ аудиофайла | 20 с |
torrent_embedded_cover_full_file |
read_embedded_cover_full_file |
Весь файл (до 2 ГБ) | 240 с |
| — | extract_embedded_cover_data_url_from_audio_path |
Путь к локальному файлу | — |
Сканирование префикса: большинство тегов ID3v2 находятся в начале MP3. Чтения 768 КБ обычно достаточно для захвата встроенного JPEG объёмом 200–500 КБ. Теги FLAC и MP4 тоже, как правило, расположены в первых нескольких сотнях КБ.
Сканирование полного файла: используется как запасной вариант, когда сканирование префикса не даёт результата (редкий случай — некоторые плохо тегированные FLAC-файлы помещают блок PICTURE в конец). Скачивает весь файл во временный путь, запускает lofty, затем удаляет временный файл.
Оба метода используют тот же disk shortcut через vozduxan: если аудиофайл уже частично или полностью загружен в bt/vozduxan/, чтение не задействует BitTorrent.
Парсинг тегов через lofty: embedded_cover_data_url_from_bytes запускает Probe::new(cursor).guess_file_type().read() на буфере. Предпочитает PictureType::CoverFront, при отсутствии берёт первый блок изображения.
Запасной путь: MusicBrainz / CoverArtArchive (cover_art.rs)¶
Используется для альбомов, для которых ни один другой источник не предоставил обложку:
MusicBrainz поиск: GET /ws/2/release?query=release:"<album>" AND artist:"<artist>"&limit=3
│
└── берёт первый release ID (MBID)
│
CoverArtArchive: GET /release/<mbid>/front-250
│
└── возвращает миниатюру 250 пикселей как data: URL
Запрос экранирует Lucene-спецсимволы (" → \"). Если известен только один из параметров (исполнитель или альбом), запрос использует только его. CAA-endpoint возвращает редирект на реальное изображение; reqwest следует ему автоматически.
Ограничения: MAX_IMAGE_BYTES = 2 МБ. При любой HTTP-ошибке или превышении размера возвращает None.
Обогащение через Deezer (coverArtLocal.ts)¶
Поиск трека в Deezer возвращает (artist, title, album, coverUrl) — каноническую запись с HTTPS-URL обложки. Два отдельных localStorage LRU сохраняют результаты:
neegde.dzTrackCanon.v1.* — каноническая запись (artist, title, album, coverUrl) макс. 600
neegde.dzAlbumArt.v1.* — только HTTPS-URL обложки альбома макс. 800
Оба используют тот же формат manifest+FNV-1a-слот, что и LRU для RuTracker. Отрицательные результаты (null) тоже сохраняются с пустым payload — чтобы при холодном старте не повторять API-запросы к Deezer для треков, которые уже были проверены и не дали результата.
Нормализация ключа:
function normalizeDeezer(s: string): string {
return s.trim().toLowerCase()
.replace(/ё/g, "е") // кириллическое «ё» → «е» (часто встречается в метаданных)
.replace(/[^\p{L}\p{N}]/gu, ""); // удаляем пунктуацию
}
// ключ трека: "artist|title"
// ключ альбома: "deezer-album-art|artist|album"
Нормализация «ё» → «е» предотвращает промахи кэша, когда один и тот же трек появляется с «ё» в одном поиске и без него в другом.