Download src/landppt/services/image/cache/image_cache.py from biaoqing/ppt-web: direct link, hf CLI and curl.
- Browser
- Download file 28.7 kB
-
https://huggingface.co/spaces/biaoqing/ppt-web/resolve/main/src/landppt/services/image/cache/image_cache.py
- Command line
-
hf download hf://spaces/biaoqing/ppt-web/src/landppt/services/image/cache/image_cache.py
-
curl -L -o image_cache.py https://huggingface.co/spaces/biaoqing/ppt-web/resolve/main/src/landppt/services/image/cache/image_cache.py
28.7 kB
| """ | |
| 图片缓存管理器 | |
| """ | |
| import asyncio | |
| import json | |
| import logging | |
| import shutil | |
| import time | |
| from pathlib import Path | |
| from typing import Dict, List, Optional, Tuple, Any | |
| import hashlib | |
| from datetime import datetime, timedelta | |
| from ..models import ( | |
| ImageInfo, ImageCacheInfo, ImageSourceType, ImageProvider | |
| ) | |
| from ....auth.request_context import current_user_id | |
| logger = logging.getLogger(__name__) | |
| class StorageQuotaExceededError(RuntimeError): | |
| """Raised when a user exceeds their image hosting storage quota.""" | |
| def __init__(self, *, user_id: int, used_bytes: int, quota_bytes: int): | |
| super().__init__( | |
| f"Storage quota exceeded for user {user_id}: used={used_bytes}B quota={quota_bytes}B" | |
| ) | |
| self.user_id = user_id | |
| self.used_bytes = used_bytes | |
| self.quota_bytes = quota_bytes | |
| class ImageCacheManager: | |
| """图片缓存管理器""" | |
| def __init__(self, config: Dict[str, Any]): | |
| self.config = config | |
| # 缓存配置 - 移除过期和清理设置,图片永久有效 | |
| self.cache_root = Path(config.get('base_dir', config.get('cache_root', 'temp/images_cache'))) | |
| # 缓存大小和清理配置(虽然图片永久有效,但保留配置项) | |
| self.max_size_gb = config.get('max_size_gb', 100.0) | |
| self.cleanup_interval_hours = config.get('cleanup_interval_hours', 240000) | |
| # 缓存目录结构 | |
| self.ai_generated_dir = self.cache_root / 'ai_generated' | |
| self.web_search_dir = self.cache_root / 'web_search' | |
| self.local_storage_dir = self.cache_root / 'local_storage' | |
| self.metadata_dir = self.cache_root / 'metadata' | |
| self.thumbnails_dir = self.cache_root / 'thumbnails' | |
| # 创建目录结构 | |
| self._create_cache_directories() | |
| # 缓存索引 | |
| self._cache_index: Dict[str, ImageCacheInfo] = {} | |
| self._load_cache_index() | |
| def _create_cache_directories(self): | |
| """创建缓存目录结构""" | |
| directories = [ | |
| self.cache_root, | |
| self.ai_generated_dir, | |
| self.ai_generated_dir / 'dalle', | |
| self.ai_generated_dir / 'stable_diffusion', | |
| self.ai_generated_dir / 'siliconflow', | |
| self.ai_generated_dir / 'pollinations', | |
| self.web_search_dir, | |
| self.web_search_dir / 'unsplash', | |
| self.web_search_dir / 'pixabay', | |
| self.local_storage_dir, | |
| self.local_storage_dir / 'user_uploads', | |
| self.local_storage_dir / 'processed', | |
| self.metadata_dir, | |
| self.metadata_dir / 'references', # 用于存储同一内容的多个引用 | |
| self.thumbnails_dir | |
| ] | |
| for directory in directories: | |
| directory.mkdir(parents=True, exist_ok=True) | |
| def _get_cache_path(self, source_type: ImageSourceType, provider: ImageProvider) -> Path: | |
| """获取缓存路径""" | |
| if source_type == ImageSourceType.AI_GENERATED: | |
| if provider == ImageProvider.DALLE: | |
| return self.ai_generated_dir / 'dalle' | |
| elif provider == ImageProvider.STABLE_DIFFUSION: | |
| return self.ai_generated_dir / 'stable_diffusion' | |
| elif provider == ImageProvider.POLLINATIONS: | |
| return self.ai_generated_dir / 'pollinations' | |
| else: | |
| return self.ai_generated_dir | |
| elif source_type == ImageSourceType.WEB_SEARCH: | |
| if provider == ImageProvider.UNSPLASH: | |
| return self.web_search_dir / 'unsplash' | |
| elif provider == ImageProvider.PIXABAY: | |
| return self.web_search_dir / 'pixabay' | |
| else: | |
| return self.web_search_dir | |
| elif source_type == ImageSourceType.LOCAL_STORAGE: | |
| if provider == ImageProvider.USER_UPLOAD: | |
| return self.local_storage_dir / 'user_uploads' | |
| else: | |
| return self.local_storage_dir / 'processed' | |
| return self.cache_root | |
| def _generate_content_hash(self, image_data: bytes) -> str: | |
| """生成图片内容哈希值""" | |
| return hashlib.sha256(image_data).hexdigest() | |
| async def get_user_storage_usage_bytes(self, user_id: int, *, refresh_index: bool = True) -> int: | |
| """Return total cached bytes for a user scope (best-effort, file-exists checked).""" | |
| if refresh_index: | |
| await asyncio.get_event_loop().run_in_executor(None, self._load_cache_index) | |
| prefix = f"u{user_id}_" | |
| total_size = 0 | |
| for cache_key, cache_info in list(self._cache_index.items()): | |
| if not cache_key.startswith(prefix): | |
| continue | |
| try: | |
| if Path(cache_info.file_path).exists(): | |
| total_size += int(cache_info.file_size or 0) | |
| except Exception: | |
| continue | |
| return total_size | |
| def _get_owner_user_id(self, image_info: ImageInfo) -> Optional[int]: | |
| owner = image_info.owner_user_id | |
| if owner is None: | |
| owner = current_user_id.get() | |
| return owner | |
| def _scoped_cache_key(self, owner_user_id: Optional[int], content_hash: str) -> str: | |
| scope = f"u{owner_user_id}" if owner_user_id is not None else "public" | |
| return f"{scope}_{content_hash}" | |
| def _generate_cache_key(self, image_info: ImageInfo, image_data: bytes = None) -> str: | |
| """生成缓存键 - 用户隔离 + 内容哈希。 | |
| 新的缓存键为 `{scope}_{sha256}`,其中 scope 为 `u{user_id}` 或 `public`。 | |
| """ | |
| owner_user_id = self._get_owner_user_id(image_info) | |
| if image_data: | |
| return self._scoped_cache_key(owner_user_id, self._generate_content_hash(image_data)) | |
| # 如果 image_id 已经是缓存键,直接复用 | |
| if image_info.image_id and image_info.image_id in self._cache_index: | |
| return image_info.image_id | |
| # 如果没有图片数据,使用图片ID作为临时键(这种情况应该避免) | |
| logger.warning(f"Generating cache key without image data for {image_info.image_id}") | |
| return hashlib.md5(f"temp_{image_info.image_id}".encode()).hexdigest() | |
| async def cache_image(self, image_info: ImageInfo, image_data: bytes) -> str: | |
| """缓存图片 - 基于内容去重""" | |
| try: | |
| # 绑定 owner(用于隔离图床数据) | |
| owner_user_id = self._get_owner_user_id(image_info) | |
| image_info.owner_user_id = owner_user_id | |
| if image_info.source_image_id is None: | |
| image_info.source_image_id = image_info.image_id | |
| # 生成用户隔离的缓存键 | |
| content_hash = self._generate_content_hash(image_data) | |
| cache_key = self._scoped_cache_key(owner_user_id, content_hash) | |
| # 以 cache_key 作为对外暴露的 image_id,避免跨用户 ID 冲突 | |
| image_info.image_id = cache_key | |
| # 检查是否已经缓存了相同内容的图片 | |
| if cache_key in self._cache_index: | |
| existing_cache_info = self._cache_index[cache_key] | |
| existing_file_path = Path(existing_cache_info.file_path) | |
| # 如果文件存在,更新访问信息并保存新的图片元数据引用 | |
| if existing_file_path.exists(): | |
| existing_cache_info.update_access() | |
| # 更新图片信息中的本地路径 | |
| image_info.local_path = str(existing_file_path) | |
| # 更新该 key 的元数据(幂等) | |
| await self._save_image_metadata(cache_key, image_info) | |
| asyncio.create_task(self._save_cache_index()) | |
| logger.debug(f"Image content already cached for user scope: {cache_key}") | |
| return cache_key | |
| else: | |
| # 如果文件不存在,从索引中移除 | |
| del self._cache_index[cache_key] | |
| # Enforce per-user storage quota (only when writing a new cache entry) | |
| quota_mb = self.config.get("user_quota_mb", 0) if isinstance(self.config, dict) else 0 | |
| try: | |
| quota_mb = int(float(quota_mb)) | |
| except Exception: | |
| quota_mb = 0 | |
| if owner_user_id is not None and quota_mb > 0: | |
| quota_bytes = quota_mb * 1024 * 1024 | |
| # Multi-worker processes: refresh filesystem index so new uploads/deletes are visible across workers. | |
| await asyncio.get_event_loop().run_in_executor(None, self._load_cache_index) | |
| used_bytes = await self.get_user_storage_usage_bytes(owner_user_id, refresh_index=False) | |
| if used_bytes + len(image_data) > quota_bytes: | |
| raise StorageQuotaExceededError( | |
| user_id=owner_user_id, | |
| used_bytes=used_bytes, | |
| quota_bytes=quota_bytes, | |
| ) | |
| # 选择存储路径 - 优先使用AI生成或网络搜索的路径 | |
| if image_info.source_type.value in ['ai_generated', 'web_search']: | |
| cache_path = self._get_cache_path(image_info.source_type, image_info.provider) | |
| else: | |
| # 对于本地上传,使用通用路径 | |
| cache_path = self.local_storage_dir / 'user_uploads' | |
| # 确定文件扩展名 | |
| file_extension = Path(image_info.filename).suffix | |
| if not file_extension: | |
| file_extension = f".{image_info.metadata.format.value}" | |
| file_path = cache_path / f"{cache_key}{file_extension}" | |
| # 保存图片文件 | |
| await asyncio.get_event_loop().run_in_executor( | |
| None, self._save_image_file, file_path, image_data | |
| ) | |
| # 更新图片信息中的本地路径 | |
| image_info.local_path = str(file_path) | |
| # 创建缓存信息 - 移除过期时间,图片永久有效 | |
| cache_info = ImageCacheInfo( | |
| cache_key=cache_key, | |
| file_path=str(file_path), | |
| file_size=len(image_data), | |
| created_at=time.time(), | |
| last_accessed=time.time(), | |
| access_count=1, | |
| expires_at=None # 永不过期 | |
| ) | |
| # 更新缓存索引 | |
| self._cache_index[cache_key] = cache_info | |
| # 保存图片元数据 | |
| await self._save_image_metadata(cache_key, image_info) | |
| # 异步保存缓存索引 | |
| asyncio.create_task(self._save_cache_index()) | |
| logger.debug(f"Image cached successfully: {cache_key}") | |
| return cache_key | |
| except Exception as e: | |
| logger.error(f"Failed to cache image {image_info.image_id}: {e}") | |
| raise | |
| def _save_image_file(self, file_path: Path, image_data: bytes): | |
| """保存图片文件""" | |
| with open(file_path, 'wb') as f: | |
| f.write(image_data) | |
| async def get_cached_image(self, cache_key: str) -> Optional[Tuple[ImageInfo, Path]]: | |
| """获取缓存的图片""" | |
| try: | |
| cache_info = self._cache_index.get(cache_key) | |
| if not cache_info: | |
| # Fallback: multi-worker processes may have stale in-memory index. | |
| # Discover the cached file on disk by cache_key and rebuild index entry on demand. | |
| cache_info = await self._discover_cache_info(cache_key) | |
| if not cache_info: | |
| return None | |
| # 检查文件是否存在 | |
| file_path = Path(cache_info.file_path) | |
| if not file_path.exists(): | |
| await self.remove_from_cache(cache_key) | |
| return None | |
| # 加载图片元数据 | |
| image_info = await self._load_image_metadata(cache_key) | |
| if not image_info: | |
| # 如果没有元数据文件,创建一个基础的ImageInfo对象 | |
| # 这在PDF转换等场景下是正常的,只需要图片文件本身 | |
| logger.debug(f"No metadata found for {cache_key}, creating basic ImageInfo") | |
| from ..models import ImageInfo, ImageMetadata, ImageFormat, ImageSourceType, ImageProvider | |
| # 从文件路径推断基本信息 | |
| file_extension = file_path.suffix.lower() | |
| format_map = { | |
| '.jpg': ImageFormat.JPEG, | |
| '.jpeg': ImageFormat.JPEG, | |
| '.png': ImageFormat.PNG, | |
| '.webp': ImageFormat.WEBP, | |
| '.gif': ImageFormat.GIF, | |
| '.svg': ImageFormat.SVG, | |
| } | |
| image_format = format_map.get(file_extension, ImageFormat.JPEG) | |
| # 创建基础元数据 | |
| metadata = ImageMetadata( | |
| format=image_format, | |
| width=0, # 未知尺寸 | |
| height=0, | |
| file_size=cache_info.file_size | |
| ) | |
| # 创建基础ImageInfo | |
| image_info = ImageInfo( | |
| image_id=cache_key, | |
| filename=file_path.name, | |
| local_path=str(file_path), | |
| source_type=ImageSourceType.LOCAL_STORAGE, | |
| provider=ImageProvider.LOCAL_STORAGE, | |
| metadata=metadata | |
| ) | |
| # 更新访问信息 | |
| cache_info.update_access() | |
| asyncio.create_task(self._save_cache_index()) | |
| logger.debug(f"Cache hit: {cache_key}") | |
| return image_info, file_path | |
| except Exception as e: | |
| logger.error(f"Failed to get cached image {cache_key}: {e}") | |
| return None | |
| async def _discover_cache_info(self, cache_key: str) -> Optional[ImageCacheInfo]: | |
| """Discover a cached image file on disk and rebuild a minimal cache index entry.""" | |
| cache_dirs = [self.ai_generated_dir, self.web_search_dir, self.local_storage_dir] | |
| exts = [".webp", ".jpg", ".jpeg", ".png", ".gif", ".svg", ".bmp", ".tiff", ".tif"] | |
| def _find_file() -> Optional[Path]: | |
| for base_dir in cache_dirs: | |
| if not base_dir.exists(): | |
| continue | |
| for ext in exts: | |
| matches = list(base_dir.glob(f"**/{cache_key}{ext}")) | |
| if matches: | |
| matches.sort(key=lambda p: p.stat().st_mtime, reverse=True) | |
| return matches[0] | |
| return None | |
| try: | |
| found = await asyncio.get_event_loop().run_in_executor(None, _find_file) | |
| if not found or not found.exists(): | |
| return None | |
| stat = found.stat() | |
| cache_info = ImageCacheInfo( | |
| cache_key=cache_key, | |
| file_path=str(found), | |
| file_size=stat.st_size, | |
| created_at=stat.st_ctime, | |
| last_accessed=stat.st_atime, | |
| access_count=1, | |
| expires_at=None, | |
| ) | |
| self._cache_index[cache_key] = cache_info | |
| logger.debug(f"Discovered cache entry from filesystem: {cache_key} -> {found}") | |
| return cache_info | |
| except Exception as e: | |
| logger.warning(f"Failed to discover cache entry for {cache_key}: {e}") | |
| return None | |
| async def is_cached(self, image_info: ImageInfo, image_data: bytes = None) -> Optional[str]: | |
| """检查图片是否已缓存""" | |
| # 如果 image_id 本身就是缓存键,直接校验 | |
| if image_info.image_id: | |
| cache_info = self._cache_index.get(image_info.image_id) | |
| if cache_info and Path(cache_info.file_path).exists(): | |
| return image_info.image_id | |
| # 如果有图片数据,使用内容哈希检查 | |
| if image_data: | |
| cache_key = self._generate_cache_key(image_info, image_data) | |
| cache_info = self._cache_index.get(cache_key) | |
| if cache_info: | |
| file_path = Path(cache_info.file_path) | |
| if file_path.exists(): | |
| return cache_key | |
| # 否则使用传统方式检查 | |
| cache_key = self._generate_cache_key(image_info) | |
| cache_info = self._cache_index.get(cache_key) | |
| if cache_info: | |
| file_path = Path(cache_info.file_path) | |
| if file_path.exists(): | |
| return cache_key | |
| return None | |
| async def remove_from_cache(self, cache_key: str) -> bool: | |
| """从缓存中移除图片""" | |
| try: | |
| cache_info = self._cache_index.get(cache_key) | |
| if not cache_info: | |
| return False | |
| # 删除图片文件 | |
| file_path = Path(cache_info.file_path) | |
| if file_path.exists(): | |
| await asyncio.get_event_loop().run_in_executor(None, file_path.unlink) | |
| # 删除缩略图 | |
| thumbnail_path = self.thumbnails_dir / f"{cache_key}.jpg" | |
| if thumbnail_path.exists(): | |
| await asyncio.get_event_loop().run_in_executor(None, thumbnail_path.unlink) | |
| # 删除元数据 | |
| metadata_path = self.metadata_dir / f"{cache_key}.json" | |
| if metadata_path.exists(): | |
| await asyncio.get_event_loop().run_in_executor(None, metadata_path.unlink) | |
| # 从索引中移除 | |
| del self._cache_index[cache_key] | |
| # 保存索引 | |
| await self._save_cache_index() | |
| logger.info(f"Removed from cache: {cache_key}") | |
| return True | |
| except Exception as e: | |
| logger.error(f"Failed to remove from cache {cache_key}: {e}") | |
| return False | |
| async def _save_image_metadata(self, cache_key: str, image_info: ImageInfo): | |
| """保存图片元数据""" | |
| metadata_path = self.metadata_dir / f"{cache_key}.json" | |
| metadata = image_info.model_dump() | |
| def _save(): | |
| with open(metadata_path, 'w', encoding='utf-8') as f: | |
| json.dump(metadata, f, ensure_ascii=False, indent=2) | |
| await asyncio.get_event_loop().run_in_executor(None, _save) | |
| async def _save_image_metadata_reference(self, content_hash: str, image_info: ImageInfo): | |
| """保存图片元数据引用 - 为同一内容的图片保存多个引用""" | |
| # 创建引用文件名:content_hash_image_id.json | |
| reference_filename = f"{content_hash}_{image_info.image_id}.json" | |
| reference_path = self.metadata_dir / "references" / reference_filename | |
| # 确保引用目录存在 | |
| reference_path.parent.mkdir(exist_ok=True) | |
| metadata = image_info.model_dump() | |
| def _save(): | |
| with open(reference_path, 'w', encoding='utf-8') as f: | |
| json.dump(metadata, f, ensure_ascii=False, indent=2) | |
| await asyncio.get_event_loop().run_in_executor(None, _save) | |
| logger.debug(f"Saved metadata reference: {reference_filename}") | |
| async def _load_image_metadata(self, cache_key: str) -> Optional[ImageInfo]: | |
| """加载图片元数据""" | |
| metadata_path = self.metadata_dir / f"{cache_key}.json" | |
| if not metadata_path.exists(): | |
| # 降低日志级别,避免在PDF转换等场景下产生不必要的警告 | |
| logger.debug(f"Metadata file not found: {metadata_path}") | |
| return None | |
| def _load(): | |
| with open(metadata_path, 'r', encoding='utf-8') as f: | |
| return json.load(f) | |
| try: | |
| metadata = await asyncio.get_event_loop().run_in_executor(None, _load) | |
| image_info = ImageInfo(**metadata) | |
| logger.debug(f"Successfully loaded metadata for cache key: {cache_key}") | |
| return image_info | |
| except Exception as e: | |
| logger.error(f"Failed to load image metadata {cache_key}: {e}") | |
| logger.error(f"Metadata file path: {metadata_path}") | |
| return None | |
| def _load_cache_index(self): | |
| """加载缓存索引 - 简化版本,直接扫描文件系统""" | |
| try: | |
| # 清空现有索引 | |
| self._cache_index.clear() | |
| # 扫描所有缓存目录 | |
| cache_dirs = [ | |
| self.ai_generated_dir, | |
| self.web_search_dir, | |
| self.local_storage_dir | |
| ] | |
| total_files = 0 | |
| for cache_dir in cache_dirs: | |
| if cache_dir.exists(): | |
| # 递归扫描所有图片文件 | |
| for file_path in cache_dir.rglob('*'): | |
| if file_path.is_file() and file_path.suffix.lower() in ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.bmp', '.tiff', '.tif']: | |
| try: | |
| # 使用文件名作为缓存键 | |
| cache_key = file_path.stem | |
| # 创建缓存信息 | |
| stat = file_path.stat() | |
| cache_info = ImageCacheInfo( | |
| cache_key=cache_key, | |
| file_path=str(file_path), | |
| file_size=stat.st_size, | |
| created_at=stat.st_ctime, | |
| last_accessed=stat.st_atime, | |
| access_count=1, | |
| expires_at=None # 永不过期 | |
| ) | |
| self._cache_index[cache_key] = cache_info | |
| total_files += 1 | |
| except Exception as e: | |
| logger.warning(f"Failed to process cache file {file_path}: {e}") | |
| logger.debug(f"Loaded {total_files} cached images from filesystem") | |
| except Exception as e: | |
| logger.error(f"Failed to load cache index: {e}") | |
| # 确保索引不为空,即使出错也继续运行 | |
| if not self._cache_index: | |
| self._cache_index = {} | |
| async def _save_cache_index(self): | |
| """保存缓存索引 - 简化版本,不再保存JSON文件""" | |
| # 不再保存索引文件,直接从文件系统读取 | |
| logger.debug("Cache index save skipped - using filesystem-based indexing") | |
| # 移除清理任务相关方法,因为图片永久有效,无需清理 | |
| async def get_cache_size(self) -> int: | |
| """获取缓存总大小""" | |
| total_size = 0 | |
| for cache_info in self._cache_index.values(): | |
| total_size += cache_info.file_size | |
| return total_size | |
| async def get_cache_stats(self) -> Dict[str, Any]: | |
| """获取缓存统计信息""" | |
| total_entries = len(self._cache_index) | |
| total_size = await self.get_cache_size() | |
| # 按来源类型统计 | |
| source_stats = {} | |
| for cache_info in self._cache_index.values(): | |
| file_path = Path(cache_info.file_path) | |
| source_type = file_path.parent.parent.name if file_path.parent.parent.name in ['ai_generated', 'web_search', 'local_storage'] else 'unknown' | |
| if source_type not in source_stats: | |
| source_stats[source_type] = {'count': 0, 'size': 0} | |
| source_stats[source_type]['count'] += 1 | |
| source_stats[source_type]['size'] += cache_info.file_size | |
| # 转换为API需要的格式 | |
| categories = {} | |
| for source_type, stats in source_stats.items(): | |
| categories[source_type] = stats['count'] | |
| return { | |
| 'total_entries': total_entries, | |
| 'total_size_bytes': total_size, | |
| 'total_size_mb': total_size / (1024 * 1024), | |
| 'categories': categories, | |
| 'source_stats': source_stats | |
| } | |
| async def clear_cache(self, source_type: Optional[ImageSourceType] = None) -> int: | |
| """清空缓存""" | |
| if source_type: | |
| # 清空特定来源的缓存 | |
| keys_to_remove = [] | |
| for cache_key, cache_info in self._cache_index.items(): | |
| file_path = Path(cache_info.file_path) | |
| if source_type.value in str(file_path): | |
| keys_to_remove.append(cache_key) | |
| for cache_key in keys_to_remove: | |
| await self.remove_from_cache(cache_key) | |
| return len(keys_to_remove) | |
| else: | |
| # 清空所有缓存 | |
| keys_to_remove = list(self._cache_index.keys()) | |
| # 先通过索引删除已知的文件 | |
| for cache_key in keys_to_remove: | |
| await self.remove_from_cache(cache_key) | |
| # 然后清理可能存在的孤立文件 | |
| await self._clear_orphaned_files() | |
| return len(keys_to_remove) | |
| async def _clear_orphaned_files(self): | |
| """清理孤立的缓存文件""" | |
| try: | |
| def _clear_directory_contents(directory: Path): | |
| """清理目录中的所有文件,但保留目录结构""" | |
| if not directory.exists(): | |
| return | |
| for item in directory.iterdir(): | |
| if item.is_file(): | |
| item.unlink() | |
| elif item.is_dir(): | |
| # 递归清理子目录 | |
| _clear_directory_contents(item) | |
| # 清理所有缓存目录中的文件 | |
| directories_to_clear = [ | |
| self.ai_generated_dir, | |
| self.web_search_dir, | |
| self.local_storage_dir, | |
| self.metadata_dir, | |
| self.thumbnails_dir | |
| ] | |
| for directory in directories_to_clear: | |
| await asyncio.get_event_loop().run_in_executor(None, _clear_directory_contents, directory) | |
| logger.info("Cleared all orphaned cache files") | |
| except Exception as e: | |
| logger.error(f"Failed to clear orphaned files: {e}") | |
| # 不抛出异常,因为主要的清理已经完成 | |
| async def deduplicate_cache(self) -> int: | |
| """去重缓存中的重复图片""" | |
| try: | |
| content_hash_to_keys = {} | |
| keys_to_remove = [] | |
| # 按内容哈希分组 | |
| for cache_key, cache_info in self._cache_index.items(): | |
| file_path = Path(cache_info.file_path) | |
| if not file_path.exists(): | |
| keys_to_remove.append(cache_key) | |
| continue | |
| try: | |
| # 读取文件内容并计算哈希 | |
| with open(file_path, 'rb') as f: | |
| content = f.read() | |
| content_hash = hashlib.md5(content).hexdigest() | |
| if content_hash not in content_hash_to_keys: | |
| content_hash_to_keys[content_hash] = [] | |
| content_hash_to_keys[content_hash].append((cache_key, cache_info)) | |
| except Exception as e: | |
| logger.warning(f"Failed to read file for deduplication {file_path}: {e}") | |
| keys_to_remove.append(cache_key) | |
| # 对于每个内容哈希,保留最新的一个,删除其他的 | |
| for content_hash, key_info_list in content_hash_to_keys.items(): | |
| if len(key_info_list) > 1: | |
| # 按创建时间排序,保留最新的 | |
| key_info_list.sort(key=lambda x: x[1].created_at, reverse=True) | |
| # 删除除了第一个(最新的)之外的所有条目 | |
| for cache_key, cache_info in key_info_list[1:]: | |
| keys_to_remove.append(cache_key) | |
| logger.info(f"Marking duplicate cache entry for removal: {cache_key}") | |
| # 删除重复和无效的条目 | |
| for cache_key in keys_to_remove: | |
| await self.remove_from_cache(cache_key) | |
| logger.info(f"Removed {len(keys_to_remove)} duplicate/invalid cache entries") | |
| return len(keys_to_remove) | |
| except Exception as e: | |
| logger.error(f"Failed to deduplicate cache: {e}") | |
| return 0 | |