Files
media-player-server/media_server/services/thumbnail_service.py
T
alexei.dolgolyov d131ba461c
Lint & Test / test (push) Successful in 20s
fix: production-readiness hardening — security, perf, a11y, observability
Security
- Default scripts_management, callbacks_management, links_management, and
  media_folders_management to False so a leaked token cannot escalate to RCE
  through admin CRUD endpoints.
- TokenSpec + scope hierarchy (read | control | admin); legacy bare-string
  api_tokens entries promote to admin for back-compat. Management endpoints
  now require admin scope.
- WebSocket subprotocol auth (Sec-WebSocket-Protocol: media-server.token.<T>)
  preferred over ?token= query so the token no longer lands in URL/history/
  Referer; query fallback retained for HA integration back-compat.
- Origin allow-list check on the WS endpoint (CSWSH defence).
- In-process token-bucket rate limiter: 5/min for failed auths,
  10/min for /api/scripts/execute and /api/callbacks/execute.
- shell=False subprocess path (shlex.split) + per-parameter regex `pattern`
  in ScriptParameterConfig to harden shell=true scripts against parameter
  injection (Windows cmd.exe env-var expansion).
- CSP gains form-action, worker-src, manifest-src directives.
- Refuse cors_origins=["*"] at startup; strip token=... from uvicorn access
  logs; validate Gitea release tag against strict SemVer regex.
- noopener noreferrer + no-referrer referrerpolicy on every outbound link.
- icacls hardening of config.yaml on Windows (current user + SYSTEM +
  Administrators only); 0600 still enforced on POSIX.
- WS volume handler clamps input and never drops the socket on bad messages.

Performance
- Album-art read in windows_media gated by track key — was decoding the
  WinRT thumbnail twice per second regardless of track changes.
- /api/media/artwork returns content-derived ETag + Cache-Control so the
  browser sends If-None-Match and gets 304s on track repeats.
- Foreground-service ctypes argtypes hoisted to one-time module init
  (was re-declaring ~14 prototypes per probe).
- display_service _static_cache keyed by (edid_hash, ...) tuple with
  eviction of disappeared monitors — fixes stale capabilities on hot-plug
  swaps where the new topology has the same monitor count.
- Visualizer rAF loop paused on document.hidden, resumed on visible.

Reliability / bug fixes
- Lifespan rewritten as try/yield/finally so a partial-startup failure
  cannot orphan background tasks or executors.
- _run_callback in routes/media.py keeps a strong task ref (GC-safe) and
  uses the dedicated callback executor instead of the default pool.
- macos_media.set_volume() no longer always returns True.
- TrayManager._restart_requested initialised in __init__; set before
  signalling exit so the main thread observes it correctly.
- Missing static_dir now logs a WARNING instead of silent UI disable.

UX / accessibility / PWA
- manifest.json theme_color and background_color match the Studio Reference
  base (#0E0D0B); added id and scope for PWA installability.
- ARIA on mini-player icon buttons; inner SVGs marked aria-hidden.
- OS mediaSession API wired so headset / lockscreen / Bluetooth buttons
  drive play/pause/next/prev/seek and show track metadata + artwork.

Observability
- X-Request-ID middleware (accept upstream id if it matches a safe regex,
  otherwise UUID4); request_id_var added to ContextVars and included in
  every log line alongside the token label.
- Audit log (append-only JSONL) for every script + callback execution,
  including the on_play/on_pause/etc. event callbacks. Background-thread
  writer; queue capped; flushed in lifespan teardown.

Deployment
- proxy_headers + forwarded_allow_ips plumbed through Settings →
  uvicorn.Config for reverse-proxy installs.
- HTTPS support via ssl_certfile + ssl_keyfile (+ optional password);
  startup refuses to launch with only one of the pair set.
- Thumbnail cache moved from project-root .cache to
  %LOCALAPPDATA%/media-server/cache (Windows) and
  $XDG_CACHE_HOME/media-server/thumbnails (POSIX).

Tests
- 35 new tests across auth scopes, rate limiter, browser path traversal
  (../ NUL UNC absolute), script-param validation incl. regex, Gitea tag
  whitelist, config atomic write + POSIX perms. 47 passed / 4 skipped.
2026-05-22 22:25:54 +03:00

406 lines
14 KiB
Python

"""Thumbnail generation and caching service."""
import asyncio
import hashlib
import logging
import shutil
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 user-writable platform cache dir so installs under
``%PROGRAMFILES%`` / ``/opt`` work without elevated permissions.
Mirrors the platform branching of ``config.get_config_dir``.
"""
import os
if os.name == "nt":
# %LOCALAPPDATA% so the cache survives roaming-profile sync.
base = Path(os.environ.get("LOCALAPPDATA")
or os.environ.get("APPDATA")
or Path.home() / "AppData" / "Local")
cache_dir = base / "media-server" / "cache" / "thumbnails"
else:
# XDG_CACHE_HOME convention; falls back to ~/.cache.
xdg = os.environ.get("XDG_CACHE_HOME")
base = Path(xdg) if xdg else Path.home() / ".cache"
cache_dir = base / "media-server" / "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()
# Sentinel value indicating "no thumbnail available" is cached
NO_THUMBNAIL = b""
@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, empty bytes if cached as "no thumbnail",
None if not cached.
"""
cache_dir = ThumbnailService.get_cache_dir()
cache_key = ThumbnailService.get_cache_key(file_path)
cache_folder = cache_dir / cache_key
cache_path = cache_folder / f"{size}.jpg"
no_thumb_path = cache_folder / ".no_thumbnail"
# Check negative cache first (no thumbnail available)
if no_thumb_path.exists():
try:
file_mtime = file_path.stat().st_mtime
cache_mtime = no_thumb_path.stat().st_mtime
if file_mtime <= cache_mtime:
return ThumbnailService.NO_THUMBNAIL
except (OSError, PermissionError):
pass
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 cache_no_thumbnail(file_path: Path) -> None:
"""Cache that a file has no thumbnail (negative cache)."""
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)
try:
(cache_folder / ".no_thumbnail").touch()
logger.debug(f"Cached no-thumbnail for {file_path.name}")
except (OSError, PermissionError) as e:
logger.error(f"Error caching no-thumbnail marker: {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:
from io import BytesIO
from mutagen import File as MutagenFile
from PIL import Image
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 io import BytesIO
from PIL import Image
# 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]}"
f":force_original_aspect_ratio=increase"
f",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 (returns bytes, empty bytes for negative cache, or None)
cached = ThumbnailService.get_cached_thumbnail(file_path, size)
if cached is not None:
return cached if cached else None
# 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_running_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 result (positive or negative)
if thumbnail_data:
ThumbnailService.cache_thumbnail(file_path, size, thumbnail_data)
else:
ThumbnailService.cache_no_thumbnail(file_path)
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}")