1use 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
32struct InflightGuard {
34 inflight: InflightMap,
35 key: String,
36 tx: watch::Sender<InflightSlot>,
37}
38
39impl InflightGuard {
40 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
53async 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
67struct SizedLruCache {
69 inner: LruCache<String, Texture>,
70 current_bytes: usize,
71 max_bytes: usize,
72 evictions_since_log: usize,
73}
74
75impl SizedLruCache {
76 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 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 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 fn clear(&mut self) {
131 self.inner.clear();
132 self.current_bytes = 0;
133 }
134}
135
136pub 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 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 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 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 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 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 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 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 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 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 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 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 pub fn clear_memory(&self) {
436 self.memory.lock().clear();
437 }
438
439 fn cache_file(&self, asset_id: &str, size: ThumbnailSize) -> PathBuf {
441 self.cache_dir.join(cache_key(asset_id, size))
442 }
443
444 fn cache_file_local(&self, asset_id: &str) -> PathBuf {
446 self.cache_dir.join(local_cache_key(asset_id))
447 }
448
449 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
465fn 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
474fn 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
481fn estimate_texture_bytes(texture: &Texture) -> usize {
483 texture.width().max(1) as usize * texture.height().max(1) as usize * 4
484}
485
486fn 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
512fn 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
538fn 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 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
562fn 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
582fn 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
598fn 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: >k::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
694async 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 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 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 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}