use crate::cache::SizeBoundedMap;
use crate::video_transcode::{TranscodeError, TranscodeTarget};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
#[cfg(test)]
use std::time::Instant;
use tokio::sync::Notify;
pub const HLS_WAIT_TIMEOUT: Duration = Duration::from_secs(60);
const FAILED_ENTRY_TTL: Duration = Duration::from_secs(60);
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub enum HlsCacheKey {
Playlist {
path: PathBuf,
target: TranscodeTarget,
},
Segment {
path: PathBuf,
target: TranscodeTarget,
segment_index: u32,
},
}
impl HlsCacheKey {
pub fn playlist(path: PathBuf, target: TranscodeTarget) -> Self {
Self::Playlist { path, target }
}
pub fn segment(path: PathBuf, target: TranscodeTarget, segment_index: u32) -> Self {
Self::Segment {
path,
target,
segment_index,
}
}
}
#[derive(Clone)]
pub enum HlsCacheState {
InProgress(Arc<Notify>),
Complete(Arc<Vec<u8>>),
Failed(String),
}
pub struct HlsCache {
cache: SizeBoundedMap<HlsCacheKey, HlsCacheState>,
}
impl HlsCache {
pub fn new(max_size_bytes: usize) -> Self {
Self {
cache: SizeBoundedMap::new(max_size_bytes),
}
}
pub fn get_state(&self, key: &HlsCacheKey) -> Option<HlsCacheState> {
self.cache.with_entry(key, |entry| entry.value.clone())
}
pub fn start_generation(&self, key: HlsCacheKey) -> HlsCacheStartResult {
if self.cache.is_disabled() {
return HlsCacheStartResult::CacheDisabled;
}
let existing = self
.cache
.with_entry(&key, |entry| match &entry.value {
HlsCacheState::InProgress(notify) => {
Some(HlsCacheStartResult::AlreadyInProgress(notify.clone()))
}
HlsCacheState::Complete(data) => {
Some(HlsCacheStartResult::AlreadyComplete(data.clone()))
}
HlsCacheState::Failed(msg) => {
if entry.inserted_at.elapsed() < FAILED_ENTRY_TTL {
Some(HlsCacheStartResult::PreviouslyFailed(msg.clone()))
} else {
tracing::debug!("Failed entry for {:?} expired; allowing retry", key);
None
}
}
})
.flatten();
if let Some(result) = existing {
return result;
}
let notify = Arc::new(Notify::new());
self.cache
.insert_weighted(key.clone(), HlsCacheState::InProgress(notify.clone()), 0);
tracing::debug!("Started generation for {:?}", key);
HlsCacheStartResult::Started(notify)
}
pub fn complete_generation(&self, key: HlsCacheKey, data: Vec<u8>) {
if self.cache.is_disabled() {
return;
}
let size_bytes = data.len();
let (replaced, new_size) = self.cache.insert_weighted(
key.clone(),
HlsCacheState::Complete(Arc::new(data)),
size_bytes,
);
tracing::debug!(
"Generation complete for {:?} ({} bytes, cache size: {} bytes)",
key,
size_bytes,
new_size
);
if let Some(HlsCacheState::InProgress(n)) = replaced {
n.notify_waiters();
}
if new_size > self.cache.max_size() {
self.evict_oldest(new_size - self.cache.max_size());
}
}
pub fn fail_generation(&self, key: HlsCacheKey, error: &TranscodeError) {
if self.cache.is_disabled() {
return;
}
let (replaced, _) =
self.cache
.insert_weighted(key.clone(), HlsCacheState::Failed(error.to_string()), 0);
tracing::warn!("Generation failed for {:?}: {}", key, error);
if let Some(HlsCacheState::InProgress(n)) = replaced {
n.notify_waiters();
}
}
pub async fn wait_for_completion(
&self,
key: &HlsCacheKey,
notify: Arc<Notify>,
timeout: Duration,
) -> Option<Arc<Vec<u8>>> {
let notified = notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if let Some(HlsCacheState::Complete(data)) = self.get_state(key) {
return Some(data);
}
match tokio::time::timeout(timeout, notified).await {
Ok(()) => match self.get_state(key) {
Some(HlsCacheState::Complete(data)) => Some(data),
_ => None,
},
Err(_) => {
tracing::warn!("Timed out waiting for in-progress generation of {:?}", key);
None
}
}
}
fn evict_oldest(&self, target_bytes: usize) {
let stats = self.cache.evict_until_freed(target_bytes, |key, entry| {
if matches!(entry.value, HlsCacheState::Complete(_)) && entry.size_bytes > 0 {
let is_playlist = matches!(key, HlsCacheKey::Playlist { .. });
Some((is_playlist, entry.inserted_at))
} else {
None
}
});
if stats.evicted > 0 {
tracing::info!(
"HLS 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()
}
#[cfg(test)]
pub fn insert_failed_for_test(&self, key: HlsCacheKey, message: &str, created_at: Instant) {
self.cache.insert_weighted_at(
key,
HlsCacheState::Failed(message.to_string()),
0,
created_at,
);
}
}
pub enum HlsCacheStartResult {
Started(Arc<Notify>),
AlreadyInProgress(Arc<Notify>),
AlreadyComplete(Arc<Vec<u8>>),
PreviouslyFailed(String),
CacheDisabled,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_playlist_key(path: &str, target: TranscodeTarget) -> HlsCacheKey {
HlsCacheKey::playlist(PathBuf::from(path), target)
}
fn make_segment_key(path: &str, target: TranscodeTarget, index: u32) -> HlsCacheKey {
HlsCacheKey::segment(PathBuf::from(path), target, index)
}
#[test]
fn test_start_and_complete_playlist() {
let cache = HlsCache::new(1024 * 1024);
let key = make_playlist_key("/videos/test.mp4", TranscodeTarget::Resolution720p);
let result = cache.start_generation(key.clone());
assert!(matches!(result, HlsCacheStartResult::Started(_)));
let state = cache.get_state(&key);
assert!(matches!(state, Some(HlsCacheState::InProgress(_))));
let data = b"#EXTM3U\n#EXT-X-VERSION:3\n".to_vec();
cache.complete_generation(key.clone(), data);
let state = cache.get_state(&key);
assert!(matches!(state, Some(HlsCacheState::Complete(_))));
}
#[test]
fn test_start_and_complete_segment() {
let cache = HlsCache::new(1024 * 1024);
let key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 5);
let result = cache.start_generation(key.clone());
assert!(matches!(result, HlsCacheStartResult::Started(_)));
let data = vec![0u8; 1000]; cache.complete_generation(key.clone(), data);
let state = cache.get_state(&key);
assert!(matches!(state, Some(HlsCacheState::Complete(_))));
}
#[test]
fn test_concurrent_start_returns_in_progress() {
let cache = HlsCache::new(1024 * 1024);
let key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
let result1 = cache.start_generation(key.clone());
assert!(matches!(result1, HlsCacheStartResult::Started(_)));
let result2 = cache.start_generation(key.clone());
assert!(matches!(result2, HlsCacheStartResult::AlreadyInProgress(_)));
}
#[test]
fn test_complete_returns_already_complete() {
let cache = HlsCache::new(1024 * 1024);
let key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
cache.start_generation(key.clone());
cache.complete_generation(key.clone(), vec![0u8; 100]);
let result = cache.start_generation(key.clone());
assert!(matches!(result, HlsCacheStartResult::AlreadyComplete(_)));
}
#[test]
fn test_failed_generation() {
let cache = HlsCache::new(1024 * 1024);
let key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
cache.start_generation(key.clone());
cache.fail_generation(
key.clone(),
&TranscodeError::TranscodeFailed("Test failure".to_string()),
);
let state = cache.get_state(&key);
assert!(matches!(state, Some(HlsCacheState::Failed(_))));
let result = cache.start_generation(key.clone());
assert!(matches!(result, HlsCacheStartResult::PreviouslyFailed(_)));
}
#[test]
fn test_failed_entry_within_ttl_blocks_retry() {
let cache = HlsCache::new(1024 * 1024);
let key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
cache.start_generation(key.clone());
cache.fail_generation(
key.clone(),
&TranscodeError::TranscodeFailed("Test failure".to_string()),
);
let result = cache.start_generation(key.clone());
assert!(matches!(result, HlsCacheStartResult::PreviouslyFailed(_)));
}
#[test]
fn test_failed_entry_expires_and_allows_retry() {
let cache = HlsCache::new(1024 * 1024);
let key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
let stale = Instant::now()
.checked_sub(FAILED_ENTRY_TTL + Duration::from_secs(1))
.expect("clock far enough from epoch");
cache.insert_failed_for_test(key.clone(), "stale failure", stale);
assert!(matches!(
cache.get_state(&key),
Some(HlsCacheState::Failed(_))
));
let result = cache.start_generation(key.clone());
assert!(matches!(result, HlsCacheStartResult::Started(_)));
}
#[test]
fn test_disabled_cache() {
let cache = HlsCache::new(0);
let key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
let result = cache.start_generation(key.clone());
assert!(matches!(result, HlsCacheStartResult::CacheDisabled));
assert!(cache.get_state(&key).is_none());
}
#[test]
fn test_different_resolutions_are_separate() {
let cache = HlsCache::new(1024 * 1024);
let key_720 = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
let key_480 = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution480p, 0);
cache.start_generation(key_720.clone());
cache.complete_generation(key_720.clone(), vec![0u8; 100]);
let result = cache.start_generation(key_480.clone());
assert!(matches!(result, HlsCacheStartResult::Started(_)));
}
#[test]
fn test_different_segments_are_separate() {
let cache = HlsCache::new(1024 * 1024);
let key_0 = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
let key_1 = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 1);
cache.start_generation(key_0.clone());
cache.complete_generation(key_0.clone(), vec![0u8; 100]);
let result = cache.start_generation(key_1.clone());
assert!(matches!(result, HlsCacheStartResult::Started(_)));
}
#[test]
fn test_playlist_and_segment_are_separate() {
let cache = HlsCache::new(1024 * 1024);
let playlist_key = make_playlist_key("/videos/test.mp4", TranscodeTarget::Resolution720p);
let segment_key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
cache.start_generation(playlist_key.clone());
cache.complete_generation(playlist_key.clone(), b"#EXTM3U\n".to_vec());
let result = cache.start_generation(segment_key.clone());
assert!(matches!(result, HlsCacheStartResult::Started(_)));
}
#[test]
fn test_size_tracking() {
let cache = HlsCache::new(1024 * 1024);
assert_eq!(cache.current_size(), 0);
let key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
cache.start_generation(key.clone());
cache.complete_generation(key.clone(), vec![0u8; 500]);
assert_eq!(cache.current_size(), 500);
}
#[test]
fn test_eviction_prefers_segments_over_playlists() {
let cache = HlsCache::new(500);
let playlist_key = make_playlist_key("/videos/test.mp4", TranscodeTarget::Resolution720p);
cache.start_generation(playlist_key.clone());
cache.complete_generation(playlist_key.clone(), vec![0u8; 50]);
for i in 0..10 {
let key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, i);
cache.start_generation(key.clone());
cache.complete_generation(key, vec![0u8; 100]);
}
assert!(matches!(
cache.get_state(&playlist_key),
Some(HlsCacheState::Complete(_))
));
}
#[test]
fn test_eviction_on_size_limit() {
let cache = HlsCache::new(1000);
for i in 0..10 {
let key = make_segment_key(
&format!("/videos/test{}.mp4", i),
TranscodeTarget::Resolution720p,
0,
);
cache.start_generation(key.clone());
cache.complete_generation(key, vec![0u8; 200]);
}
assert!(cache.current_size() <= 1200); }
#[test]
fn test_size_accounting_on_overwrite() {
let cache = HlsCache::new(1024 * 1024);
let key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
cache.start_generation(key.clone());
cache.complete_generation(key.clone(), vec![0u8; 500]);
assert_eq!(cache.current_size(), 500);
cache.complete_generation(key.clone(), vec![0u8; 200]);
assert_eq!(cache.current_size(), 200);
cache.complete_generation(key.clone(), vec![0u8; 900]);
assert_eq!(cache.current_size(), 900);
}
#[tokio::test]
async fn test_wait_for_completion_already_done_no_lost_wakeup() {
let cache = HlsCache::new(1024 * 1024);
let key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
let notify = match cache.start_generation(key.clone()) {
HlsCacheStartResult::Started(n) => n,
_ => panic!("expected Started"),
};
cache.complete_generation(key.clone(), vec![7u8; 128]);
let data = cache
.wait_for_completion(&key, notify, Duration::from_secs(5))
.await;
assert_eq!(data.map(|d| d.len()), Some(128));
}
#[tokio::test]
async fn test_wait_for_completion_concurrent_waiters() {
use std::sync::Arc as StdArc;
let cache = StdArc::new(HlsCache::new(1024 * 1024));
let key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
let gen_notify = match cache.start_generation(key.clone()) {
HlsCacheStartResult::Started(n) => n,
_ => panic!("expected Started"),
};
let waiters: Vec<_> = (0..8)
.map(|_| {
let cache = cache.clone();
let key = key.clone();
let notify = match cache.start_generation(key.clone()) {
HlsCacheStartResult::AlreadyInProgress(n) => n,
_ => panic!("expected AlreadyInProgress"),
};
tokio::spawn(async move {
cache
.wait_for_completion(&key, notify, Duration::from_secs(5))
.await
})
})
.collect();
tokio::time::sleep(Duration::from_millis(20)).await;
cache.complete_generation(key.clone(), vec![3u8; 256]);
gen_notify.notify_waiters();
for w in waiters {
let data = w.await.expect("waiter task panicked");
assert_eq!(data.map(|d| d.len()), Some(256));
}
}
#[tokio::test]
async fn test_wait_for_completion_times_out() {
let cache = HlsCache::new(1024 * 1024);
let key = make_segment_key("/videos/test.mp4", TranscodeTarget::Resolution720p, 0);
let notify = match cache.start_generation(key.clone()) {
HlsCacheStartResult::Started(n) => n,
_ => panic!("expected Started"),
};
let data = cache
.wait_for_completion(&key, notify, Duration::from_millis(50))
.await;
assert!(data.is_none());
}
}