use papaya::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
#[derive(Clone)]
pub enum CachedMetadata {
Cover(Vec<u8>),
Chapters(String),
Captions(String),
NotAvailable,
}
impl CachedMetadata {
fn estimated_size(&self) -> usize {
match self {
CachedMetadata::Cover(bytes) => bytes.len() + std::mem::size_of::<Self>(),
CachedMetadata::Chapters(s) | CachedMetadata::Captions(s) => {
s.len() + std::mem::size_of::<Self>()
}
CachedMetadata::NotAvailable => std::mem::size_of::<Self>(),
}
}
}
#[derive(Clone)]
struct CacheEntry {
data: CachedMetadata,
inserted_at: Instant,
size_bytes: usize,
}
pub struct VideoMetadataCache {
cache: HashMap<String, CacheEntry>,
current_size: AtomicUsize,
max_size: usize,
}
impl VideoMetadataCache {
pub fn new(max_size_bytes: usize) -> Self {
Self {
cache: HashMap::new(),
current_size: AtomicUsize::new(0),
max_size: max_size_bytes,
}
}
pub fn get(&self, key: &str) -> Option<CachedMetadata> {
if self.max_size == 0 {
return None;
}
let guard = self.cache.pin();
match guard.get(key) {
Some(entry) => {
tracing::debug!("video metadata cache hit: {}", key);
Some(entry.data.clone())
}
None => {
tracing::debug!("video metadata cache miss: {}", key);
None
}
}
}
pub fn insert(&self, key: String, data: CachedMetadata) {
if self.max_size == 0 {
return;
}
let size_bytes = data.estimated_size() + key.len() + std::mem::size_of::<CacheEntry>();
let entry = CacheEntry {
data,
inserted_at: Instant::now(),
size_bytes,
};
self.cache.pin().insert(key.clone(), entry);
let new_size = self.current_size.fetch_add(size_bytes, Ordering::Relaxed) + size_bytes;
tracing::debug!("video metadata cached: {} ({} bytes)", key, size_bytes);
if new_size > self.max_size {
self.evict_oldest(new_size - self.max_size);
}
}
fn evict_oldest(&self, target_bytes: usize) {
let guard = self.cache.pin();
let mut entries: Vec<(String, Instant, usize)> = guard
.iter()
.map(|(k, v)| (k.clone(), v.inserted_at, v.size_bytes))
.collect();
entries.sort_by_key(|(_, inserted_at, _)| *inserted_at);
let mut freed = 0usize;
let mut evict_count = 0usize;
for (key, _, size) in entries {
if freed >= target_bytes {
break;
}
if guard.remove(&key).is_some() {
freed += size;
evict_count += 1;
self.current_size.fetch_sub(size, Ordering::Relaxed);
}
}
if evict_count > 0 {
tracing::debug!(
"video metadata cache evicted {} entries ({} bytes freed)",
evict_count,
freed
);
}
}
#[cfg(test)]
pub fn current_size(&self) -> usize {
self.current_size.load(Ordering::Relaxed)
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.cache.pin().len()
}
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.cache.pin().is_empty()
}
}
pub fn cache_key(video_path: &str, metadata_type: &str) -> String {
format!("{}::{}", video_path, metadata_type)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_insert_and_retrieve_cover() {
let cache = VideoMetadataCache::new(1024 * 1024);
let key = "videos/test.mp4::cover";
let data = CachedMetadata::Cover(vec![0x89, 0x50, 0x4E, 0x47]);
cache.insert(key.to_string(), data);
let retrieved = cache.get(key);
assert!(retrieved.is_some());
match retrieved.unwrap() {
CachedMetadata::Cover(bytes) => assert_eq!(bytes.len(), 4),
_ => panic!("Expected Cover variant"),
}
}
#[test]
fn test_insert_and_retrieve_vtt() {
let cache = VideoMetadataCache::new(1024 * 1024);
let key = "videos/test.mp4::chapters";
let vtt = "WEBVTT\n\n00:00:00.000 --> 00:01:00.000\nIntro\n\n";
let data = CachedMetadata::Chapters(vtt.to_string());
cache.insert(key.to_string(), data);
let retrieved = cache.get(key);
assert!(retrieved.is_some());
match retrieved.unwrap() {
CachedMetadata::Chapters(s) => assert!(s.contains("WEBVTT")),
_ => panic!("Expected Chapters variant"),
}
}
#[test]
fn test_cache_miss() {
let cache = VideoMetadataCache::new(1024 * 1024);
let retrieved = cache.get("nonexistent");
assert!(retrieved.is_none());
}
#[test]
fn test_disabled_cache() {
let cache = VideoMetadataCache::new(0);
let key = "videos/test.mp4::cover";
let data = CachedMetadata::Cover(vec![1, 2, 3, 4]);
cache.insert(key.to_string(), data);
assert!(cache.get(key).is_none());
}
#[test]
fn test_not_available_marker() {
let cache = VideoMetadataCache::new(1024 * 1024);
let key = "videos/test.mp4::captions";
cache.insert(key.to_string(), CachedMetadata::NotAvailable);
let retrieved = cache.get(key);
assert!(retrieved.is_some());
assert!(matches!(retrieved.unwrap(), CachedMetadata::NotAvailable));
}
#[test]
fn test_size_tracking() {
let cache = VideoMetadataCache::new(1024 * 1024);
assert_eq!(cache.current_size(), 0);
let key = "videos/test.mp4::cover";
let data = CachedMetadata::Cover(vec![0; 100]);
cache.insert(key.to_string(), data);
assert!(cache.current_size() > 100);
}
#[test]
fn test_eviction_on_size_limit() {
let cache = VideoMetadataCache::new(500);
for i in 0..10 {
let key = format!("videos/test{}.mp4::cover", i);
let data = CachedMetadata::Cover(vec![0; 50]);
cache.insert(key, data);
}
assert!(cache.current_size() <= 600); }
#[test]
fn test_cache_key_generation() {
let key = cache_key("videos/foo.mp4", "cover");
assert_eq!(key, "videos/foo.mp4::cover");
let key = cache_key("videos/Eric Jones/Metal.mp4", "chapters");
assert_eq!(key, "videos/Eric Jones/Metal.mp4::chapters");
}
}