Files
media-player-server/media_server/services/thumbnail_service.py
alexei.dolgolyov 7c631d09f6 Add media browser feature with UI improvements
- Refactored index.html: Split into separate HTML (309 lines), CSS (908 lines), and JS (1,286 lines) files
- Implemented media browser with folder configuration, recursive navigation, and thumbnail display
- Added metadata extraction using mutagen library (title, artist, album, duration, bitrate, codec)
- Implemented thumbnail generation and caching with SHA256 hash-based keys and LRU eviction
- Added platform-specific file playback (os.startfile on Windows, xdg-open on Linux, open on macOS)
- Implemented path validation security to prevent directory traversal attacks
- Added smooth thumbnail loading with fade-in animation and loading spinner
- Added i18n support for browser (English and Russian)
- Updated dependencies: mutagen>=1.47.0, pillow>=10.0.0
- Added comprehensive media browser documentation to README

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 21:31:02 +03:00

360 lines
12 KiB
Python

"""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}")