perf(image-cache): normalize Plex token out of cache keys for hits across sessions

Plex thumbnail URLs carry X-Plex-Token (and sometimes
X-Plex-Client-Identifier) as a query parameter. Those rotate each
time a token refresh lands, so the *exact same* /library/metadata/…/
thumb resource hashes to a different cache key between sessions, and
the backend dutifully re-downloads and re-caches the same bytes under
the new hash. On a collection that's mostly Plex (the user's 67-item
case), that means every open re-pulls 67 images over the network —
which is indistinguishable from "caching isn't working".

Normalize the cache key on both sides, keeping the original URL for
the actual authenticated fetch:

  * src-tauri: cache_key_for() strips X-Plex-Token and
    X-Plex-Client-Identifier before hashing. The `url` passed to
    reqwest is still the original with token, so the GET authenticates
    correctly; only the on-disk cache key is stable across sessions.
  * frontend: same normalization in imageCacheService.cacheKeyFor()
    covers the in-memory resolvedUrls map, pendingRequests dedup,
    getResolvedIfCached sync hit, and preloadImages "skip if already
    cached" short-circuit.

Non-Plex URLs are untouched — no '?' means no normalization work.
Qobuz's static.qobuz.com CDN URLs don't carry session params, so they
already cache correctly and this is a no-op for them.

cc
This commit is contained in:
vicrodh committed 2026-04-21 14:24:51 -06:00
1 parent c5db3895a0
commit 1ded75b35e
2 files changed
+66 -14

No files matched your search

+32 -3
View File
@@ -41,12 +41,40 @@ async fn download_image_to_temp(url: &str) -> Result<String, String> {
Ok(format!("file://{}", tmp_path.display()))
}
/// Strip query params that rotate across sessions but don't change the
/// underlying resource. The canonical case is Plex's X-Plex-Token: it's
/// regenerated per token refresh, so the same /library/metadata/…/thumb
/// asset keeps hashing to a different key across restarts — meaning
/// every re-open of the same collection re-downloads and re-caches the
/// same image bytes. Normalizing the cache key (not the download URL)
/// keeps the fetch authenticated while letting repeat lookups hit disk.
fn cache_key_for(url: &str) -> String {
let Some(q_idx) = url.find('?') else {
return url.to_string();
};
let (base, query_with_qmark) = url.split_at(q_idx);
let query = &query_with_qmark[1..]; // skip '?'
let kept: Vec<&str> = query
.split('&')
.filter(|p| {
!p.starts_with("X-Plex-Token=")
&& !p.starts_with("X-Plex-Client-Identifier=")
})
.collect();
if kept.is_empty() {
base.to_string()
} else {
format!("{}?{}", base, kept.join("&"))
}
}
#[tauri::command]
pub async fn v2_get_cached_image(
url: String,
cache_state: State<'_, crate::image_cache::ImageCacheState>,
settings_state: State<'_, crate::config::ImageCacheSettingsState>,
) -> Result<String, String> {
let cache_key = cache_key_for(&url);
// Check if caching is enabled
let settings = {
let lock = settings_state
@@ -65,14 +93,15 @@ pub async fn v2_get_cached_image(
return download_image_to_temp(&url).await;
}
// Check cache first
// Check cache first (keyed by normalized URL so Plex token rotation
// doesn't force a miss on re-opens).
{
let lock = cache_state
.service
.lock()
.map_err(|e| format!("Cache lock error: {}", e))?;
if let Some(service) = lock.as_ref() {
if let Some(path) = service.get(&url) {
if let Some(path) = service.get(&cache_key) {
return Ok(format!("file://{}", path.display()));
}
}
@@ -107,7 +136,7 @@ pub async fn v2_get_cached_image(
.lock()
.map_err(|e| format!("Cache lock error: {}", e))?;
if let Some(service) = lock.as_ref() {
let path = service.store(&url, &bytes)?;
let path = service.store(&cache_key, &bytes)?;
let _ = service.evict(max_bytes);
Some(format!("file://{}", path.display()))
} else {
+34 -11
View File
@@ -9,12 +9,30 @@
import { invoke } from '@tauri-apps/api/core';
import { convertFileSrc } from '@tauri-apps/api/core';
// In-memory URL map: original URL -> resolved URL (cached path or original)
// In-memory URL map: normalized URL -> resolved URL (cached path or original)
const resolvedUrls = new Map<string, string>();
// Track pending requests to avoid duplicate downloads
const pendingRequests = new Map<string, Promise<string>>();
/**
* Strip query params that rotate across sessions but point at the
* same underlying image. Keeps Plex thumbnails from missing the cache
* whenever the token refreshes (which happens on every session
* restore) — same logic lives in the Rust command so the on-disk
* cache stays consistent too.
*/
function cacheKeyFor(url: string): string {
const q = url.indexOf('?');
if (q < 0) return url;
const base = url.slice(0, q);
const query = url.slice(q + 1);
const kept = query
.split('&')
.filter((p) => !p.startsWith('X-Plex-Token=') && !p.startsWith('X-Plex-Client-Identifier='));
return kept.length === 0 ? base : `${base}?${kept.join('&')}`;
}
// Whether the backend cache is enabled (loaded once, updated on settings change)
let cacheEnabled: boolean | null = null;
@@ -27,16 +45,21 @@ export async function getCachedImageUrl(url: string): Promise<string> {
return url;
}
const key = cacheKeyFor(url);
// Check in-memory cache first
const cached = resolvedUrls.get(url);
const cached = resolvedUrls.get(key);
if (cached) return cached;
// Check if there's already a pending request for this URL
const pending = pendingRequests.get(url);
const pending = pendingRequests.get(key);
if (pending !== undefined) return pending;
const promise = (async () => {
try {
// Send the ORIGINAL url (with token) so the backend can
// authenticate the fetch; the backend uses its own normalized
// key for the disk cache.
const result = await invoke<string>('v2_get_cached_image', { url });
// Convert file:// paths to Tauri asset URLs for WebView access
let resolved: string;
@@ -46,20 +69,20 @@ export async function getCachedImageUrl(url: string): Promise<string> {
} else {
resolved = result;
}
resolvedUrls.set(url, resolved);
resolvedUrls.set(key, resolved);
return resolved;
} catch {
// Backend failed to proxy — use original URL as last resort.
// This may fail on AppImage distros with broken GnuTLS but
// is better than showing nothing on distros where WebKit works.
resolvedUrls.set(url, url);
resolvedUrls.set(key, url);
return url;
} finally {
pendingRequests.delete(url);
pendingRequests.delete(key);
}
})();
pendingRequests.set(url, promise);
pendingRequests.set(key, promise);
return promise;
}
@@ -78,7 +101,7 @@ export async function getCachedImageUrl(url: string): Promise<string> {
export function getResolvedIfCached(url: string): string | undefined {
if (!url) return undefined;
if (url.startsWith('file://') || url.startsWith('asset://')) return url;
return resolvedUrls.get(url);
return resolvedUrls.get(cacheKeyFor(url));
}
/**
@@ -94,8 +117,8 @@ export function clearResolvedUrls(): void {
*/
export function preloadImages(urls: string[]): void {
for (const url of urls) {
if (url && !resolvedUrls.has(url) && !url.startsWith('file://') && !url.startsWith('asset://')) {
getCachedImageUrl(url).catch(() => {});
}
if (!url || url.startsWith('file://') || url.startsWith('asset://')) continue;
if (resolvedUrls.has(cacheKeyFor(url))) continue;
getCachedImageUrl(url).catch(() => {});
}
}