import asyncio import logging import shutil import subprocess import time from pathlib import Path from infrastructure.cache.memory_cache import CacheInterface from infrastructure.cache.cache_keys import AUDIODB_PREFIX from infrastructure.persistence import LibraryDB from infrastructure.cache.disk_cache import DiskMetadataCache from api.v1.schemas.cache import CacheStats, CacheClearResponse logger = logging.getLogger(__name__) def get_covers_cache_dir() -> Path: from core.config import get_settings return get_settings().cache_dir / "covers" class CacheService: def __init__(self, cache: CacheInterface, library_db: LibraryDB, disk_cache: DiskMetadataCache): self._cache = cache self._library_db = library_db self._disk_cache = disk_cache self._cached_stats: CacheStats | None = None self._stats_cache_time: float = 0.0 self._stats_cache_ttl: float = 30.0 self._stats_lock = asyncio.Lock() def _clear_genre_disk_cache(self) -> int: try: from core.dependencies import get_home_service return get_home_service().clear_genre_disk_cache() except Exception: # noqa: BLE001 logger.debug("Genre disk cache cleanup skipped (home service unavailable)") return 0 async def get_stats(self) -> CacheStats: covers_cache_dir = get_covers_cache_dir() async with self._stats_lock: now = time.time() if self._cached_stats and (now - self._stats_cache_time) < self._stats_cache_ttl: return self._cached_stats memory_entries = self._cache.size() memory_bytes = self._cache.estimate_memory_bytes() memory_mb = memory_bytes / (1024 * 1024) metadata_stats = self._disk_cache.get_stats() metadata_count = metadata_stats['total_count'] metadata_albums = metadata_stats['album_count'] metadata_artists = metadata_stats['artist_count'] disk_count = 0 disk_bytes = 0 if covers_cache_dir.exists(): du_available = shutil.which('du') is not None if du_available: try: result = await asyncio.to_thread( subprocess.run, ['du', '-sb', str(covers_cache_dir)], capture_output=True, text=True, timeout=5.0, ) if result.returncode == 0: disk_bytes = int(result.stdout.split()[0]) result = await asyncio.to_thread( subprocess.run, ['find', str(covers_cache_dir), '-type', 'f'], capture_output=True, text=True, timeout=5.0, ) if result.returncode == 0: lines = result.stdout.strip() disk_count = len(lines.split('\n')) if lines else 0 else: du_available = False except (subprocess.TimeoutExpired, subprocess.SubprocessError, ValueError) as e: logger.warning(f"Subprocess disk stats failed, falling back to Python: {e}") du_available = False if not du_available: def _python_scan() -> tuple[int, int]: count = 0 total = 0 for file_path in covers_cache_dir.rglob("*"): if file_path.is_file(): count += 1 total += file_path.stat().st_size return count, total disk_count, disk_bytes = await asyncio.to_thread(_python_scan) disk_mb = disk_bytes / (1024 * 1024) lib_stats = await self._library_db.get_stats() lib_bytes = lib_stats['db_size_bytes'] lib_mb = lib_bytes / (1024 * 1024) total_bytes = memory_bytes + disk_bytes + lib_bytes total_mb = total_bytes / (1024 * 1024) stats = CacheStats( memory_entries=memory_entries, memory_size_bytes=memory_bytes, memory_size_mb=round(memory_mb, 2), disk_metadata_count=metadata_count, disk_metadata_albums=metadata_albums, disk_metadata_artists=metadata_artists, disk_cover_count=disk_count, disk_cover_size_bytes=disk_bytes, disk_cover_size_mb=round(disk_mb, 2), library_db_artist_count=lib_stats['artist_count'], library_db_album_count=lib_stats['album_count'], library_db_size_bytes=lib_bytes, library_db_size_mb=round(lib_mb, 2), library_db_last_sync=lib_stats.get('last_sync'), total_size_bytes=total_bytes, total_size_mb=round(total_mb, 2), disk_audiodb_artist_count=metadata_stats.get('audiodb_artist_count', 0), disk_audiodb_album_count=metadata_stats.get('audiodb_album_count', 0), ) self._cached_stats = stats self._stats_cache_time = now return stats async def clear_memory_cache(self) -> CacheClearResponse: try: entries_before = self._cache.size() await self._cache.clear() self._cached_stats = None return CacheClearResponse( success=True, message=f"Successfully cleared {entries_before} memory cache entries", cleared_memory_entries=entries_before, cleared_disk_files=0 ) except Exception as e: # noqa: BLE001 logger.error(f"Failed to clear memory cache: {e}") return CacheClearResponse( success=False, message=f"Failed to clear memory cache: {str(e)}", cleared_memory_entries=0, cleared_disk_files=0 ) async def clear_disk_cache(self) -> CacheClearResponse: covers_cache_dir = get_covers_cache_dir() try: metadata_stats = self._disk_cache.get_stats() metadata_count = metadata_stats['total_count'] await self._disk_cache.clear_all() files_cleared = 0 if covers_cache_dir.exists(): for file_path in covers_cache_dir.rglob("*"): if file_path.is_file(): file_path.unlink() files_cleared += 1 files_cleared += self._clear_genre_disk_cache() self._cached_stats = None return CacheClearResponse( success=True, message=f"Successfully cleared {metadata_count} metadata files and {files_cleared} cover images from disk", cleared_memory_entries=0, cleared_disk_files=files_cleared + metadata_count ) except Exception as e: # noqa: BLE001 logger.error(f"Failed to clear disk cache: {e}") return CacheClearResponse( success=False, message=f"Failed to clear disk cache: {str(e)}", cleared_memory_entries=0, cleared_disk_files=0 ) async def clear_all_cache(self) -> CacheClearResponse: covers_cache_dir = get_covers_cache_dir() try: memory_entries = self._cache.size() await self._cache.clear() metadata_stats = self._disk_cache.get_stats() metadata_count = metadata_stats['total_count'] await self._disk_cache.clear_all() disk_files = 0 if covers_cache_dir.exists(): for file_path in covers_cache_dir.rglob("*"): if file_path.is_file(): file_path.unlink() disk_files += 1 disk_files += self._clear_genre_disk_cache() self._cached_stats = None return CacheClearResponse( success=True, message=f"Successfully cleared {memory_entries} memory entries, {metadata_count} metadata files, and {disk_files} cover files (library database preserved)", cleared_memory_entries=memory_entries, cleared_disk_files=disk_files + metadata_count ) except Exception as e: # noqa: BLE001 logger.error(f"Failed to clear all cache: {e}") return CacheClearResponse( success=False, message=f"Couldn't clear the cache: {str(e)}", cleared_memory_entries=0, cleared_disk_files=0 ) async def clear_covers_cache(self) -> CacheClearResponse: covers_cache_dir = get_covers_cache_dir() try: files_cleared = 0 if covers_cache_dir.exists(): for file_path in covers_cache_dir.rglob("*"): if file_path.is_file(): file_path.unlink() files_cleared += 1 self._cached_stats = None return CacheClearResponse( success=True, message=f"Successfully cleared {files_cleared} cover images", cleared_memory_entries=0, cleared_disk_files=files_cleared ) except Exception as e: # noqa: BLE001 logger.error(f"Failed to clear covers cache: {e}") return CacheClearResponse( success=False, message=f"Failed to clear covers cache: {str(e)}", cleared_memory_entries=0, cleared_disk_files=0 ) async def clear_library_cache(self) -> CacheClearResponse: try: lib_stats = await self._library_db.get_stats() artists_before = lib_stats['artist_count'] albums_before = lib_stats['album_count'] await self._library_db.clear() self._cached_stats = None return CacheClearResponse( success=True, message=f"Successfully cleared library database: {artists_before} artists, {albums_before} albums", cleared_memory_entries=0, cleared_disk_files=0, cleared_library_artists=artists_before, cleared_library_albums=albums_before ) except Exception as e: # noqa: BLE001 logger.error(f"Failed to clear library cache: {e}") return CacheClearResponse( success=False, message=f"Failed to clear library cache: {str(e)}", cleared_memory_entries=0, cleared_disk_files=0 ) async def clear_audiodb(self) -> CacheClearResponse: try: stats_before = self._disk_cache.get_stats() count_before = stats_before.get('audiodb_artist_count', 0) + stats_before.get('audiodb_album_count', 0) await self._disk_cache.clear_audiodb() memory_cleared = await self._cache.clear_prefix(AUDIODB_PREFIX) self._cached_stats = None return CacheClearResponse( success=True, message=f"Successfully cleared {count_before} AudioDB cache entries and {memory_cleared} memory entries", cleared_memory_entries=memory_cleared, cleared_disk_files=count_before, ) except Exception as e: # noqa: BLE001 logger.error(f"Failed to clear AudioDB cache: {e}") return CacheClearResponse( success=False, message=f"Failed to clear AudioDB cache: {str(e)}", cleared_memory_entries=0, cleared_disk_files=0, )