bcc6d40ed7
Lint & Test / test (push) Successful in 20s
Security
- Default bind 127.0.0.1; first-run bootstrap generates random api_token
and refuses to bind non-loopback without auth unless explicitly opted in
- Path-traversal hardened: BrowserService.validate_path rejects absolute
paths, drive letters, UNC, NUL bytes. /api/browser/{play,metadata,
thumbnail} now require folder_id and a folder-relative path
- Pydantic validators on links: http(s) URLs only, mdi:<slug> icons only
- Scripts/callbacks/links create/update/delete gated by *_management flags
- Strict CSP, X-Frame-Options DENY, Referrer-Policy no-referrer,
X-Content-Type-Options nosniff
- CORS locked to localhost:<port> + 127.0.0.1:<port> by default; configurable
- config.yaml writes atomic (tmp + os.replace) and 0o600 on POSIX
- Subprocesses spawned in their own process group / new session so timeout
kills the whole tree (Windows CREATE_NEW_PROCESS_GROUP, POSIX
start_new_session=True)
- Frontend XSS: monitor name + details escapeHtml'd; power button moved to
delegated data-action handler; remote MDI SVGs parsed and sanitized
(strip script/foreignObject/on*/javascript: hrefs) before innerHTML
- All dynamic URL segments now wrapped in encodeURIComponent
Bugs
- WebSocket reconnect: close previous socket before opening new, clear
ping interval per-socket, clear reconnectTimeout up-front, retry on
online/visibilitychange, try/catch JSON.parse
- Artwork fetch race: AbortController + generation guard
- _broadcast_after_open: initialize status, swallow per-poll errors,
background tasks tracked in a strong-ref set with done-callback cleanup
- Audio analyzer: sticky _unavailable flag prevents infinite start/stop
spin when no loopback device exists; cleared by set_device()
- Volume short-circuit cache invalidated when server reports remote volume
- Browser thumbnail race: per-folder generation counter + isConnected
checks; aborts in-flight fetches on navigation
- Track-skip uses cached title instead of full WinRT status round-trip
Performance
- Linux MPRIS/pactl and /api/display DDC-CI handlers wrapped in
asyncio.to_thread so blocking IO never stalls the event loop
- browse_directory moved off the event loop (SMB shares could freeze it)
- Windows status poll caches one asyncio loop per worker thread via
threading.local instead of new_event_loop/close on every 0.5s tick
- broadcast() serializes JSON once and uses send_text to all clients
- Hourly thumbnail cache cleanup scheduled in lifespan (was never invoked
— cache grew unbounded)
- Progress drag listeners attached only while dragging
Quality
- All asyncio.get_event_loop() in coroutines → get_running_loop()
- ThreadPoolExecutors shut down cleanly during lifespan teardown
- config_manager dedup: 12 near-identical methods collapsed onto generic
_upsert/_delete helpers (~290 lines removed)
- Service worker no longer pass-throughs every fetch
- M3U playlist written via NamedTemporaryFile (no fixed-path symlink
clobber race)
- __version__ now prefers live pyproject.toml in dev checkouts so
pip install -e . users see the source-of-truth version, not the stale
package-metadata version baked in at install time
UI/UX (Studio Reference)
- Green leftover focus rings (rgba(29,185,84,...)) all replaced with
copper accent (rgba(var(--copper-rgb),...))
- Dialogs: square corners, copper top hairline, unified with editorial
chrome
- .browser-item: transparent with copper hover border (was filled card)
- Audio device select uses var(--sans) instead of generic system font
- Mobile container padding tuned for ≤480px screens
- Breadcrumb home is a real <button> with aria-label; aria-current on root
- i18n: filled display.msg.power_*, execution.*, scripts.params.execute,
callbacks.empty in both en + ru
395 lines
13 KiB
Python
395 lines
13 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:
|
|
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()
|
|
|
|
# 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}")
|