feat: add cache_manager service for analysis caching

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 14:39:43 -04:00
parent dc3e7945c6
commit 1773ae81e6
2 changed files with 208 additions and 0 deletions

View File

@@ -0,0 +1,207 @@
// Future: used by project file save/load and cache Tauri commands.
#![allow(dead_code)]
use crate::models::{ThumbnailSpritesheet, WaveformTiers};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheManifest {
pub version: u32,
pub source_url: String,
pub title: String,
pub duration: f64,
pub fps: f64,
pub created_at: String,
pub last_accessed_at: String,
pub waveform_tiers: Option<WaveformTiers>,
pub keyframe_positions: Vec<f64>,
pub thumbnail_spritesheets: Vec<ThumbnailSpritesheet>,
pub caption_file_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheStats {
pub entry_count: usize,
pub total_bytes: u64,
}
/// First 16 hex characters of the SHA-256 hash of the URL.
pub fn url_hash(url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
let result = hasher.finalize();
result[..8]
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
}
pub fn cache_base_dir() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("gui-video-clipper")
.join("cache")
}
pub fn cache_dir_for_url(url: &str) -> PathBuf {
cache_base_dir().join(url_hash(url))
}
pub fn thumbnail_cache_dir(url: &str) -> PathBuf {
cache_dir_for_url(url).join("thumbnails")
}
pub fn caption_cache_dir(url: &str) -> PathBuf {
cache_dir_for_url(url).join("captions")
}
fn manifest_path(url: &str) -> PathBuf {
cache_dir_for_url(url).join("manifest.json")
}
fn now_iso8601() -> String {
// Use a simple UTC timestamp without pulling in chrono
let duration = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
// Format as ISO 8601 — good enough for cache timestamps
format!("{secs}")
}
pub fn load_manifest(url: &str) -> Option<CacheManifest> {
let path = manifest_path(url);
if !path.is_file() {
return None;
}
let data = std::fs::read_to_string(&path).ok()?;
let mut manifest: CacheManifest = serde_json::from_str(&data).ok()?;
// Update last-accessed timestamp
manifest.last_accessed_at = now_iso8601();
if let Ok(json) = serde_json::to_string_pretty(&manifest) {
let _ = std::fs::write(&path, json);
}
Some(manifest)
}
pub fn save_manifest(url: &str, manifest: &CacheManifest) -> Result<(), String> {
let dir = cache_dir_for_url(url);
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create cache dir: {e}"))?;
let path = dir.join("manifest.json");
let json = serde_json::to_string_pretty(manifest)
.map_err(|e| format!("Failed to serialize manifest: {e}"))?;
std::fs::write(&path, json)
.map_err(|e| format!("Failed to write manifest: {e}"))?;
eprintln!("[video-clipper:cache] saved manifest to '{}'", path.display());
Ok(())
}
pub fn clear_cache_entry(url: &str) -> Result<(), String> {
let dir = cache_dir_for_url(url);
if dir.is_dir() {
std::fs::remove_dir_all(&dir)
.map_err(|e| format!("Failed to remove cache entry: {e}"))?;
eprintln!("[video-clipper:cache] cleared entry at '{}'", dir.display());
}
Ok(())
}
pub fn clear_all_cache() -> Result<(), String> {
let base = cache_base_dir();
if base.is_dir() {
std::fs::remove_dir_all(&base)
.map_err(|e| format!("Failed to remove cache dir: {e}"))?;
eprintln!("[video-clipper:cache] cleared all cache at '{}'", base.display());
}
Ok(())
}
pub fn get_cache_stats() -> Result<CacheStats, String> {
let base = cache_base_dir();
if !base.is_dir() {
return Ok(CacheStats {
entry_count: 0,
total_bytes: 0,
});
}
let mut entry_count = 0usize;
let mut total_bytes = 0u64;
let entries = std::fs::read_dir(&base)
.map_err(|e| format!("Failed to read cache dir: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
entry_count += 1;
total_bytes += dir_size(&path);
}
}
Ok(CacheStats {
entry_count,
total_bytes,
})
}
fn dir_size(path: &std::path::Path) -> u64 {
let mut total = 0u64;
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_file() {
total += std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0);
} else if p.is_dir() {
total += dir_size(&p);
}
}
}
total
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_hash_length() {
let hash = url_hash("https://youtube.com/watch?v=abc123");
assert_eq!(hash.len(), 16);
}
#[test]
fn test_url_hash_deterministic() {
let url = "https://youtube.com/watch?v=abc123";
assert_eq!(url_hash(url), url_hash(url));
}
#[test]
fn test_url_hash_different_urls() {
let a = url_hash("https://youtube.com/watch?v=abc");
let b = url_hash("https://youtube.com/watch?v=xyz");
assert_ne!(a, b);
}
#[test]
fn test_cache_dir_for_url_contains_hash() {
let url = "https://youtube.com/watch?v=test";
let dir = cache_dir_for_url(url);
let hash = url_hash(url);
assert!(dir.to_string_lossy().contains(&hash));
}
#[test]
fn test_thumbnail_cache_dir_is_subdir() {
let url = "https://example.com/video";
let base = cache_dir_for_url(url);
let thumbs = thumbnail_cache_dir(url);
assert!(thumbs.starts_with(&base));
assert!(thumbs.to_string_lossy().ends_with("thumbnails"));
}
}

View File

@@ -1,3 +1,4 @@
pub mod cache_manager;
pub mod clip_exporter; pub mod clip_exporter;
pub mod dependency_manager; pub mod dependency_manager;
pub mod download_manager; pub mod download_manager;