diff --git a/src-tauri/src/services/cache_manager.rs b/src-tauri/src/services/cache_manager.rs new file mode 100644 index 0000000..c30284a --- /dev/null +++ b/src-tauri/src/services/cache_manager.rs @@ -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, + pub keyframe_positions: Vec, + pub thumbnail_spritesheets: Vec, + pub caption_file_path: Option, +} + +#[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::() +} + +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 { + 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 { + 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")); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 3ae3f92..517b364 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod cache_manager; pub mod clip_exporter; pub mod dependency_manager; pub mod download_manager;