use crate::cache::{Entry, SizeBoundedMap};
use std::path::Path;
use std::time::UNIX_EPOCH;
#[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>(),
}
}
}
pub struct VideoMetadataCache {
cache: SizeBoundedMap<String, CachedMetadata>,
}
impl VideoMetadataCache {
pub fn new(max_size_bytes: usize) -> Self {
Self {
cache: SizeBoundedMap::new(max_size_bytes),
}
}
pub fn get(&self, key: &str) -> Option<CachedMetadata> {
if self.cache.is_disabled() {
return None;
}
match self.cache.with_entry(key, |entry| entry.value.clone()) {
Some(data) => {
tracing::debug!("video metadata cache hit: {}", key);
Some(data)
}
None => {
tracing::debug!("video metadata cache miss: {}", key);
None
}
}
}
pub fn insert(&self, key: String, data: CachedMetadata) {
if self.cache.is_disabled() {
return;
}
let size_bytes = Entry::<CachedMetadata>::weigh(data.estimated_size(), key.len());
let (_, new_size) = self.cache.insert_weighted(key.clone(), data, size_bytes);
tracing::debug!("video metadata cached: {} ({} bytes)", key, size_bytes);
if new_size > self.cache.max_size() {
let stats = self
.cache
.evict_until_freed(new_size - self.cache.max_size(), |_, entry| {
Some(entry.inserted_at)
});
if stats.evicted > 0 {
tracing::debug!(
"video metadata cache evicted {} entries ({} bytes freed)",
stats.evicted,
stats.freed
);
}
}
}
#[cfg(test)]
pub fn current_size(&self) -> usize {
self.cache.current_size()
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.cache.len()
}
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.cache.is_empty()
}
}
pub fn cache_key(video_path: &str, metadata_type: &str) -> String {
format!("{}::{}", video_path, metadata_type)
}
pub fn cache_key_with_mtime(source_file: &Path, metadata_type: &str) -> String {
format!(
"{}::{}::mtime={}",
source_file.display(),
metadata_type,
source_file_mtime_nanos(source_file)
)
}
fn source_file_mtime_nanos(path: &Path) -> u128 {
std::fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok())
.map_or(0, |elapsed| elapsed.as_nanos())
}
#[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");
}
#[test]
fn test_size_accounting_stable_on_overwrite() {
let cache = VideoMetadataCache::new(1024 * 1024);
let key = "videos/test.mp4::cover";
cache.insert(key.to_string(), CachedMetadata::Cover(vec![0; 100]));
let size_after_first = cache.current_size();
assert_eq!(cache.len(), 1);
for _ in 0..10 {
cache.insert(key.to_string(), CachedMetadata::Cover(vec![0; 100]));
}
assert_eq!(cache.len(), 1);
assert_eq!(cache.current_size(), size_after_first);
}
#[test]
fn test_size_accounting_tracks_replacement_delta() {
let cache = VideoMetadataCache::new(1024 * 1024);
let key = "videos/test.mp4::cover";
cache.insert(key.to_string(), CachedMetadata::Cover(vec![0; 100]));
let small = cache.current_size();
cache.insert(key.to_string(), CachedMetadata::Cover(vec![0; 200]));
let large = cache.current_size();
assert_eq!(cache.len(), 1);
assert_eq!(large - small, 100);
}
#[test]
fn test_cache_key_with_mtime_changes_on_edit() {
use filetime::{FileTime, set_file_mtime};
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("clip.mp4");
std::fs::write(&file, b"first").unwrap();
set_file_mtime(&file, FileTime::from_unix_time(1_000_000, 0)).unwrap();
let key_before = cache_key_with_mtime(&file, "cover");
std::fs::write(&file, b"second-edit").unwrap();
set_file_mtime(&file, FileTime::from_unix_time(2_000_000, 0)).unwrap();
let key_after = cache_key_with_mtime(&file, "cover");
assert_ne!(
key_before, key_after,
"changed mtime must produce a different cache key"
);
}
#[test]
fn test_mtime_scoped_negative_entry_misses_after_edit() {
use filetime::{FileTime, set_file_mtime};
let cache = VideoMetadataCache::new(1024 * 1024);
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("clip.mp4");
std::fs::write(&file, b"first").unwrap();
set_file_mtime(&file, FileTime::from_unix_time(1_000_000, 0)).unwrap();
let key_before = cache_key_with_mtime(&file, "cover");
cache.insert(key_before.clone(), CachedMetadata::NotAvailable);
assert!(matches!(
cache.get(&key_before),
Some(CachedMetadata::NotAvailable)
));
std::fs::write(&file, b"second-edit").unwrap();
set_file_mtime(&file, FileTime::from_unix_time(2_000_000, 0)).unwrap();
let key_after = cache_key_with_mtime(&file, "cover");
assert!(cache.get(&key_after).is_none());
}
#[test]
fn test_cache_key_with_mtime_deterministic_and_stable() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("clip.mp4");
std::fs::write(&file, b"data").unwrap();
let a = cache_key_with_mtime(&file, "chapters");
let b = cache_key_with_mtime(&file, "chapters");
assert_eq!(a, b);
let missing = dir.path().join("nope.mp4");
let key = cache_key_with_mtime(&missing, "cover");
assert!(key.ends_with("::cover::mtime=0"));
}
}