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

Обложки

Загрузка обложек в 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 для того же магнета.

only_files = объединение всех активных file_idx из refcounts

Ограничения

Константа Значение Назначение
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"

Нормализация «ё» → «е» предотвращает промахи кэша, когда один и тот же трек появляется с «ё» в одном поиске и без него в другом.