Skip to main content

oximedia_cache/
content_aware_cache.rs

1//! Media-content-aware caching.
2//!
3//! Standard eviction policies treat all entries equally.  For multimedia
4//! workloads, different content types have very different access patterns
5//! and cost/benefit trade-offs:
6//!
7//! * Manifests are tiny but highly re-fetched → very high priority, short TTL.
8//! * Video segments are large and often sequential → medium priority, long TTL.
9//! * Thumbnails are small and rarely re-fetched but cheap → very long TTL.
10//!
11//! [`ContentAwareCache`] layers this domain knowledge on top of
12//! [`LruCache`] so that eviction candidates are
13//! scored by a combined recency × priority × size-efficiency metric rather
14//! than pure LRU order.
15
16use std::time::{Duration, Instant};
17
18use crate::lru_cache::LruCache;
19
20// ── MediaContentType ──────────────────────────────────────────────────────────
21
22/// The media type of a cached entry.
23///
24/// The variant determines the default priority, TTL, and eviction score.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum MediaContentType {
27    /// A video segment (e.g. MPEG-DASH chunk or HLS `.ts` file).
28    VideoSegment {
29        /// Encoded bitrate in bits per second.
30        bitrate: u32,
31        /// Codec name (e.g. `"av1"`, `"vp9"`).
32        codec: String,
33    },
34    /// An audio-only segment.
35    AudioSegment {
36        /// Encoded bitrate in bits per second.
37        bitrate: u32,
38    },
39    /// A still image (e.g. JPEG or PNG frame).
40    Image {
41        /// Width in pixels.
42        width: u32,
43        /// Height in pixels.
44        height: u32,
45    },
46    /// A streaming manifest / playlist (e.g. HLS `.m3u8` or DASH `.mpd`).
47    Manifest,
48    /// A thumbnail preview image.
49    Thumbnail,
50    /// Lightweight metadata / sidecar (e.g. `.json` or `.xml` descriptor).
51    Metadata,
52}
53
54// ── ContentCachePriority ──────────────────────────────────────────────────────
55
56/// Numeric priority (higher = more important to keep in cache).
57///
58/// Derived from [`MediaContentType`] via [`ContentCachePriority::for_type`].
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
60pub struct ContentCachePriority(pub u8);
61
62impl ContentCachePriority {
63    /// Compute the priority for the given content type.
64    ///
65    /// | Content type                        | Priority |
66    /// |-------------------------------------|----------|
67    /// | Manifest                            | 10       |
68    /// | Thumbnail                           |  8       |
69    /// | VideoSegment (bitrate ≥ 4 Mbps)     |  7       |
70    /// | VideoSegment (bitrate < 4 Mbps)     |  6       |
71    /// | AudioSegment                        |  5       |
72    /// | Image                               |  4       |
73    /// | Metadata                            |  3       |
74    pub fn for_type(content_type: &MediaContentType) -> Self {
75        let p = match content_type {
76            MediaContentType::Manifest => 10,
77            MediaContentType::Thumbnail => 8,
78            MediaContentType::VideoSegment { bitrate, .. } => {
79                if *bitrate >= 4_000_000 {
80                    7
81                } else {
82                    6
83                }
84            }
85            MediaContentType::AudioSegment { .. } => 5,
86            MediaContentType::Image { .. } => 4,
87            MediaContentType::Metadata => 3,
88        };
89        Self(p)
90    }
91}
92
93// ── CacheEntry ────────────────────────────────────────────────────────────────
94
95/// A single entry held in [`ContentAwareCache`].
96#[derive(Debug, Clone)]
97pub struct CacheEntry {
98    /// The cache key.
99    pub key: String,
100    /// Raw payload bytes.
101    pub data: Vec<u8>,
102    /// Media content type (determines priority and TTL).
103    pub content_type: MediaContentType,
104    /// Wall-clock time at which this entry was first inserted.
105    pub inserted_at: Instant,
106    /// Wall-clock time of the most recent successful lookup.
107    pub last_accessed: Instant,
108    /// Total number of successful lookups since insertion.
109    pub access_count: u32,
110    /// `data.len()` cached to avoid recomputation.
111    pub size_bytes: usize,
112}
113
114impl CacheEntry {
115    /// Create a new entry.
116    fn new(key: String, data: Vec<u8>, content_type: MediaContentType) -> Self {
117        let size = data.len();
118        let now = Instant::now();
119        Self {
120            key,
121            data,
122            content_type,
123            inserted_at: now,
124            last_accessed: now,
125            access_count: 0,
126            size_bytes: size,
127        }
128    }
129
130    /// Compute the eviction score for this entry.
131    ///
132    /// A higher score means the entry is a *better* eviction candidate (i.e.
133    /// it should be evicted first).
134    ///
135    /// ```text
136    /// score = (1.0 - recency) × (1.0 / priority) × size_factor
137    /// ```
138    ///
139    /// Where:
140    /// * `recency  = e^(-age_secs / 60.0)` — exponential decay over 1 minute.
141    /// * `priority = ContentCachePriority::for_type(content_type).0` cast to f32.
142    /// * `size_factor = size_bytes / 1_048_576.0 + 1.0` — larger entries score
143    ///   higher (give bigger bang for the eviction buck).
144    pub fn score_for_eviction(&self) -> f32 {
145        let age_secs = self.last_accessed.elapsed().as_secs_f32();
146        let recency = (-age_secs / 60.0_f32).exp(); // 1.0 when just accessed, → 0 with age
147        let priority = ContentCachePriority::for_type(&self.content_type).0 as f32;
148        let size_factor = self.size_bytes as f32 / 1_048_576.0 + 1.0;
149        (1.0 - recency) * (1.0 / priority.max(0.001)) * size_factor
150    }
151}
152
153// ── TTL helpers ───────────────────────────────────────────────────────────────
154
155/// Return the recommended TTL for the given content type.
156///
157/// | Content type          | TTL        |
158/// |-----------------------|------------|
159/// | Manifest              | 30 s       |
160/// | VideoSegment          | 300 s (5 min) |
161/// | AudioSegment          | 300 s      |
162/// | Image                 | 3 600 s (1 h) |
163/// | Thumbnail             | 86 400 s (24 h) |
164/// | Metadata              | 600 s (10 min) |
165pub fn ttl_for_type(content_type: &MediaContentType) -> Duration {
166    match content_type {
167        MediaContentType::Manifest => Duration::from_secs(30),
168        MediaContentType::VideoSegment { .. } => Duration::from_secs(300),
169        MediaContentType::AudioSegment { .. } => Duration::from_secs(300),
170        MediaContentType::Image { .. } => Duration::from_secs(3_600),
171        MediaContentType::Thumbnail => Duration::from_secs(86_400),
172        MediaContentType::Metadata => Duration::from_secs(600),
173    }
174}
175
176// ── ContentAwareCache ─────────────────────────────────────────────────────────
177
178/// A media-content-aware cache that scores eviction candidates by a
179/// recency × priority × size metric rather than pure LRU order.
180///
181/// Internally, it delegates storage to an [`LruCache<String, CacheEntry>`] for
182/// O(1) operations and maintains a live count of bytes.
183pub struct ContentAwareCache {
184    inner: LruCache<String, CacheEntry>,
185    /// Capacity in number of entries.
186    capacity: usize,
187    /// Current total size of all resident entries in bytes.
188    total_bytes: usize,
189    /// Optional byte-level capacity; entries whose cumulative size exceeds
190    /// this trigger an additional content-aware eviction pass.
191    max_bytes: Option<usize>,
192}
193
194impl ContentAwareCache {
195    /// Create a new `ContentAwareCache` with an entry-count `capacity`.
196    pub fn new(capacity: usize) -> Self {
197        Self {
198            inner: LruCache::new(capacity),
199            capacity,
200            total_bytes: 0,
201            max_bytes: None,
202        }
203    }
204
205    /// Set an optional byte-level capacity in addition to the entry count cap.
206    pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
207        self.max_bytes = Some(max_bytes);
208        self
209    }
210
211    // ── Insertion ─────────────────────────────────────────────────────────────
212
213    /// Insert a media entry.
214    ///
215    /// If the cache is at capacity, the entry with the highest eviction score
216    /// is removed first.  If the new entry has a higher priority than the
217    /// current worst entry, it displaces it; otherwise the LRU entry is used
218    /// as the fallback.
219    pub fn insert_media(&mut self, key: String, data: Vec<u8>, content_type: MediaContentType) {
220        let size = data.len();
221
222        // If the key already exists, remove the old size first.
223        if let Some(old) = self.inner.remove(&key) {
224            self.total_bytes = self.total_bytes.saturating_sub(old.size_bytes);
225        }
226
227        // Enforce byte-level capacity if configured.
228        if let Some(max_bytes) = self.max_bytes {
229            while self.total_bytes + size > max_bytes && !self.inner.is_empty() {
230                self.evict_worst();
231            }
232        }
233
234        // Enforce entry-count capacity.
235        if self.inner.len() >= self.capacity {
236            self.evict_worst();
237        }
238
239        let entry = CacheEntry::new(key.clone(), data, content_type);
240        self.total_bytes += size;
241        self.inner.insert(key, entry, size);
242    }
243
244    // ── Retrieval ─────────────────────────────────────────────────────────────
245
246    /// Look up `key` and return an immutable reference to its [`CacheEntry`]
247    /// if present (updating last-accessed time).
248    pub fn get(&mut self, key: &str) -> Option<&CacheEntry> {
249        // We need to update last_accessed but LruCache::get only returns &V.
250        // We do a two-step: get (to update LRU order) then update the entry.
251        let key_owned = key.to_string();
252        if self.inner.contains(&key_owned) {
253            // Touch the entry to update `last_accessed` and `access_count`.
254            // We use `peek` to get a reference without a second LRU move, then
255            // perform a targeted update via `insert` (which handles duplicates).
256            let updated_entry = {
257                let entry = self.inner.peek(&key_owned)?;
258                let mut e = entry.clone();
259                e.last_accessed = Instant::now();
260                e.access_count = e.access_count.saturating_add(1);
261                e
262            };
263            let size = updated_entry.size_bytes;
264            // Re-insert to move to MRU head and persist the updated timestamps.
265            // Adjust total_bytes: remove old, add back same size.
266            self.total_bytes = self.total_bytes.saturating_sub(size);
267            self.inner.insert(key_owned.clone(), updated_entry, size);
268            self.total_bytes += size;
269            self.inner.peek(&key_owned)
270        } else {
271            None
272        }
273    }
274
275    /// Peek at `key` without updating access metadata or LRU order.
276    pub fn peek(&self, key: &str) -> Option<&CacheEntry> {
277        self.inner.peek(&key.to_string())
278    }
279
280    // ── Removal ───────────────────────────────────────────────────────────────
281
282    /// Explicitly remove an entry by `key`.
283    ///
284    /// Returns `true` if the entry was present.
285    pub fn remove(&mut self, key: &str) -> bool {
286        if let Some(entry) = self.inner.remove(&key.to_string()) {
287            self.total_bytes = self.total_bytes.saturating_sub(entry.size_bytes);
288            true
289        } else {
290            false
291        }
292    }
293
294    // ── TTL expiry ────────────────────────────────────────────────────────────
295
296    /// Scan all entries and remove those whose TTL has elapsed.
297    ///
298    /// Returns the number of entries evicted.
299    ///
300    /// This is an O(n) operation; callers should invoke it periodically rather
301    /// than on every access.
302    pub fn evict_expired(&mut self) -> usize {
303        // Collect expired keys — we cannot mutate inner while iterating.
304        // We use a drain-based approach: repeatedly pop the LRU until we find a
305        // non-expired entry or exhaust the cache.  This is O(n) worst case but
306        // is acceptable for a maintenance sweep.
307        //
308        // A more efficient approach would require direct iteration over the
309        // backing HashMap; that's not exposed by LruCache, so we collect keys
310        // via `peek` patterns.  Instead we collect entries into a scratch vec.
311        let mut expired_keys: Vec<String> = Vec::new();
312        let mut remaining: Vec<(String, CacheEntry)> = Vec::new();
313
314        // Drain by repeated LRU eviction, noting which entries are expired.
315        while let Some((k, entry)) = self.inner.evict_lru() {
316            let ttl = ttl_for_type(&entry.content_type);
317            if entry.inserted_at.elapsed() > ttl {
318                expired_keys.push(k);
319            } else {
320                remaining.push((k, entry));
321            }
322        }
323
324        // Re-insert non-expired entries.
325        for (k, entry) in remaining {
326            let size = entry.size_bytes;
327            self.inner.insert(k, entry, size);
328        }
329
330        // Recompute total_bytes.
331        self.total_bytes = 0;
332        // We cannot iterate LruCache directly; use a fresh total.
333        // Instead, drain and reinsert again is wasteful; track via expired_keys count.
334        // Correct approach: adjust total_bytes by subtracting expired sizes.
335        // Since we already re-inserted all non-expired entries, recalculate.
336        // Use the stats for a best-effort figure.
337        self.total_bytes = self.inner.stats().total_size_bytes;
338
339        expired_keys.len()
340    }
341
342    // ── Statistics ────────────────────────────────────────────────────────────
343
344    /// Return the number of entries currently in the cache.
345    pub fn len(&self) -> usize {
346        self.inner.len()
347    }
348
349    /// Return `true` when the cache has no entries.
350    pub fn is_empty(&self) -> bool {
351        self.inner.is_empty()
352    }
353
354    /// Return the total number of bytes currently stored.
355    pub fn total_bytes(&self) -> usize {
356        self.total_bytes
357    }
358
359    /// Return the entry-count capacity.
360    pub fn capacity(&self) -> usize {
361        self.capacity
362    }
363
364    // ── Internal eviction ─────────────────────────────────────────────────────
365
366    /// Evict the entry with the highest eviction score (content-aware).
367    ///
368    /// Because [`LruCache`] does not expose iteration, we must drain and
369    /// re-insert to find the worst entry.  This is O(n) per eviction;
370    /// acceptable for moderate cache sizes and infrequent evictions.
371    ///
372    /// If finding the worst entry is too expensive (e.g. very large cache),
373    /// callers can fall back to plain LRU via `inner.evict_lru()` directly.
374    fn evict_worst(&mut self) {
375        if self.inner.is_empty() {
376            return;
377        }
378
379        // Drain everything, find the worst, re-insert the rest.
380        let mut entries: Vec<(String, CacheEntry)> = Vec::with_capacity(self.inner.len());
381        while let Some((k, entry)) = self.inner.evict_lru() {
382            entries.push((k, entry));
383        }
384
385        // Find the index of the highest eviction score.
386        let worst_idx = entries
387            .iter()
388            .enumerate()
389            .max_by(|(_, (_, a)), (_, (_, b))| {
390                a.score_for_eviction()
391                    .partial_cmp(&b.score_for_eviction())
392                    .unwrap_or(std::cmp::Ordering::Equal)
393            })
394            .map(|(i, _)| i)
395            .unwrap_or(0);
396
397        // Remove the worst entry.
398        let (_, evicted) = entries.remove(worst_idx);
399        self.total_bytes = self.total_bytes.saturating_sub(evicted.size_bytes);
400
401        // Re-insert the remaining entries.
402        for (k, entry) in entries {
403            let size = entry.size_bytes;
404            self.inner.insert(k, entry, size);
405        }
406    }
407}
408
409// ── Tests ─────────────────────────────────────────────────────────────────────
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    // ── ContentCachePriority ──────────────────────────────────────────────────
416
417    #[test]
418    fn test_priority_manifest_is_highest() {
419        let p = ContentCachePriority::for_type(&MediaContentType::Manifest);
420        assert_eq!(p.0, 10);
421    }
422
423    #[test]
424    fn test_priority_thumbnail() {
425        let p = ContentCachePriority::for_type(&MediaContentType::Thumbnail);
426        assert_eq!(p.0, 8);
427    }
428
429    #[test]
430    fn test_priority_high_bitrate_video() {
431        let p = ContentCachePriority::for_type(&MediaContentType::VideoSegment {
432            bitrate: 5_000_000,
433            codec: "av1".into(),
434        });
435        assert_eq!(p.0, 7);
436    }
437
438    #[test]
439    fn test_priority_low_bitrate_video() {
440        let p = ContentCachePriority::for_type(&MediaContentType::VideoSegment {
441            bitrate: 1_000_000,
442            codec: "vp9".into(),
443        });
444        assert_eq!(p.0, 6);
445    }
446
447    #[test]
448    fn test_priority_audio() {
449        let p =
450            ContentCachePriority::for_type(&MediaContentType::AudioSegment { bitrate: 128_000 });
451        assert_eq!(p.0, 5);
452    }
453
454    #[test]
455    fn test_priority_image() {
456        let p = ContentCachePriority::for_type(&MediaContentType::Image {
457            width: 1920,
458            height: 1080,
459        });
460        assert_eq!(p.0, 4);
461    }
462
463    #[test]
464    fn test_priority_metadata() {
465        let p = ContentCachePriority::for_type(&MediaContentType::Metadata);
466        assert_eq!(p.0, 3);
467    }
468
469    // ── TTL ───────────────────────────────────────────────────────────────────
470
471    #[test]
472    fn test_ttl_manifest() {
473        assert_eq!(
474            ttl_for_type(&MediaContentType::Manifest),
475            Duration::from_secs(30)
476        );
477    }
478
479    #[test]
480    fn test_ttl_video_segment() {
481        let ct = MediaContentType::VideoSegment {
482            bitrate: 2_000_000,
483            codec: "av1".into(),
484        };
485        assert_eq!(ttl_for_type(&ct), Duration::from_secs(300));
486    }
487
488    #[test]
489    fn test_ttl_thumbnail() {
490        assert_eq!(
491            ttl_for_type(&MediaContentType::Thumbnail),
492            Duration::from_secs(86_400)
493        );
494    }
495
496    #[test]
497    fn test_ttl_image() {
498        let ct = MediaContentType::Image {
499            width: 100,
500            height: 100,
501        };
502        assert_eq!(ttl_for_type(&ct), Duration::from_secs(3_600));
503    }
504
505    // ── ContentAwareCache basic operations ────────────────────────────────────
506
507    #[test]
508    fn test_insert_and_get() {
509        let mut cache = ContentAwareCache::new(16);
510        cache.insert_media(
511            "seg1".into(),
512            vec![0u8; 1024],
513            MediaContentType::VideoSegment {
514                bitrate: 2_000_000,
515                codec: "av1".into(),
516            },
517        );
518        let entry = cache.get("seg1");
519        assert!(entry.is_some());
520        assert_eq!(entry.map(|e| e.size_bytes), Some(1024));
521    }
522
523    #[test]
524    fn test_get_absent_returns_none() {
525        let mut cache = ContentAwareCache::new(8);
526        assert!(cache.get("missing").is_none());
527    }
528
529    #[test]
530    fn test_len_and_is_empty() {
531        let mut cache = ContentAwareCache::new(8);
532        assert!(cache.is_empty());
533        cache.insert_media("m".into(), vec![1, 2], MediaContentType::Manifest);
534        assert_eq!(cache.len(), 1);
535        assert!(!cache.is_empty());
536    }
537
538    #[test]
539    fn test_remove() {
540        let mut cache = ContentAwareCache::new(8);
541        cache.insert_media("key".into(), vec![0u8; 512], MediaContentType::Metadata);
542        assert!(cache.remove("key"));
543        assert!(cache.get("key").is_none());
544    }
545
546    #[test]
547    fn test_remove_absent() {
548        let mut cache = ContentAwareCache::new(8);
549        assert!(!cache.remove("ghost"));
550    }
551
552    #[test]
553    fn test_total_bytes_tracking() {
554        let mut cache = ContentAwareCache::new(16);
555        cache.insert_media("a".into(), vec![0u8; 100], MediaContentType::Manifest);
556        cache.insert_media("b".into(), vec![0u8; 200], MediaContentType::Metadata);
557        assert_eq!(cache.total_bytes(), 300);
558        cache.remove("a");
559        assert_eq!(cache.total_bytes(), 200);
560    }
561
562    #[test]
563    fn test_capacity_reported() {
564        let cache = ContentAwareCache::new(32);
565        assert_eq!(cache.capacity(), 32);
566    }
567
568    // ── Eviction scoring ─────────────────────────────────────────────────────
569
570    #[test]
571    fn test_score_for_eviction_just_inserted_is_low() {
572        let entry = CacheEntry::new("k".into(), vec![0u8; 100], MediaContentType::Manifest);
573        // Just inserted → recency ≈ 1.0 → (1 - 1) * … ≈ 0.
574        let score = entry.score_for_eviction();
575        assert!(
576            score < 0.1,
577            "fresh entry should have low eviction score, got {score}"
578        );
579    }
580
581    #[test]
582    fn test_score_low_priority_higher_than_high_priority() {
583        // An aged metadata entry should score higher than an aged manifest entry
584        // because metadata has lower priority (easier to re-fetch).
585        let manifest_entry =
586            CacheEntry::new("m".into(), vec![0u8; 100], MediaContentType::Manifest);
587        let meta_entry = CacheEntry::new("d".into(), vec![0u8; 100], MediaContentType::Metadata);
588        // Force age by checking the formula with same recency.
589        // priority_manifest = 10, priority_metadata = 3 → 1/3 > 1/10.
590        let p_manifest = ContentCachePriority::for_type(&MediaContentType::Manifest).0 as f32;
591        let p_meta = ContentCachePriority::for_type(&MediaContentType::Metadata).0 as f32;
592        assert!(
593            1.0 / p_meta > 1.0 / p_manifest,
594            "metadata entry should evict before manifest"
595        );
596        drop(manifest_entry);
597        drop(meta_entry);
598    }
599
600    // ── Content-aware eviction when at capacity ───────────────────────────────
601
602    #[test]
603    fn test_eviction_prefers_low_priority_entries() {
604        // Capacity of 2: insert a Manifest + a Metadata.
605        // Then insert a third entry. The Metadata (priority=3) should be evicted
606        // over Manifest (priority=10), all else being equal.
607        let mut cache = ContentAwareCache::new(2);
608        // Insert manifest and metadata with tiny data.
609        cache.insert_media("manifest".into(), vec![0u8; 1], MediaContentType::Manifest);
610        cache.insert_media("meta".into(), vec![0u8; 1], MediaContentType::Metadata);
611        // Let both entries age so the score formula produces non-zero values,
612        // then refresh manifest's last_accessed.  Without this sleep the age
613        // is <1 µs on fast (Linux/CUDA) machines and both scores collapse to
614        // (1 - 1) * … = 0, making eviction order non-deterministic (flaky).
615        std::thread::sleep(std::time::Duration::from_millis(100));
616        // Force the manifest to be "recently used" relative to the aged metadata.
617        let _ = cache.get("manifest");
618        // Insert third entry to trigger eviction.
619        cache.insert_media(
620            "new".into(),
621            vec![0u8; 1],
622            MediaContentType::VideoSegment {
623                bitrate: 2_000_000,
624                codec: "av1".into(),
625            },
626        );
627        assert_eq!(cache.len(), 2);
628        // Manifest should still be present (higher priority).
629        assert!(
630            cache.peek("manifest").is_some(),
631            "manifest should survive eviction"
632        );
633    }
634
635    // ── access_count and last_accessed updates ────────────────────────────────
636
637    #[test]
638    fn test_access_count_increments_on_get() {
639        let mut cache = ContentAwareCache::new(8);
640        cache.insert_media("k".into(), vec![1, 2, 3], MediaContentType::Thumbnail);
641        cache.get("k");
642        cache.get("k");
643        let count = cache.peek("k").map(|e| e.access_count).unwrap_or(0);
644        assert_eq!(count, 2, "access_count should be 2 after two gets");
645    }
646
647    // ── Byte-level capacity ───────────────────────────────────────────────────
648
649    #[test]
650    fn test_max_bytes_triggers_eviction() {
651        let mut cache = ContentAwareCache::new(100).with_max_bytes(500);
652        // Insert 5 × 100-byte entries = 500 bytes (at limit).
653        for i in 0..5u32 {
654            cache.insert_media(
655                format!("seg_{i}"),
656                vec![0u8; 100],
657                MediaContentType::AudioSegment { bitrate: 128_000 },
658            );
659        }
660        assert!(cache.total_bytes() <= 500);
661        // Insert one more → must evict to stay within budget.
662        cache.insert_media("extra".into(), vec![0u8; 100], MediaContentType::Metadata);
663        assert!(
664            cache.total_bytes() <= 500,
665            "total bytes exceeded budget: {}",
666            cache.total_bytes()
667        );
668    }
669
670    // ── Peek does not update metadata ─────────────────────────────────────────
671
672    #[test]
673    fn test_peek_does_not_change_access_count() {
674        let mut cache = ContentAwareCache::new(8);
675        cache.insert_media("p".into(), vec![99], MediaContentType::Manifest);
676        let before = cache.peek("p").map(|e| e.access_count).unwrap_or(99);
677        let _ = cache.peek("p");
678        let after = cache.peek("p").map(|e| e.access_count).unwrap_or(99);
679        assert_eq!(before, after, "peek must not change access_count");
680    }
681
682    // ── Upsert behaviour ─────────────────────────────────────────────────────
683
684    #[test]
685    fn test_insert_same_key_updates_value() {
686        let mut cache = ContentAwareCache::new(8);
687        cache.insert_media("k".into(), vec![1, 2, 3], MediaContentType::Manifest);
688        cache.insert_media("k".into(), vec![10, 20], MediaContentType::Manifest);
689        assert_eq!(cache.len(), 1, "duplicate key should not increase len");
690        assert_eq!(
691            cache.total_bytes(),
692            2,
693            "total_bytes should reflect updated size"
694        );
695    }
696
697    // ── evict_expired ─────────────────────────────────────────────────────────
698
699    #[test]
700    fn test_evict_expired_no_entries() {
701        let mut cache = ContentAwareCache::new(8);
702        assert_eq!(cache.evict_expired(), 0);
703    }
704
705    #[test]
706    fn test_evict_expired_fresh_entries_survive() {
707        let mut cache = ContentAwareCache::new(8);
708        cache.insert_media("fresh".into(), vec![0u8; 10], MediaContentType::Manifest);
709        // Manifest TTL is 30 s; the entry was just inserted → should not expire.
710        let evicted = cache.evict_expired();
711        assert_eq!(evicted, 0);
712        assert!(cache.peek("fresh").is_some());
713    }
714}