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:
2 files changed
+66
-14
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user