Skip to main content

mimick/library/
thumbnail_cache.rs

1//! Two-tier (memory + disk) thumbnail cache for the library grid.
2//!
3//! Remote thumbnails are fetched via the API client and decoded into
4//! GDK textures. An LRU memory cache with a configurable byte budget
5//! keeps hot textures in RAM, while a persistent disk cache avoids
6//! redundant network requests across sessions. In-flight deduplication
7//! ensures concurrent requests for the same asset coalesce into one load.
8
9use parking_lot::Mutex;
10use std::collections::HashMap;
11use std::hash::{Hash, Hasher};
12use std::num::NonZeroUsize;
13use std::path::PathBuf;
14use std::sync::Arc;
15
16use gdk4::Texture;
17use gdk4::prelude::TextureExt;
18use glib::Bytes;
19use lru::LruCache;
20use tokio::sync::{Semaphore, watch};
21
22use crate::api_client::{ImmichApiClient, ThumbnailSize};
23
24const FALLBACK_CPUS: usize = 4;
25const SMALL_LOAD_MAX: usize = 16;
26const LARGE_LOAD_MAX: usize = 6;
27
28type InflightSlot = Option<Result<Texture, String>>;
29type InflightRx = watch::Receiver<InflightSlot>;
30type InflightMap = Arc<Mutex<HashMap<String, InflightRx>>>;
31
32/// Guard that cleans up in-flight channel entries on drop.
33struct InflightGuard {
34    inflight: InflightMap,
35    key: String,
36    tx: watch::Sender<InflightSlot>,
37}
38
39impl InflightGuard {
40    /// Publish the load result to all listening subscribers.
41    fn publish(&self, result: Result<Texture, String>) {
42        let _ = self.tx.send(Some(result));
43    }
44}
45
46impl Drop for InflightGuard {
47    fn drop(&mut self) {
48        let mut map = self.inflight.lock();
49        map.remove(&self.key);
50    }
51}
52
53/// Helper to await an in-flight thumbnail load result from a subscriber channel.
54async fn await_inflight(mut rx: InflightRx) -> Result<Texture, String> {
55    if let Some(result) = rx.borrow_and_update().clone() {
56        return result;
57    }
58    match rx.changed().await {
59        Ok(()) => rx
60            .borrow()
61            .clone()
62            .unwrap_or_else(|| Err("Thumbnail load cancelled".to_string())),
63        Err(_) => Err("Thumbnail load cancelled".to_string()),
64    }
65}
66
67/// LRU Cache that limits memory usage based on estimated byte size.
68struct SizedLruCache {
69    inner: LruCache<String, Texture>,
70    current_bytes: usize,
71    max_bytes: usize,
72    evictions_since_log: usize,
73}
74
75impl SizedLruCache {
76    /// Construct a new LRU cache with a specific byte limit.
77    fn new(max_bytes: usize) -> Self {
78        let approx_per_entry = 256 * 1024;
79        let count_cap = (max_bytes / approx_per_entry).max(8);
80        Self {
81            inner: LruCache::new(NonZeroUsize::new(count_cap).unwrap()),
82            current_bytes: 0,
83            max_bytes,
84            evictions_since_log: 0,
85        }
86    }
87
88    fn stats(&mut self) -> (usize, usize, usize, usize) {
89        let evictions = self.evictions_since_log;
90        self.evictions_since_log = 0;
91        (
92            self.inner.len(),
93            self.current_bytes,
94            self.max_bytes,
95            evictions,
96        )
97    }
98
99    /// Retrieve a texture from the cache if present, updating LRU recency.
100    fn get(&mut self, key: &str) -> Option<Texture> {
101        self.inner.get(key).cloned()
102    }
103
104    fn peek(&self, key: &str) -> Option<Texture> {
105        self.inner.peek(key).cloned()
106    }
107
108    /// Insert a texture into the cache, evicting entries to respect the byte budget.
109    fn insert(&mut self, key: String, texture: Texture) {
110        let added = estimate_texture_bytes(&texture);
111        if let Some(previous) = self.inner.put(key, texture) {
112            self.current_bytes = self
113                .current_bytes
114                .saturating_sub(estimate_texture_bytes(&previous));
115        }
116        self.current_bytes = self.current_bytes.saturating_add(added);
117        while self.current_bytes > self.max_bytes {
118            if let Some((_key, removed)) = self.inner.pop_lru() {
119                self.current_bytes = self
120                    .current_bytes
121                    .saturating_sub(estimate_texture_bytes(&removed));
122                self.evictions_since_log += 1;
123            } else {
124                break;
125            }
126        }
127    }
128
129    /// Clear all cached items and reset the current byte counter.
130    fn clear(&mut self) {
131        self.inner.clear();
132        self.current_bytes = 0;
133    }
134}
135
136/// A combined memory (LRU) and disk-based caching manager for remote and local assets.
137pub struct ThumbnailCache {
138    api_client: std::sync::Arc<ImmichApiClient>,
139    memory: Mutex<SizedLruCache>,
140    cache_dir: PathBuf,
141    small_semaphore: Arc<Semaphore>,
142    large_semaphore: Arc<Semaphore>,
143    inflight: InflightMap,
144}
145
146impl ThumbnailCache {
147    /// Floor for the auto-sized RAM budget, so very low-memory systems still
148    /// hold a working set of decoded thumbnails.
149    const AUTO_MIN_BYTES: usize = 500 * 1024 * 1024;
150    const AUTO_MAX_BYTES: usize = 3 * 1024 * 1024 * 1024;
151    const AUTO_FRACTION_PERCENT: usize = 20;
152
153    /// Construct a new thumbnail cache manager. Disk pruning is handled
154    /// centrally by `cache_manager` at startup, not here.
155    pub fn new(api_client: std::sync::Arc<ImmichApiClient>) -> Self {
156        let cache_dir = crate::profile::cache_dir()
157            .unwrap_or_else(|| PathBuf::from("/tmp").join(crate::profile::dir_segment()))
158            .join("thumbnails");
159
160        let max_bytes = auto_memory_budget();
161        let cpus = std::thread::available_parallelism()
162            .map(|n| n.get())
163            .unwrap_or(FALLBACK_CPUS);
164        let small = SMALL_LOAD_MAX.min(cpus.saturating_mul(2)).max(2);
165        let large = LARGE_LOAD_MAX.min(cpus).max(2);
166        log::info!(
167            "ThumbnailCache memory budget: {} MB (auto), concurrency small={} large={}",
168            max_bytes / (1024 * 1024),
169            small,
170            large,
171        );
172
173        Self {
174            api_client,
175            memory: Mutex::new(SizedLruCache::new(max_bytes)),
176            cache_dir,
177            small_semaphore: Arc::new(Semaphore::new(small)),
178            large_semaphore: Arc::new(Semaphore::new(large)),
179            inflight: Arc::new(Mutex::new(HashMap::new())),
180        }
181    }
182
183    #[cfg(test)]
184    fn new_for_test(
185        api_client: std::sync::Arc<ImmichApiClient>,
186        cache_dir: PathBuf,
187        max_bytes: usize,
188    ) -> Self {
189        Self {
190            api_client,
191            memory: Mutex::new(SizedLruCache::new(max_bytes)),
192            cache_dir,
193            small_semaphore: Arc::new(Semaphore::new(SMALL_LOAD_MAX)),
194            large_semaphore: Arc::new(Semaphore::new(LARGE_LOAD_MAX)),
195            inflight: Arc::new(Mutex::new(HashMap::new())),
196        }
197    }
198
199    /// Retrieve a thumbnail texture from memory if present, updating LRU recency.
200    pub fn get_cached(&self, asset_id: &str, size: ThumbnailSize) -> Option<Texture> {
201        let key = cache_key(asset_id, size);
202        self.memory.lock().get(&key)
203    }
204
205    /// Same as `get_cached` but does not touch LRU order. Use for read-only
206    /// per-frame paint lookups.
207    pub fn peek_cached(&self, asset_id: &str, size: ThumbnailSize) -> Option<Texture> {
208        let key = cache_key(asset_id, size);
209        self.memory.lock().peek(&key)
210    }
211
212    /// (entries, current_bytes, max_bytes, evictions_since_last_call).
213    pub fn cache_stats(&self) -> (usize, usize, usize, usize) {
214        self.memory.lock().stats()
215    }
216
217    fn semaphore_for(&self, size: ThumbnailSize) -> Arc<Semaphore> {
218        match size {
219            ThumbnailSize::Thumbnail => self.small_semaphore.clone(),
220            ThumbnailSize::Preview | ThumbnailSize::Fullsize => self.large_semaphore.clone(),
221        }
222    }
223
224    /// Asynchronously fetch a remote thumbnail with default non-cancellable execution.
225    pub async fn load_thumbnail(
226        &self,
227        asset_id: &str,
228        size: ThumbnailSize,
229    ) -> Result<Texture, String> {
230        self.load_thumbnail_cancellable(asset_id, size, || false)
231            .await
232    }
233
234    /// Asynchronously fetch a remote thumbnail with cancellable hook support.
235    pub async fn load_thumbnail_cancellable<F>(
236        &self,
237        asset_id: &str,
238        size: ThumbnailSize,
239        is_cancelled: F,
240    ) -> Result<Texture, String>
241    where
242        F: Fn() -> bool,
243    {
244        if let Some(texture) = self.get_cached(asset_id, size) {
245            return Ok(texture);
246        }
247
248        let key = cache_key(asset_id, size);
249        let guard = match self.enter_inflight(&key) {
250            Ok(guard) => guard,
251            Err(rx) => return await_inflight(rx).await,
252        };
253        let result = self
254            .fetch_remote_thumbnail(asset_id, size, &key, &is_cancelled)
255            .await;
256        guard.publish(result.clone());
257        result
258    }
259
260    /// Internal helper that performs the actual network request and disk backup logic.
261    async fn fetch_remote_thumbnail(
262        &self,
263        asset_id: &str,
264        size: ThumbnailSize,
265        key: &str,
266        is_cancelled: &dyn Fn() -> bool,
267    ) -> Result<Texture, String> {
268        if is_cancelled() {
269            return Err("cancelled".to_string());
270        }
271        let _permit = self
272            .semaphore_for(size)
273            .acquire_owned()
274            .await
275            .map_err(|err| err.to_string())?;
276
277        if is_cancelled() {
278            return Err("cancelled".to_string());
279        }
280        if let Some(texture) = self.get_cached(asset_id, size) {
281            return Ok(texture);
282        }
283
284        let cache_file = self.cache_file(asset_id, size);
285        let cache_file_for_read = cache_file.clone();
286        let from_disk = tokio::task::spawn_blocking(move || -> Option<Vec<u8>> {
287            std::fs::read(&cache_file_for_read).ok()
288        })
289        .await
290        .map_err(|err| err.to_string())?;
291
292        if let Some(bytes) = from_disk {
293            let texture = decode_to_scaled_texture(bytes, target_dim_for_bucket(size))
294                .await
295                .map_err(|err| err.to_string())?;
296            self.memory.lock().insert(key.to_string(), texture.clone());
297            return Ok(texture);
298        }
299
300        if is_cancelled() {
301            return Err("cancelled".to_string());
302        }
303        let bytes = self.api_client.fetch_thumbnail(asset_id, size).await?;
304        let cache_dir = self.cache_dir.clone();
305        let cache_file_for_write = cache_file.clone();
306        let bytes_for_write = bytes.clone();
307        let _ = tokio::task::spawn_blocking(move || {
308            let _ = std::fs::create_dir_all(&cache_dir);
309            let _ = std::fs::write(&cache_file_for_write, &bytes_for_write);
310        })
311        .await;
312        let texture = decode_to_scaled_texture(bytes, target_dim_for_bucket(size))
313            .await
314            .map_err(|err| err.to_string())?;
315        self.memory.lock().insert(key.to_string(), texture.clone());
316        Ok(texture)
317    }
318
319    /// Asynchronously generate a local file thumbnail with cancellable hook support.
320    pub async fn load_local_thumbnail_cancellable<F>(
321        &self,
322        asset_id: &str,
323        path: &std::path::Path,
324        is_cancelled: F,
325    ) -> Result<Texture, String>
326    where
327        F: Fn() -> bool,
328    {
329        let key = cache_key(asset_id, ThumbnailSize::Thumbnail);
330        if let Some(texture) = self.memory.lock().get(&key) {
331            return Ok(texture);
332        }
333
334        let guard = match self.enter_inflight(&key) {
335            Ok(guard) => guard,
336            Err(rx) => return await_inflight(rx).await,
337        };
338        let result = self
339            .fetch_local_thumbnail(asset_id, path, &key, &is_cancelled)
340            .await;
341        guard.publish(result.clone());
342        result
343    }
344
345    /// Internal helper that scales, saves, and loads a local folder asset thumbnail.
346    async fn fetch_local_thumbnail(
347        &self,
348        asset_id: &str,
349        path: &std::path::Path,
350        key: &str,
351        is_cancelled: &dyn Fn() -> bool,
352    ) -> Result<Texture, String> {
353        if is_cancelled() {
354            return Err("cancelled".to_string());
355        }
356        let _permit = self
357            .semaphore_for(ThumbnailSize::Thumbnail)
358            .acquire_owned()
359            .await
360            .map_err(|err| err.to_string())?;
361
362        if is_cancelled() {
363            return Err("cancelled".to_string());
364        }
365        if let Some(texture) = self.memory.lock().get(key) {
366            return Ok(texture);
367        }
368
369        let cache_file = self.cache_file_local(asset_id);
370        let cache_file_for_read = cache_file.clone();
371        let from_disk = tokio::task::spawn_blocking(move || -> Option<Texture> {
372            if !cache_file_for_read.exists() {
373                return None;
374            }
375            Texture::from_filename(&cache_file_for_read).ok()
376        })
377        .await
378        .map_err(|err| err.to_string())?;
379
380        if let Some(texture) = from_disk {
381            self.memory.lock().insert(key.to_string(), texture.clone());
382            return Ok(texture);
383        }
384
385        let decode_started = std::time::Instant::now();
386        let path = path.to_path_buf();
387        let log_path = path.clone();
388        let cache_dir = self.cache_dir.clone();
389        let texture = tokio::task::spawn_blocking(move || -> Result<Texture, String> {
390            let pixbuf = decode_local_pixbuf(&path)?;
391            std::fs::create_dir_all(&cache_dir).map_err(|err| err.to_string())?;
392            let encoded = pixbuf_png_bytes(&pixbuf)?;
393            std::fs::write(&cache_file, encoded).map_err(|err| err.to_string())?;
394            let format = if pixbuf.has_alpha() {
395                gdk4::MemoryFormat::R8g8b8a8
396            } else {
397                gdk4::MemoryFormat::R8g8b8
398            };
399            let bytes = pixbuf.read_pixel_bytes();
400            let mem_tex = gdk4::MemoryTexture::new(
401                pixbuf.width(),
402                pixbuf.height(),
403                format,
404                &bytes,
405                pixbuf.rowstride() as usize,
406            );
407            use gtk::prelude::Cast;
408            Ok(mem_tex.upcast::<Texture>())
409        })
410        .await
411        .map_err(|err| err.to_string())??;
412        log::debug!(
413            "Local thumbnail decoded fresh for {} in {}ms ({}x{})",
414            log_path.display(),
415            decode_started.elapsed().as_millis(),
416            texture.width(),
417            texture.height(),
418        );
419        self.memory.lock().insert(key.to_string(), texture.clone());
420        Ok(texture)
421    }
422
423    /// Purge all memory caches and completely delete the disk cache directory.
424    pub fn clear(&self) -> Result<(), String> {
425        self.memory.lock().clear();
426        if self.cache_dir.exists() {
427            std::fs::remove_dir_all(&self.cache_dir).map_err(|err| err.to_string())?;
428        }
429        Ok(())
430    }
431
432    /// Drop every cached texture from RAM without touching the disk cache.
433    /// Invoked when the library window closes so the texture memory is
434    /// released until the user reopens it.
435    pub fn clear_memory(&self) {
436        self.memory.lock().clear();
437    }
438
439    /// Return the physical disk cache path for a remote asset thumbnail.
440    fn cache_file(&self, asset_id: &str, size: ThumbnailSize) -> PathBuf {
441        self.cache_dir.join(cache_key(asset_id, size))
442    }
443
444    /// Return the physical disk cache path for a local folder asset thumbnail.
445    fn cache_file_local(&self, asset_id: &str) -> PathBuf {
446        self.cache_dir.join(local_cache_key(asset_id))
447    }
448
449    /// Register or join an ongoing in-flight loading request for a key.
450    fn enter_inflight(&self, key: &str) -> Result<InflightGuard, InflightRx> {
451        let mut map = self.inflight.lock();
452        if let Some(rx) = map.get(key) {
453            return Err(rx.clone());
454        }
455        let (tx, rx) = watch::channel::<InflightSlot>(None);
456        map.insert(key.to_string(), rx);
457        Ok(InflightGuard {
458            inflight: self.inflight.clone(),
459            key: key.to_string(),
460            tx,
461        })
462    }
463}
464
465/// Construct a cache key string for remote assets.
466fn cache_key(asset_id: &str, size: ThumbnailSize) -> String {
467    match size {
468        ThumbnailSize::Thumbnail => format!("thumbnail:{}", asset_id),
469        ThumbnailSize::Preview => format!("preview:{}", asset_id),
470        ThumbnailSize::Fullsize => format!("fullsize:{}", asset_id),
471    }
472}
473
474/// Construct a cache key string for local assets using hashed paths.
475fn local_cache_key(asset_id: &str) -> String {
476    let mut hasher = std::collections::hash_map::DefaultHasher::new();
477    asset_id.hash(&mut hasher);
478    format!("local-thumbnail-v2:{:x}", hasher.finish())
479}
480
481/// Estimate memory byte size occupied by a texture.
482fn estimate_texture_bytes(texture: &Texture) -> usize {
483    texture.width().max(1) as usize * texture.height().max(1) as usize * 4
484}
485
486/// Pick a thumbnail-cache RAM budget from `MemTotal` in `/proc/meminfo`,
487/// clamped to `[AUTO_MIN_BYTES, AUTO_MAX_BYTES]`. Falls back to the floor
488/// if the file can't be read.
489fn auto_memory_budget() -> usize {
490    let total = read_meminfo_total_bytes().unwrap_or(0);
491    if total == 0 {
492        return ThumbnailCache::AUTO_MIN_BYTES;
493    }
494    let fraction = total / 100 * ThumbnailCache::AUTO_FRACTION_PERCENT;
495    fraction.clamp(
496        ThumbnailCache::AUTO_MIN_BYTES,
497        ThumbnailCache::AUTO_MAX_BYTES,
498    )
499}
500
501fn read_meminfo_total_bytes() -> Option<usize> {
502    let text = std::fs::read_to_string("/proc/meminfo").ok()?;
503    for line in text.lines() {
504        if let Some(rest) = line.strip_prefix("MemTotal:") {
505            let kb: usize = rest.split_whitespace().next()?.parse().ok()?;
506            return Some(kb.saturating_mul(1024));
507        }
508    }
509    None
510}
511
512/// Decode a local file to a 256x256 thumbnail pixbuf, routing through video,
513/// RAW, or standard image pipelines as appropriate.
514fn decode_local_pixbuf(path: &std::path::Path) -> Result<gtk::gdk_pixbuf::Pixbuf, String> {
515    if crate::media_kinds::is_video_path(path) {
516        return ffmpeg_extract_thumbnail(path);
517    }
518    if crate::media_kinds::is_raw_path(path) {
519        return custom_decode_to_thumbnail(path).or_else(|_| {
520            gtk::gdk_pixbuf::Pixbuf::from_file_at_scale(path, 256, 256, true)
521                .map(|raw| raw.apply_embedded_orientation().unwrap_or(raw))
522                .map_err(|e| e.to_string())
523        });
524    }
525    match gtk::gdk_pixbuf::Pixbuf::from_file_at_scale(path, 256, 256, true) {
526        Ok(raw) => Ok(raw.apply_embedded_orientation().unwrap_or(raw)),
527        Err(err) => {
528            log::debug!(
529                "gdk_pixbuf direct load failed for {}: {}; attempting custom decoder",
530                path.display(),
531                err
532            );
533            custom_decode_to_thumbnail(path)
534        }
535    }
536}
537
538/// Extract a thumbnail frame from a video file using `ffmpeg`.
539fn ffmpeg_extract_thumbnail(path: &std::path::Path) -> Result<gtk::gdk_pixbuf::Pixbuf, String> {
540    let tmp_file = tempfile::Builder::new()
541        .prefix("mimick_vthumb_")
542        .suffix(".png")
543        .tempfile()
544        .map_err(|e| format!("failed to create temp file: {}", e))?;
545    let tmp_path = tmp_file.path().to_path_buf();
546
547    // Try 1s seek first (avoids black intro), fall back to 0s.
548    if !run_ffmpeg_frame(path, "1", &tmp_path) {
549        run_ffmpeg_frame(path, "0", &tmp_path);
550    }
551
552    if !tmp_path.exists() {
553        return Err(format!(
554            "ffmpeg failed to extract frame from {}",
555            path.display()
556        ));
557    }
558    let pixbuf = gtk::gdk_pixbuf::Pixbuf::from_file(&tmp_path).map_err(|e| e.to_string())?;
559    scale_pixbuf_to_thumbnail(pixbuf)
560}
561
562/// Run ffmpeg to extract a single frame at the given seek position.
563fn run_ffmpeg_frame(input: &std::path::Path, seek_sec: &str, output: &std::path::Path) -> bool {
564    use std::process::Command;
565    Command::new("ffmpeg")
566        .args(["-y", "-ss", seek_sec, "-i"])
567        .arg(input)
568        .args([
569            "-frames:v",
570            "1",
571            "-vf",
572            "scale=256:-1",
573            "-loglevel",
574            "error",
575        ])
576        .arg(output)
577        .output()
578        .map(|o| o.status.success() && output.exists())
579        .unwrap_or(false)
580}
581
582/// Scale a pixbuf down to fit within 256x256 if needed.
583fn scale_pixbuf_to_thumbnail(
584    pixbuf: gtk::gdk_pixbuf::Pixbuf,
585) -> Result<gtk::gdk_pixbuf::Pixbuf, String> {
586    let (w, h) = (pixbuf.width(), pixbuf.height());
587    if w <= 256 && h <= 256 {
588        return Ok(pixbuf);
589    }
590    let scale = (256.0 / w as f64).min(256.0 / h as f64);
591    let tw = ((w as f64 * scale).round() as i32).max(1);
592    let th = ((h as f64 * scale).round() as i32).max(1);
593    pixbuf
594        .scale_simple(tw, th, gtk::gdk_pixbuf::InterpType::Bilinear)
595        .ok_or_else(|| "Failed to scale video thumbnail".to_string())
596}
597
598/// Decodes an image file through the application's custom texture pipeline and
599/// scale the result down to a 256x256 thumbnail pixbuf.
600///
601/// RAW paths use the thumbnail-specific decoder (embedded JPEG first, full
602/// demosaic only as last resort) so the global "Full RAW Decoding" toggle
603/// -- which is meant for lightbox quality -- never penalises grid loading.
604fn custom_decode_to_thumbnail(path: &std::path::Path) -> Result<gtk::gdk_pixbuf::Pixbuf, String> {
605    let full_texture = if crate::media_kinds::is_raw_path(path) {
606        super::decode_raw_thumbnail_texture(path)
607    } else {
608        super::load_texture_blocking(path)
609    }
610    .ok_or_else(|| format!("No decoder succeeded for {}", path.display()))?;
611    let mut downloader = gdk4::TextureDownloader::new(&full_texture);
612    downloader.set_format(gdk4::MemoryFormat::R8g8b8a8);
613    let (bytes, stride) = downloader.download_bytes();
614    let full_pixbuf = gtk::gdk_pixbuf::Pixbuf::from_mut_slice(
615        bytes.to_vec(),
616        gtk::gdk_pixbuf::Colorspace::Rgb,
617        true,
618        8,
619        full_texture.width(),
620        full_texture.height(),
621        stride as i32,
622    );
623    let w = full_pixbuf.width();
624    let h = full_pixbuf.height();
625    let scale = (256.0 / w as f64).min(256.0 / h as f64).min(1.0);
626    let tw = ((w as f64 * scale).round() as i32).max(1);
627    let th = ((h as f64 * scale).round() as i32).max(1);
628    full_pixbuf
629        .scale_simple(tw, th, gtk::gdk_pixbuf::InterpType::Bilinear)
630        .ok_or_else(|| "Failed to scale pixbuf".to_string())
631}
632
633fn pixbuf_png_bytes(pixbuf: &gtk::gdk_pixbuf::Pixbuf) -> Result<Vec<u8>, String> {
634    let width = pixbuf.width().max(1) as usize;
635    let height = pixbuf.height().max(1) as usize;
636    let channels = if pixbuf.has_alpha() { 4 } else { 3 };
637    let rowstride = pixbuf.rowstride() as usize;
638    let bytes = pixbuf.read_pixel_bytes();
639
640    let packed = pack_pixel_rows(bytes.as_ref(), width, height, channels, rowstride)?;
641    encode_png_bytes(&packed, width as u32, height as u32, pixbuf.has_alpha())
642}
643
644fn pack_pixel_rows(
645    src: &[u8],
646    width: usize,
647    height: usize,
648    channels: usize,
649    rowstride: usize,
650) -> Result<Vec<u8>, String> {
651    let mut packed = Vec::with_capacity(width * height * channels);
652    for row in 0..height {
653        let start = row
654            .checked_mul(rowstride)
655            .ok_or_else(|| "pixbuf row offset overflow".to_string())?;
656        let end = start
657            .checked_add(width * channels)
658            .ok_or_else(|| "pixbuf row end overflow".to_string())?;
659        let row_bytes = src
660            .get(start..end)
661            .ok_or_else(|| "pixbuf row outside buffer".to_string())?;
662        packed.extend_from_slice(row_bytes);
663    }
664    Ok(packed)
665}
666
667fn encode_png_bytes(
668    packed: &[u8],
669    width: u32,
670    height: u32,
671    has_alpha: bool,
672) -> Result<Vec<u8>, String> {
673    use image::ImageEncoder;
674    let color = if has_alpha {
675        image::ColorType::Rgba8
676    } else {
677        image::ColorType::Rgb8
678    };
679    let mut encoded = Vec::new();
680    image::codecs::png::PngEncoder::new(&mut encoded)
681        .write_image(packed, width, height, color.into())
682        .map_err(|err| err.to_string())?;
683    Ok(encoded)
684}
685
686fn target_dim_for_bucket(size: ThumbnailSize) -> i32 {
687    match size {
688        ThumbnailSize::Thumbnail => 256,
689        ThumbnailSize::Preview => 1440,
690        ThumbnailSize::Fullsize => 2560,
691    }
692}
693
694/// Asynchronously decode image bytes into a scaled texture.
695async fn decode_to_scaled_texture(bytes: Vec<u8>, max_dim: i32) -> Result<Texture, String> {
696    tokio::task::spawn_blocking(move || -> Result<Texture, String> {
697        let stream = gtk::gio::MemoryInputStream::from_bytes(&Bytes::from_owned(bytes));
698        let pixbuf = gtk::gdk_pixbuf::Pixbuf::from_stream_at_scale(
699            &stream,
700            max_dim,
701            max_dim,
702            true,
703            gtk::gio::Cancellable::NONE,
704        )
705        .map_err(|err| err.to_string())?;
706        let format = if pixbuf.has_alpha() {
707            gdk4::MemoryFormat::R8g8b8a8
708        } else {
709            gdk4::MemoryFormat::R8g8b8
710        };
711        let bytes = pixbuf.read_pixel_bytes();
712        let mem_tex = gdk4::MemoryTexture::new(
713            pixbuf.width(),
714            pixbuf.height(),
715            format,
716            &bytes,
717            pixbuf.rowstride() as usize,
718        );
719        use gtk::prelude::Cast;
720        Ok(mem_tex.upcast::<Texture>())
721    })
722    .await
723    .map_err(|err| err.to_string())?
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use crate::api_client::ImmichApiClient;
730    use tempfile::tempdir;
731
732    // 1x1 transparent PNG
733    const PNG_BYTES: &[u8] = &[
734        137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6,
735        0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 240,
736        31, 0, 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
737    ];
738
739    fn cache(max_bytes: usize) -> ThumbnailCache {
740        let dir = tempdir().unwrap();
741        let cache_dir = dir.keep().join("thumbs");
742        ThumbnailCache::new_for_test(
743            std::sync::Arc::new(ImmichApiClient::new(
744                String::new(),
745                String::new(),
746                String::new(),
747            )),
748            cache_dir,
749            max_bytes,
750        )
751    }
752
753    fn texture_from_png() -> Texture {
754        Texture::from_bytes(&Bytes::from(PNG_BYTES)).unwrap()
755    }
756
757    #[test]
758    fn test_memory_hit_after_insert() {
759        let cache = cache(1024);
760        cache
761            .memory
762            .lock()
763            .insert("thumbnail:1".into(), texture_from_png());
764
765        assert!(cache.get_cached("1", ThumbnailSize::Thumbnail).is_some());
766    }
767
768    #[test]
769    fn test_get_cached_does_not_touch_disk() {
770        let cache = cache(1024);
771        std::fs::create_dir_all(&cache.cache_dir).unwrap();
772        std::fs::write(cache.cache_file("2", ThumbnailSize::Thumbnail), PNG_BYTES).unwrap();
773
774        assert!(cache.get_cached("2", ThumbnailSize::Thumbnail).is_none());
775    }
776
777    #[test]
778    fn test_eviction_after_byte_budget_overflow() {
779        let cache = cache(3);
780        cache
781            .memory
782            .lock()
783            .insert("thumbnail:1".into(), texture_from_png());
784        cache
785            .memory
786            .lock()
787            .insert("thumbnail:2".into(), texture_from_png());
788
789        assert!(cache.memory.lock().inner.len() <= 1);
790    }
791
792    #[test]
793    fn test_clear_removes_memory_and_disk() {
794        let cache = cache(1024);
795        std::fs::create_dir_all(&cache.cache_dir).unwrap();
796        std::fs::write(cache.cache_file("3", ThumbnailSize::Thumbnail), PNG_BYTES).unwrap();
797        cache
798            .memory
799            .lock()
800            .insert("thumbnail:3".into(), texture_from_png());
801
802        cache.clear().unwrap();
803
804        assert!(cache.memory.lock().inner.is_empty());
805        assert!(!cache.cache_dir.exists());
806    }
807
808    #[tokio::test]
809    async fn test_load_local_raw_thumbnail() {
810        let cache = cache(1024 * 1024 * 10);
811        let fixture_path =
812            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sample.dng");
813
814        let result = cache
815            .load_local_thumbnail_cancellable("local_dng_test", &fixture_path, || false)
816            .await;
817
818        // Synthetic DNG may not decode via libraw alone; accept either outcome.
819        match result {
820            Ok(texture) => {
821                assert!(texture.width() > 0);
822                assert!(texture.height() > 0);
823                let cache_file = cache.cache_file_local("local_dng_test");
824                assert!(cache_file.exists(), "Cache file was not written to disk");
825            }
826            Err(_) => {
827                // Expected when libraw cannot decode the synthetic fixture.
828                let cache_file = cache.cache_file_local("local_dng_test");
829                assert!(
830                    !cache_file.exists(),
831                    "Cache file should not exist after failed decode"
832                );
833            }
834        }
835    }
836}