"""Thumbnail generation and caching service.""" import asyncio import hashlib import logging import os import shutil import subprocess from pathlib import Path from typing import Optional logger = logging.getLogger(__name__) # Thumbnail sizes THUMBNAIL_SIZES = { "small": (150, 150), "medium": (300, 300), } # Cache size limit (500MB) CACHE_SIZE_LIMIT = 500 * 1024 * 1024 # 500MB in bytes class ThumbnailService: """Service for generating and caching thumbnails.""" @staticmethod def get_cache_dir() -> Path: """Get the thumbnail cache directory path. Returns: Path to the cache directory (project-local). """ # Store cache in project directory: media-server/.cache/thumbnails/ project_root = Path(__file__).parent.parent.parent cache_dir = project_root / ".cache" / "thumbnails" cache_dir.mkdir(parents=True, exist_ok=True) return cache_dir @staticmethod def get_cache_key(file_path: Path) -> str: """Generate cache key from file path. Args: file_path: Path to the media file. Returns: SHA256 hash of the absolute file path. """ absolute_path = str(file_path.resolve()) return hashlib.sha256(absolute_path.encode()).hexdigest() @staticmethod def get_cached_thumbnail(file_path: Path, size: str) -> Optional[bytes]: """Get cached thumbnail if valid. Args: file_path: Path to the media file. size: Thumbnail size ("small" or "medium"). Returns: Thumbnail bytes if cached and valid, None otherwise. """ cache_dir = ThumbnailService.get_cache_dir() cache_key = ThumbnailService.get_cache_key(file_path) cache_path = cache_dir / cache_key / f"{size}.jpg" if not cache_path.exists(): return None # Check if file has been modified since cache was created try: file_mtime = file_path.stat().st_mtime cache_mtime = cache_path.stat().st_mtime if file_mtime > cache_mtime: logger.debug(f"Cache invalidated for {file_path.name} (file modified)") return None # Read cached thumbnail with open(cache_path, "rb") as f: return f.read() except (OSError, PermissionError) as e: logger.error(f"Error reading cached thumbnail: {e}") return None @staticmethod def cache_thumbnail(file_path: Path, size: str, image_data: bytes) -> None: """Cache a thumbnail. Args: file_path: Path to the media file. size: Thumbnail size ("small" or "medium"). image_data: Thumbnail image data (JPEG bytes). """ cache_dir = ThumbnailService.get_cache_dir() cache_key = ThumbnailService.get_cache_key(file_path) cache_folder = cache_dir / cache_key cache_folder.mkdir(parents=True, exist_ok=True) cache_path = cache_folder / f"{size}.jpg" try: with open(cache_path, "wb") as f: f.write(image_data) logger.debug(f"Cached thumbnail for {file_path.name} ({size})") except (OSError, PermissionError) as e: logger.error(f"Error caching thumbnail: {e}") @staticmethod def generate_audio_thumbnail(file_path: Path, size: str) -> Optional[bytes]: """Generate thumbnail from audio file (extract album art). Args: file_path: Path to the audio file. size: Thumbnail size ("small" or "medium"). Returns: Thumbnail bytes (JPEG) or None if no album art. """ try: import mutagen from mutagen import File as MutagenFile from PIL import Image from io import BytesIO audio = MutagenFile(str(file_path)) if audio is None: return None # Extract album art art_data = None # Try different tag types for album art if hasattr(audio, "pictures") and audio.pictures: # FLAC, Ogg Vorbis art_data = audio.pictures[0].data elif hasattr(audio, "tags"): tags = audio.tags if tags is not None: # MP3 (ID3) if hasattr(tags, "getall"): apic_frames = tags.getall("APIC") if apic_frames: art_data = apic_frames[0].data # MP4/M4A elif "covr" in tags: art_data = bytes(tags["covr"][0]) # Try other common keys elif "APIC:" in tags: art_data = tags["APIC:"].data if art_data is None: return None # Resize image img = Image.open(BytesIO(art_data)) # Convert to RGB if necessary (handle RGBA, grayscale, etc.) if img.mode not in ("RGB", "L"): img = img.convert("RGB") # Resize with maintaining aspect ratio and center crop target_size = THUMBNAIL_SIZES[size] img.thumbnail((target_size[0] * 2, target_size[1] * 2), Image.Resampling.LANCZOS) # Center crop to square width, height = img.size min_dim = min(width, height) left = (width - min_dim) // 2 top = (height - min_dim) // 2 right = left + min_dim bottom = top + min_dim img = img.crop((left, top, right, bottom)) # Final resize img = img.resize(target_size, Image.Resampling.LANCZOS) # Save as JPEG output = BytesIO() img.save(output, format="JPEG", quality=85, optimize=True) return output.getvalue() except ImportError: logger.error("Required libraries (mutagen, Pillow) not installed") return None except Exception as e: logger.debug(f"Error generating audio thumbnail for {file_path.name}: {e}") return None @staticmethod async def generate_video_thumbnail(file_path: Path, size: str) -> Optional[bytes]: """Generate thumbnail from video file using ffmpeg. Args: file_path: Path to the video file. size: Thumbnail size ("small" or "medium"). Returns: Thumbnail bytes (JPEG) or None if ffmpeg not available. """ try: from PIL import Image from io import BytesIO # Check if ffmpeg is available if not shutil.which("ffmpeg"): logger.debug("ffmpeg not available, cannot generate video thumbnail") return None # Extract frame at 10% duration target_size = THUMBNAIL_SIZES[size] # Use ffmpeg to extract a frame cmd = [ "ffmpeg", "-i", str(file_path), "-vf", f"thumbnail,scale={target_size[0]}:{target_size[1]}:force_original_aspect_ratio=increase,crop={target_size[0]}:{target_size[1]}", "-frames:v", "1", "-f", "image2pipe", "-vcodec", "mjpeg", "-" ] # Run ffmpeg with timeout process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, ) try: stdout, _ = await asyncio.wait_for(process.communicate(), timeout=10.0) if process.returncode == 0 and stdout: # ffmpeg output is already JPEG, but let's ensure proper quality img = Image.open(BytesIO(stdout)) # Convert to RGB if necessary if img.mode != "RGB": img = img.convert("RGB") # Save as JPEG with consistent quality output = BytesIO() img.save(output, format="JPEG", quality=85, optimize=True) return output.getvalue() except asyncio.TimeoutError: logger.warning(f"ffmpeg timeout for {file_path.name}") process.kill() await process.wait() return None except ImportError: logger.error("Pillow library not installed") return None except Exception as e: logger.debug(f"Error generating video thumbnail for {file_path.name}: {e}") return None @staticmethod async def get_thumbnail(file_path: Path, size: str = "medium") -> Optional[bytes]: """Get thumbnail for a media file (from cache or generate). Args: file_path: Path to the media file. size: Thumbnail size ("small" or "medium"). Returns: Thumbnail bytes (JPEG) or None if unavailable. """ from .browser_service import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS # Validate size if size not in THUMBNAIL_SIZES: size = "medium" # Check cache first cached = ThumbnailService.get_cached_thumbnail(file_path, size) if cached: return cached # Generate thumbnail based on file type suffix = file_path.suffix.lower() thumbnail_data = None if suffix in AUDIO_EXTENSIONS: # Audio files - run in executor (sync operation) loop = asyncio.get_event_loop() thumbnail_data = await loop.run_in_executor( None, ThumbnailService.generate_audio_thumbnail, file_path, size, ) elif suffix in VIDEO_EXTENSIONS: # Video files - already async thumbnail_data = await ThumbnailService.generate_video_thumbnail(file_path, size) # Cache if generated successfully if thumbnail_data: ThumbnailService.cache_thumbnail(file_path, size, thumbnail_data) return thumbnail_data @staticmethod def cleanup_cache() -> None: """Clean up cache if it exceeds size limit. Removes oldest thumbnails by access time. """ cache_dir = ThumbnailService.get_cache_dir() try: # Calculate total cache size total_size = 0 cache_items = [] for folder in cache_dir.iterdir(): if folder.is_dir(): for file in folder.iterdir(): if file.is_file(): stat = file.stat() total_size += stat.st_size cache_items.append((file, stat.st_atime, stat.st_size)) # If cache is within limit, no cleanup needed if total_size <= CACHE_SIZE_LIMIT: return logger.info(f"Cache size {total_size / 1024 / 1024:.2f}MB exceeds limit, cleaning up...") # Sort by access time (oldest first) cache_items.sort(key=lambda x: x[1]) # Remove oldest items until under limit for file, _, size in cache_items: if total_size <= CACHE_SIZE_LIMIT: break try: file.unlink() total_size -= size logger.debug(f"Removed cached thumbnail: {file}") # Remove empty parent folder parent = file.parent if parent != cache_dir and not any(parent.iterdir()): parent.rmdir() except (OSError, PermissionError) as e: logger.error(f"Error removing cache file: {e}") logger.info(f"Cache cleanup complete, new size: {total_size / 1024 / 1024:.2f}MB") except Exception as e: logger.error(f"Error during cache cleanup: {e}")