use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::engine::hash;
use crate::engine::vision::{DetectedObject, VisionProfile};
const VISION_CACHE_DIR: &str = "vision";
const CACHE_SCHEMA_VERSION: u32 = 9;
const LOCK_TIMEOUT: Duration = Duration::from_secs(30);
const HASH_HEX_LEN: usize = 64;
const IMAGE_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "webp", "bmp", "tiff", "tif", "avif", "heic", "heif", "ico",
];
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct CacheEntry {
schema_version: u32,
profile: VisionProfile,
pub(crate) caption: Option<String>,
pub(crate) objects: Option<Vec<DetectedObject>>,
pub(crate) ocr: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum CacheVersion {
Missing,
File { len: u64, modified: SystemTime },
Unknown,
}
impl CacheEntry {
pub(crate) fn new_empty(profile: VisionProfile) -> Self {
Self {
schema_version: CACHE_SCHEMA_VERSION,
profile,
caption: None,
objects: None,
ocr: None,
}
}
fn is_valid(&self, profile: VisionProfile) -> bool {
self.schema_version == CACHE_SCHEMA_VERSION && self.profile == profile
}
}
fn entry_path(readseek_dir: &Path, hash_hex: &str) -> PathBuf {
readseek_dir
.join(VISION_CACHE_DIR)
.join(&hash_hex[..2])
.join(format!("{}.json", &hash_hex[2..]))
}
pub(crate) fn load(
readseek_dir: &Path,
hash_hex: &str,
profile: VisionProfile,
) -> (CacheEntry, CacheVersion) {
let path = entry_path(readseek_dir, hash_hex);
let before = cache_version(&path);
if before == CacheVersion::Missing {
return (CacheEntry::new_empty(profile), before);
}
let data = match fs::read(&path) {
Ok(data) => data,
Err(error) => {
log::warn!("vision cache read {}: {error}", path.display());
return (CacheEntry::new_empty(profile), CacheVersion::Unknown);
}
};
let after = cache_version(&path);
let version = if before == after {
after
} else {
CacheVersion::Unknown
};
let entry: CacheEntry = match serde_json::from_slice(&data) {
Ok(entry) => entry,
Err(error) => {
log::warn!("vision cache parse {}: {error}", path.display());
return (CacheEntry::new_empty(profile), version);
}
};
if !entry.is_valid(profile) {
log::warn!(
"vision cache schema/model/profile mismatch in {}, treating as miss",
path.display()
);
return (CacheEntry::new_empty(profile), version);
}
(entry, version)
}
fn cache_version(path: &Path) -> CacheVersion {
match fs::metadata(path) {
Ok(metadata) => match metadata.modified() {
Ok(modified) => CacheVersion::File {
len: metadata.len(),
modified,
},
Err(_) => CacheVersion::Unknown,
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => CacheVersion::Missing,
Err(_) => CacheVersion::Unknown,
}
}
pub(crate) fn store(
readseek_dir: &Path,
hash_hex: &str,
loaded_version: &CacheVersion,
entry: &CacheEntry,
) {
let path = entry_path(readseek_dir, hash_hex);
if let Some(parent) = path.parent()
&& let Err(error) = fs::create_dir_all(parent)
{
log::warn!("vision cache mkdir {}: {error}", parent.display());
return;
}
let _lock = match CacheLock::acquire(&path.with_extension("json.file-lock")) {
Ok(lock) => lock,
Err(error) => {
log::warn!("vision cache lock {}: {error}", path.display());
return;
}
};
let mut merged =
if *loaded_version != CacheVersion::Unknown && cache_version(&path) == *loaded_version {
entry.clone()
} else {
load(readseek_dir, hash_hex, entry.profile).0
};
if merged.caption.is_none() {
merged.caption.clone_from(&entry.caption);
}
if merged.objects.is_none() {
merged.objects.clone_from(&entry.objects);
}
if merged.ocr.is_none() {
merged.ocr.clone_from(&entry.ocr);
}
let data = match serde_json::to_vec(&merged) {
Ok(data) => data,
Err(error) => {
log::warn!("vision cache serialize: {error}");
return;
}
};
if let Err(error) = crate::engine::repo::write_atomic(&path, &data) {
log::warn!("vision cache write {}: {error}", path.display());
}
}
struct CacheLock {
_file: fs::File,
}
impl CacheLock {
fn acquire(path: &Path) -> std::io::Result<Self> {
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)?;
let deadline = Instant::now() + LOCK_TIMEOUT;
loop {
match file.try_lock() {
Ok(()) => return Ok(Self { _file: file }),
Err(fs::TryLockError::WouldBlock) => {
if Instant::now() >= deadline {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"timed out waiting for vision cache lock",
));
}
thread::sleep(Duration::from_millis(10));
}
Err(fs::TryLockError::Error(error)) => return Err(error),
}
}
}
}
fn has_image_extension(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(str::to_ascii_lowercase)
.is_some_and(|ext| IMAGE_EXTENSIONS.contains(&ext.as_str()))
}
pub(crate) fn image_hash(path: &Path) -> Option<String> {
if !has_image_extension(path) {
return None;
}
let bytes = fs::read(path).ok()?;
crate::engine::image::probe(&bytes)?;
Some(hash::hash_bytes(&bytes))
}
pub(crate) fn remove_stale(readseek_dir: &Path, active: &HashSet<String>) -> Result<usize> {
let mut removed = 0;
let vision_root = readseek_dir.join(VISION_CACHE_DIR);
if !vision_root.is_dir() {
return Ok(0);
}
for shard in fs::read_dir(&vision_root)
.map_err(|error| anyhow::anyhow!("read {}: {error}", vision_root.display()))?
{
let shard = shard?;
if !shard.file_type()?.is_dir() {
continue;
}
let prefix = shard.file_name().to_string_lossy().into_owned();
for file_entry in fs::read_dir(shard.path())? {
let file_entry = file_entry?;
let path = file_entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
continue;
};
let hash_hex = format!("{prefix}{stem}");
if hash_hex.len() == HASH_HEX_LEN
&& hex::decode(&hash_hex).is_ok()
&& !active.contains(&hash_hex)
{
fs::remove_file(&path)?;
removed += 1;
}
}
}
Ok(removed)
}