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.
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.