Skip to content

Cover Art

Cover fetching in neegde is multi-layered: a shared reactive cache core, three provider-specific fetch paths (RuTracker, SoulSeek, in-torrent image), embedded tag extraction, and a MusicBrainz fallback. Every path returns a data: URL or a plain https:// URL that the <img> element renders.

Sources: 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


Shared cache core: makeCoverCache

All three provider caches are built with the same factory from src/lib/coverCacheCore.ts. It implements a two-space design:

positives  — reactive(Map<key, dataUrl>)  never evicted by a failed re-fetch
negatives  — Map<key, expiryEpochMs>      TTL-backed miss cache

remember(key, null) never removes an existing positive. This prevents the "cover disappears" bug: a failed re-fetch (after the in-memory LRU soft-evicted the entry) used to null out the reactive entry. Now a null result only sets a negative-TTL entry, which expires and allows the next component mount to retry.

export interface CoverCacheOptions {
  fetch: (key: string) => Promise<string | null>;
  maxEntries?: number;       // FIFO soft-eviction cap
  maxBytes?: number;         // byte-count soft-eviction cap
  negativeTtlMs?: number;    // how long a miss is cached (default 90 s)
  maxConcurrent?: number;    // concurrency gate (0 = unlimited)
  onPositivePersist?:        // localStorage / disk persistence hook
    (key: string, dataUrl: string) => void;
}

getOrFetch(key) deduplicates concurrent requests: if two components request the same cover simultaneously, only one network call goes out; both await the same Promise.

The concurrency gate (maxConcurrent) prevents a results page of 100 SoulSeek albums from firing 100 parallel P2P connections.


RuTracker covers

Key format: "${mirror}\n${topicId}"

The mirror is included in the key so a mirror switch (e.g. .org.net) doesn't serve a stale URL fetched against a different mirror's session.

Fetch: rutracker_get_cover(mirror, topicId) — Rust command that scrapes the topic page HTML for a cover image (see Torrent Details).

localStorage persistence:

neegde.rtCover.manifest.v1  — ordered list of FNV-1a slot IDs
neegde.rtCover.v1.<slotId>  — composite: logicalKey + \u0001 + dataUrl
RT_MAX = 36                 — LRU cap (data URLs are large ~50–200 KB)

FNV-1a over the logical key produces a compact, collision-resistant slot ID without a UUID crate. The manifest stores insertion order for LRU eviction. On app start, hydrateRutrackerCoversFromDisk reads all entries back into the in-memory cache.

Login reaction: watch(rtLoggedIn) — when the user logs in, clearNegatives() is called so thumbnails that failed before auth can retry immediately. rutrackerCoverFetchEpoch is also incremented so CoverThumb's IntersectionObserver re-attaches (the observer disconnects on first intersection and would otherwise not retry).


SoulSeek covers

Key format: "${username}\n${filepath}" (backslashes normalized to forward slash)

Fetch: soulseek_cover_preview(username, filepath, filesize) — Rust command that opens a P2P connection to the peer, downloads up to 512 KB of the image file, and returns { mime, base64 }. The base64 result is returned to the frontend as data:${mime};base64,${base64}.

The filesize parameter is passed out-of-band via filesizeByKey because it is needed by the Rust transfer handshake but is not part of the cache key.

Disk cache (Rust side): Successful fetches are persisted to soulseek/covers/<md5_of_key>.json as SlskCoverPreview { mime, base64 }. On the next fetch for the same (username, filepath), the Rust command reads from disk and returns immediately without any P2P connection.

Concurrency cap: maxConcurrent = 4 — at most 4 simultaneous peer connections from cover fetching. Each one involves a P-connection handshake, TransferRequest/TransferResponse exchange, and partial file download.

Memory limits: maxEntries = 1024, maxBytes = 64 MB. FIFO eviction.


In-torrent image fetch (torrent_image.rs)

RuTracker topics sometimes include a cover image file inside the torrent (e.g. folder.jpg, cover.png). TorrentImageState downloads it via a separate librqbit session so it never interferes with the audio streaming session.

Why a separate session?

The vozduxan streaming session uses libtorrent (C++). A concurrent librqbit session for images shares the same swarm of seeders but through a different peer identity. Seeders often rate-limit connections per IP, so two concurrent connections can slow audio piece delivery. The librqbit session is a deliberate trade-off: it avoids modifying vozduxan's C++ API, at the cost of occasionally competing for seeder slots.

Vozduxan disk shortcut

Before opening a librqbit stream, read_image_data_url checks whether vozduxan has already downloaded the file to bt/vozduxan/<relative_path>:

if let Some(ref base) = self.vozduxan_storage {
    let full_path = base.join(&rel_path);
    if full_path.exists() {
        // read directly from disk — no second BT connection needed
        return Some(format!("data:{mime};base64,{b64}"));
    }
}

This is the common case when the user is actively streaming an album: vozduxan has already downloaded the image pieces along with the audio pieces.

Per-magnet refcounting

MagnetInner tracks concurrent fetches for the same magnet via a refcounts: HashMap<usize, usize> (file_idx → count). When a second cover request arrives for a different file in the same torrent, register_file calls update_only_files on the existing handle to add the new file_idx to the active set, rather than opening a second librqbit torrent handle for the same magnet.

only_files = union of all active refcount file indices

Limits

Constant Value Purpose
MAX_IMAGE_BYTES 3 MB Skip files larger than this
FETCH_TIMEOUT_SECS 15 s Timeout for downloading the image
CACHE_MAX_ENTRIES 128 In-memory data-URL LRU (FIFO)

Embedded cover extraction

When a RuTracker topic has no separate cover file (coverFileIdx == null), the frontend can request extraction of embedded cover art from the audio file itself (ID3v2 APIC, FLAC PICTURE, MP4 cover atom).

Three Tauri commands, in escalating cost:

Command Method Reads Timeout
torrent_embedded_cover read_embedded_cover_prefix First 768 KB of audio file 20 s
torrent_embedded_cover_full_file read_embedded_cover_full_file Entire file (up to 2 GB cap) 240 s
extract_embedded_cover_data_url_from_audio_path Local file path

Prefix scan: Most ID3v2 tags are at the beginning of an MP3. Reading 768 KB is usually sufficient to capture a 200–500 KB embedded JPEG. FLAC and MP4 tags are also typically in the first few hundred KB.

Full file scan: Used as a fallback when the prefix misses (uncommon — some poorly-tagged FLAC files bury the PICTURE block). Downloads the entire file to a temp path, runs lofty, then deletes the temp file.

Both methods use the same vozduxan disk shortcut: if the audio file is already partially or fully downloaded in bt/vozduxan/, the read avoids any BitTorrent traffic.

lofty tag parsing: embedded_cover_data_url_from_bytes runs Probe::new(cursor).guess_file_type().read() on the buffer. It prefers PictureType::CoverFront and falls back to the first picture in the tag.


MusicBrainz / CoverArtArchive fallback (cover_art.rs)

Used for albums where no other source provided a cover. This path is currently exposed as a Rust-side helper called when needed:

MusicBrainz search: GET /ws/2/release?query=release:"<album>" AND artist:"<artist>"&limit=3
  └── picks first release ID (MBID)
CoverArtArchive: GET /release/<mbid>/front-250
  └── returns 250 px thumbnail as data: URL

Query is Lucene-escaped ("\"). If only one of artist/album is known, the query uses only that field. The CAA endpoint returns a redirect to the actual image; reqwest follows it.

Limits: MAX_IMAGE_BYTES = 2 MB. Returns None on any HTTP error or oversized response.


Deezer enrichment (coverArtLocal.ts)

Deezer track lookup returns (artist, title, album, coverUrl) — a canonical record that carries the Deezer album art HTTPS URL. Two separate localStorage LRUs persist results:

neegde.dzTrackCanon.v1.*    — canonical (artist, title, album, coverUrl)   max 600
neegde.dzAlbumArt.v1.*      — album art HTTPS URL only                     max 800

Both use the same manifest+FNV-1a slot format as the RuTracker LRU. Negative results (null) are stored with an empty payload — so cold starts skip Deezer API calls for tracks that were already looked up and returned no result, avoiding repeated latency hits.

Key normalization:

function normalizeDeezer(s: string): string {
  return s.trim().toLowerCase()
    .replace(/ё/g, "е")              // Cyrillic Yo → Ye (common in Russian metadata)
    .replace(/[^\p{L}\p{N}]/gu, ""); // strip punctuation
}

// track key: "artist|title"
// album key: "deezer-album-art|artist|album"

The Yo→Ye normalization prevents cache misses when the same track appears with ё in one search and without in another.