use std::collections::HashMap;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use crate::TranscodeError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EvictionPolicy {
Lru,
Lfu,
LargestFirst,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscodeCacheConfig {
pub max_entries: usize,
pub max_bytes: u64,
pub eviction_policy: EvictionPolicy,
}
impl Default for TranscodeCacheConfig {
fn default() -> Self {
Self {
max_entries: 256,
max_bytes: 50 * 1024 * 1024 * 1024, eviction_policy: EvictionPolicy::Lru,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CacheParams {
pub codec: String,
pub bitrate_bps: u64,
pub width: u32,
pub height: u32,
pub extra: HashMap<String, String>,
}
impl CacheParams {
#[must_use]
pub fn hash(&self) -> u64 {
const OFFSET: u64 = 0xcbf29ce484222325;
const PRIME: u64 = 0x00000100000001b3;
let mut h: u64 = OFFSET;
let mix = |h: u64, bytes: &[u8]| -> u64 {
bytes.iter().fold(h, |acc, &b| {
acc.wrapping_mul(PRIME) ^ u64::from(b)
})
};
h = mix(h, self.codec.as_bytes());
h = mix(h, &self.bitrate_bps.to_le_bytes());
h = mix(h, &self.width.to_le_bytes());
h = mix(h, &self.height.to_le_bytes());
let mut extras: Vec<(&String, &String)> = self.extra.iter().collect();
extras.sort_by_key(|(k, _)| k.as_str());
for (k, v) in extras {
h = mix(h, k.as_bytes());
h = mix(h, v.as_bytes());
}
h
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CacheKey {
pub source_hash: u64,
pub params_hash: u64,
}
impl CacheKey {
#[must_use]
pub fn new(source_hash: u64, params: &CacheParams) -> Self {
Self {
source_hash,
params_hash: params.hash(),
}
}
#[must_use]
pub fn from_hashes(source_hash: u64, params_hash: u64) -> Self {
Self {
source_hash,
params_hash,
}
}
}
#[derive(Debug, Clone)]
pub struct CacheEntry {
pub output_path: String,
pub size_bytes: u64,
pub created_at: Instant,
pub last_accessed: Instant,
pub access_count: u64,
}
impl CacheEntry {
fn new(output_path: String, size_bytes: u64) -> Self {
let now = Instant::now();
Self {
output_path,
size_bytes,
created_at: now,
last_accessed: now,
access_count: 0,
}
}
#[must_use]
pub fn age(&self) -> Duration {
self.created_at.elapsed()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CacheStats {
pub total_lookups: u64,
pub hits: u64,
pub misses: u64,
pub total_inserts: u64,
pub total_evictions: u64,
pub current_entries: usize,
pub current_bytes: u64,
}
impl CacheStats {
#[must_use]
pub fn hit_ratio(&self) -> f64 {
if self.total_lookups == 0 {
0.0
} else {
self.hits as f64 / self.total_lookups as f64
}
}
}
pub struct TranscodeCache {
config: TranscodeCacheConfig,
entries: HashMap<CacheKey, CacheEntry>,
stats: CacheStats,
}
impl TranscodeCache {
#[must_use]
pub fn new(config: TranscodeCacheConfig) -> Self {
Self {
config,
entries: HashMap::new(),
stats: CacheStats::default(),
}
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(TranscodeCacheConfig::default())
}
pub fn get(&mut self, key: &CacheKey) -> Option<&CacheEntry> {
self.stats.total_lookups += 1;
if let Some(entry) = self.entries.get_mut(key) {
entry.last_accessed = Instant::now();
entry.access_count += 1;
self.stats.hits += 1;
self.entries.get(key)
} else {
self.stats.misses += 1;
None
}
}
pub fn insert(
&mut self,
key: CacheKey,
output_path: String,
size_bytes: u64,
) -> Result<(), TranscodeError> {
if output_path.is_empty() {
return Err(TranscodeError::InvalidOutput(
"Cache: output_path must not be empty".into(),
));
}
if size_bytes == 0 {
return Err(TranscodeError::InvalidOutput(
"Cache: size_bytes must be > 0".into(),
));
}
if let Some(existing) = self.entries.get_mut(&key) {
self.stats.current_bytes = self
.stats
.current_bytes
.saturating_sub(existing.size_bytes)
.saturating_add(size_bytes);
existing.output_path = output_path;
existing.size_bytes = size_bytes;
existing.last_accessed = Instant::now();
return Ok(());
}
while self.needs_eviction(size_bytes) {
if !self.evict_one() {
break; }
}
self.stats.current_bytes = self.stats.current_bytes.saturating_add(size_bytes);
self.stats.current_entries += 1;
self.stats.total_inserts += 1;
self.entries.insert(key, CacheEntry::new(output_path, size_bytes));
Ok(())
}
pub fn remove(&mut self, key: &CacheKey) -> bool {
if let Some(entry) = self.entries.remove(key) {
self.stats.current_bytes = self.stats.current_bytes.saturating_sub(entry.size_bytes);
self.stats.current_entries = self.stats.current_entries.saturating_sub(1);
true
} else {
false
}
}
pub fn evict_older_than(&mut self, max_age: Duration) -> usize {
let expired: Vec<CacheKey> = self
.entries
.iter()
.filter(|(_, e)| e.age() > max_age)
.map(|(k, _)| k.clone())
.collect();
let count = expired.len();
for key in expired {
self.remove(&key);
self.stats.total_evictions += 1;
}
count
}
pub fn clear(&mut self) {
self.stats.total_evictions += self.entries.len() as u64;
self.entries.clear();
self.stats.current_entries = 0;
self.stats.current_bytes = 0;
}
#[must_use]
pub fn stats(&self) -> &CacheStats {
&self.stats
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn needs_eviction(&self, incoming_bytes: u64) -> bool {
let over_count = self.entries.len() >= self.config.max_entries;
let over_bytes = self.config.max_bytes > 0
&& self.stats.current_bytes.saturating_add(incoming_bytes) > self.config.max_bytes;
over_count || over_bytes
}
fn evict_one(&mut self) -> bool {
if self.entries.is_empty() {
return false;
}
let victim_key: Option<CacheKey> = match self.config.eviction_policy {
EvictionPolicy::Lru => self
.entries
.iter()
.min_by_key(|(_, e)| e.last_accessed)
.map(|(k, _)| k.clone()),
EvictionPolicy::Lfu => self
.entries
.iter()
.min_by_key(|(_, e)| e.access_count)
.map(|(k, _)| k.clone()),
EvictionPolicy::LargestFirst => self
.entries
.iter()
.max_by_key(|(_, e)| e.size_bytes)
.map(|(k, _)| k.clone()),
};
if let Some(key) = victim_key {
if let Some(evicted) = self.entries.remove(&key) {
self.stats.current_bytes = self
.stats
.current_bytes
.saturating_sub(evicted.size_bytes);
self.stats.current_entries = self.stats.current_entries.saturating_sub(1);
}
self.stats.total_evictions += 1;
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_key(src: u64, codec: &str, bitrate: u64) -> CacheKey {
let params = CacheParams {
codec: codec.into(),
bitrate_bps: bitrate,
width: 1920,
height: 1080,
extra: HashMap::new(),
};
CacheKey::new(src, ¶ms)
}
#[test]
fn test_cache_miss_on_empty() {
let mut cache = TranscodeCache::with_defaults();
let key = make_key(1, "vp9", 4_000_000);
assert!(cache.get(&key).is_none());
assert_eq!(cache.stats().misses, 1);
}
#[test]
fn test_insert_and_hit() {
let mut cache = TranscodeCache::with_defaults();
let key = make_key(42, "av1", 3_000_000);
cache.insert(key.clone(), "/tmp/out.webm".into(), 10_000).unwrap();
let entry = cache.get(&key).expect("should be a hit");
assert_eq!(entry.output_path, "/tmp/out.webm");
assert_eq!(cache.stats().hits, 1);
assert_eq!(cache.stats().total_inserts, 1);
}
#[test]
fn test_remove_entry() {
let mut cache = TranscodeCache::with_defaults();
let key = make_key(7, "opus", 128_000);
cache.insert(key.clone(), "/tmp/audio.ogg".into(), 512).unwrap();
assert!(cache.remove(&key));
assert!(cache.get(&key).is_none());
}
#[test]
fn test_eviction_lru_respects_max_entries() {
let cfg = TranscodeCacheConfig {
max_entries: 3,
max_bytes: 0,
eviction_policy: EvictionPolicy::Lru,
};
let mut cache = TranscodeCache::new(cfg);
for i in 0u64..5 {
let key = make_key(i, "vp9", i * 1000);
cache.insert(key, format!("/tmp/out{i}.webm"), 100).unwrap();
}
assert_eq!(cache.len(), 3, "LRU eviction should cap at max_entries");
assert!(cache.stats().total_evictions >= 2);
}
#[test]
fn test_eviction_lfu_respects_max_entries() {
let cfg = TranscodeCacheConfig {
max_entries: 2,
max_bytes: 0,
eviction_policy: EvictionPolicy::Lfu,
};
let mut cache = TranscodeCache::new(cfg);
for i in 0u64..4 {
let key = make_key(i, "av1", i * 500);
cache.insert(key, format!("/tmp/lfu{i}.webm"), 200).unwrap();
}
assert_eq!(cache.len(), 2);
}
#[test]
fn test_eviction_largest_first() {
let cfg = TranscodeCacheConfig {
max_entries: 2,
max_bytes: 0,
eviction_policy: EvictionPolicy::LargestFirst,
};
let mut cache = TranscodeCache::new(cfg);
let k0 = make_key(0, "vp9", 1000);
let k1 = make_key(1, "vp9", 2000);
let k2 = make_key(2, "vp9", 3000);
cache.insert(k0.clone(), "/tmp/a.webm".into(), 100).unwrap();
cache.insert(k1.clone(), "/tmp/b.webm".into(), 500).unwrap();
cache.insert(k2.clone(), "/tmp/c.webm".into(), 200).unwrap();
assert_eq!(cache.len(), 2);
assert!(cache.get(&k1).is_none(), "largest entry should have been evicted");
}
#[test]
fn test_max_bytes_triggers_eviction() {
let cfg = TranscodeCacheConfig {
max_entries: 100,
max_bytes: 1000,
eviction_policy: EvictionPolicy::Lru,
};
let mut cache = TranscodeCache::new(cfg);
for i in 0u64..5 {
let key = make_key(i, "av1", i);
cache.insert(key, format!("/tmp/b{i}.webm"), 300).unwrap();
}
assert!(
cache.stats().current_bytes <= 1000,
"bytes {} should be <= 1000",
cache.stats().current_bytes
);
}
#[test]
fn test_clear_resets_state() {
let mut cache = TranscodeCache::with_defaults();
for i in 0u64..5 {
let key = make_key(i, "flac", i);
cache.insert(key, format!("/tmp/f{i}.flac"), 400).unwrap();
}
cache.clear();
assert!(cache.is_empty());
assert_eq!(cache.stats().current_bytes, 0);
}
#[test]
fn test_hit_ratio_calculation() {
let mut cache = TranscodeCache::with_defaults();
let key = make_key(99, "opus", 192_000);
cache.insert(key.clone(), "/tmp/o.opus".into(), 1024).unwrap();
let _ = cache.get(&key); let _ = cache.get(&make_key(0, "unknown", 0)); let ratio = cache.stats().hit_ratio();
assert!((ratio - 0.5).abs() < 1e-9, "hit ratio should be 0.5");
}
#[test]
fn test_insert_empty_path_returns_error() {
let mut cache = TranscodeCache::with_defaults();
let key = make_key(1, "vp9", 1000);
assert!(cache.insert(key, String::new(), 100).is_err());
}
#[test]
fn test_insert_zero_bytes_returns_error() {
let mut cache = TranscodeCache::with_defaults();
let key = make_key(2, "av1", 2000);
assert!(cache.insert(key, "/tmp/x.webm".into(), 0).is_err());
}
#[test]
fn test_cache_params_hash_deterministic() {
let p = CacheParams {
codec: "vp9".into(),
bitrate_bps: 5_000_000,
width: 1280,
height: 720,
extra: HashMap::new(),
};
assert_eq!(p.hash(), p.hash(), "hash must be deterministic");
}
#[test]
fn test_cache_params_different_codecs_different_hash() {
let mut p1 = CacheParams {
codec: "vp9".into(),
bitrate_bps: 4_000_000,
width: 1920,
height: 1080,
extra: HashMap::new(),
};
let mut p2 = p1.clone();
p2.codec = "av1".into();
assert_ne!(p1.hash(), p2.hash());
p1.bitrate_bps = 2_000_000;
assert_ne!(p1.hash(), p2.hash());
}
#[test]
fn test_evict_older_than_removes_entries() {
let mut cache = TranscodeCache::with_defaults();
for i in 0u64..3 {
let key = make_key(i, "vp9", i);
cache.insert(key, format!("/tmp/t{i}.webm"), 100).unwrap();
}
let evicted = cache.evict_older_than(Duration::from_nanos(0));
assert!(evicted >= 1, "should have evicted at least one entry");
}
}