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>
This commit is contained in:
216
media_server/services/browser_service.py
Normal file
216
media_server/services/browser_service.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Browser service for media file browsing and path validation."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Media file extensions
|
||||
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".flac", ".wav", ".ogg", ".aac", ".wma", ".opus"}
|
||||
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm", ".flv"}
|
||||
MEDIA_EXTENSIONS = AUDIO_EXTENSIONS | VIDEO_EXTENSIONS
|
||||
|
||||
|
||||
class BrowserService:
|
||||
"""Service for browsing media files with path validation."""
|
||||
|
||||
@staticmethod
|
||||
def validate_path(folder_id: str, requested_path: str) -> Path:
|
||||
"""Validate and resolve a path within an allowed folder.
|
||||
|
||||
Args:
|
||||
folder_id: ID of the configured media folder.
|
||||
requested_path: Path to validate (relative to folder root or absolute).
|
||||
|
||||
Returns:
|
||||
Resolved absolute Path object.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder_id invalid or path traversal attempted.
|
||||
FileNotFoundError: If path does not exist.
|
||||
"""
|
||||
# Get folder config
|
||||
if folder_id not in settings.media_folders:
|
||||
raise ValueError(f"Media folder '{folder_id}' not configured")
|
||||
|
||||
folder_config = settings.media_folders[folder_id]
|
||||
if not folder_config.enabled:
|
||||
raise ValueError(f"Media folder '{folder_id}' is disabled")
|
||||
|
||||
# Get base path
|
||||
base_path = Path(folder_config.path).resolve()
|
||||
if not base_path.exists():
|
||||
raise FileNotFoundError(f"Media folder path does not exist: {base_path}")
|
||||
if not base_path.is_dir():
|
||||
raise ValueError(f"Media folder path is not a directory: {base_path}")
|
||||
|
||||
# Handle relative vs absolute paths
|
||||
if requested_path.startswith("/") or requested_path.startswith("\\"):
|
||||
# Relative to folder root (remove leading slash)
|
||||
requested_path = requested_path.lstrip("/\\")
|
||||
|
||||
# Build and resolve full path
|
||||
if requested_path:
|
||||
full_path = (base_path / requested_path).resolve()
|
||||
else:
|
||||
full_path = base_path
|
||||
|
||||
# Security check: Ensure resolved path is within base path
|
||||
try:
|
||||
full_path.relative_to(base_path)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Path traversal attempt detected: {requested_path} "
|
||||
f"(resolved to {full_path}, base: {base_path})"
|
||||
)
|
||||
raise ValueError("Path traversal not allowed")
|
||||
|
||||
# Check if path exists
|
||||
if not full_path.exists():
|
||||
raise FileNotFoundError(f"Path does not exist: {requested_path}")
|
||||
|
||||
return full_path
|
||||
|
||||
@staticmethod
|
||||
def is_media_file(path: Path) -> bool:
|
||||
"""Check if a file is a media file based on extension.
|
||||
|
||||
Args:
|
||||
path: Path to check.
|
||||
|
||||
Returns:
|
||||
True if file is a media file, False otherwise.
|
||||
"""
|
||||
return path.suffix.lower() in MEDIA_EXTENSIONS
|
||||
|
||||
@staticmethod
|
||||
def get_file_type(path: Path) -> str:
|
||||
"""Get the file type (folder, audio, video, other).
|
||||
|
||||
Args:
|
||||
path: Path to check.
|
||||
|
||||
Returns:
|
||||
File type string: "folder", "audio", "video", or "other".
|
||||
"""
|
||||
if path.is_dir():
|
||||
return "folder"
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in AUDIO_EXTENSIONS:
|
||||
return "audio"
|
||||
elif suffix in VIDEO_EXTENSIONS:
|
||||
return "video"
|
||||
else:
|
||||
return "other"
|
||||
|
||||
@staticmethod
|
||||
def browse_directory(
|
||||
folder_id: str,
|
||||
path: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
) -> dict:
|
||||
"""Browse a directory and return items with metadata.
|
||||
|
||||
Args:
|
||||
folder_id: ID of the configured media folder.
|
||||
path: Path to browse (relative to folder root).
|
||||
offset: Pagination offset (default: 0).
|
||||
limit: Maximum items to return (default: 100).
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- current_path: Current path (relative to folder root)
|
||||
- parent_path: Parent path (None if at root)
|
||||
- items: List of file/folder items
|
||||
- total: Total number of items
|
||||
- offset: Current offset
|
||||
- limit: Current limit
|
||||
- folder_id: Folder ID
|
||||
|
||||
Raises:
|
||||
ValueError: If path validation fails.
|
||||
FileNotFoundError: If path does not exist.
|
||||
"""
|
||||
# Validate path
|
||||
full_path = BrowserService.validate_path(folder_id, path)
|
||||
|
||||
# Get base path for relative path calculation
|
||||
folder_config = settings.media_folders[folder_id]
|
||||
base_path = Path(folder_config.path).resolve()
|
||||
|
||||
# Check if it's a directory
|
||||
if not full_path.is_dir():
|
||||
raise ValueError(f"Path is not a directory: {path}")
|
||||
|
||||
# Calculate relative path
|
||||
try:
|
||||
relative_path = full_path.relative_to(base_path)
|
||||
current_path = "/" + str(relative_path).replace("\\", "/") if str(relative_path) != "." else "/"
|
||||
except ValueError:
|
||||
current_path = "/"
|
||||
|
||||
# Calculate parent path
|
||||
if full_path == base_path:
|
||||
parent_path = None
|
||||
else:
|
||||
parent_relative = full_path.parent.relative_to(base_path)
|
||||
parent_path = "/" + str(parent_relative).replace("\\", "/") if str(parent_relative) != "." else "/"
|
||||
|
||||
# List directory contents
|
||||
try:
|
||||
all_items = []
|
||||
for item in full_path.iterdir():
|
||||
# Skip hidden files (starting with .)
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
|
||||
# Get file type
|
||||
file_type = BrowserService.get_file_type(item)
|
||||
|
||||
# Skip non-media files (but include folders)
|
||||
if file_type == "other" and not item.is_dir():
|
||||
continue
|
||||
|
||||
# Get file info
|
||||
try:
|
||||
stat = item.stat()
|
||||
size = stat.st_size if item.is_file() else None
|
||||
modified = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
except (OSError, PermissionError):
|
||||
size = None
|
||||
modified = None
|
||||
|
||||
all_items.append({
|
||||
"name": item.name,
|
||||
"type": file_type,
|
||||
"size": size,
|
||||
"modified": modified,
|
||||
"is_media": file_type in ("audio", "video"),
|
||||
})
|
||||
|
||||
# Sort: folders first, then by name
|
||||
all_items.sort(key=lambda x: (x["type"] != "folder", x["name"].lower()))
|
||||
|
||||
# Apply pagination
|
||||
total = len(all_items)
|
||||
items = all_items[offset:offset + limit]
|
||||
|
||||
return {
|
||||
"folder_id": folder_id,
|
||||
"current_path": current_path,
|
||||
"parent_path": parent_path,
|
||||
"items": items,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
except PermissionError:
|
||||
raise ValueError(f"Permission denied accessing path: {path}")
|
||||
@@ -293,3 +293,27 @@ class LinuxMediaController(MediaController):
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to seek: {e}")
|
||||
return False
|
||||
|
||||
async def open_file(self, file_path: str) -> bool:
|
||||
"""Open a media file with the default system player (Linux).
|
||||
|
||||
Uses xdg-open to open the file with the default application.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the media file
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
'xdg-open', file_path,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL
|
||||
)
|
||||
await process.wait()
|
||||
logger.info(f"Opened file with default player: {file_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
@@ -294,3 +294,27 @@ class MacOSMediaController(MediaController):
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def open_file(self, file_path: str) -> bool:
|
||||
"""Open a media file with the default system player (macOS).
|
||||
|
||||
Uses the 'open' command to open the file with the default application.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the media file
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
'open', file_path,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL
|
||||
)
|
||||
await process.wait()
|
||||
logger.info(f"Opened file with default player: {file_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
@@ -94,3 +94,15 @@ class MediaController(ABC):
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def open_file(self, file_path: str) -> bool:
|
||||
"""Open a media file with the default system player.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the media file
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
194
media_server/services/metadata_service.py
Normal file
194
media_server/services/metadata_service.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Metadata extraction service for media files."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetadataService:
|
||||
"""Service for extracting metadata from media files."""
|
||||
|
||||
@staticmethod
|
||||
def extract_audio_metadata(file_path: Path) -> dict:
|
||||
"""Extract metadata from an audio file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the audio file.
|
||||
|
||||
Returns:
|
||||
Dictionary with audio metadata.
|
||||
"""
|
||||
try:
|
||||
import mutagen
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
audio = MutagenFile(str(file_path), easy=True)
|
||||
if audio is None:
|
||||
return {"error": "Unable to read audio file"}
|
||||
|
||||
metadata = {
|
||||
"type": "audio",
|
||||
"filename": file_path.name,
|
||||
"path": str(file_path),
|
||||
}
|
||||
|
||||
# Extract duration
|
||||
if hasattr(audio.info, "length"):
|
||||
metadata["duration"] = round(audio.info.length, 2)
|
||||
|
||||
# Extract bitrate
|
||||
if hasattr(audio.info, "bitrate"):
|
||||
metadata["bitrate"] = audio.info.bitrate
|
||||
|
||||
# Extract sample rate
|
||||
if hasattr(audio.info, "sample_rate"):
|
||||
metadata["sample_rate"] = audio.info.sample_rate
|
||||
elif hasattr(audio.info, "samplerate"):
|
||||
metadata["sample_rate"] = audio.info.samplerate
|
||||
|
||||
# Extract channels
|
||||
if hasattr(audio.info, "channels"):
|
||||
metadata["channels"] = audio.info.channels
|
||||
|
||||
# Extract tags (use easy=True for consistent tag names)
|
||||
if audio is not None and hasattr(audio, "tags") and audio.tags:
|
||||
# Easy tags provide lists, so we take the first item
|
||||
tags = audio.tags
|
||||
|
||||
if "title" in tags:
|
||||
metadata["title"] = tags["title"][0] if isinstance(tags["title"], list) else tags["title"]
|
||||
|
||||
if "artist" in tags:
|
||||
metadata["artist"] = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"]
|
||||
|
||||
if "album" in tags:
|
||||
metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"]
|
||||
|
||||
if "albumartist" in tags:
|
||||
metadata["album_artist"] = tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"]
|
||||
|
||||
if "date" in tags:
|
||||
metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"]
|
||||
|
||||
if "genre" in tags:
|
||||
metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"]
|
||||
|
||||
if "tracknumber" in tags:
|
||||
metadata["track_number"] = tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"]
|
||||
|
||||
# If no title tag, use filename
|
||||
if "title" not in metadata:
|
||||
metadata["title"] = file_path.stem
|
||||
|
||||
return metadata
|
||||
|
||||
except ImportError:
|
||||
logger.error("mutagen library not installed, cannot extract metadata")
|
||||
return {"error": "mutagen library not installed"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting audio metadata from {file_path}: {e}")
|
||||
return {
|
||||
"error": str(e),
|
||||
"filename": file_path.name,
|
||||
"title": file_path.stem,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def extract_video_metadata(file_path: Path) -> dict:
|
||||
"""Extract basic metadata from a video file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the video file.
|
||||
|
||||
Returns:
|
||||
Dictionary with video metadata.
|
||||
"""
|
||||
try:
|
||||
import mutagen
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
video = MutagenFile(str(file_path))
|
||||
if video is None:
|
||||
return {
|
||||
"type": "video",
|
||||
"filename": file_path.name,
|
||||
"title": file_path.stem,
|
||||
}
|
||||
|
||||
metadata = {
|
||||
"type": "video",
|
||||
"filename": file_path.name,
|
||||
"path": str(file_path),
|
||||
}
|
||||
|
||||
# Extract duration
|
||||
if hasattr(video.info, "length"):
|
||||
metadata["duration"] = round(video.info.length, 2)
|
||||
|
||||
# Extract bitrate
|
||||
if hasattr(video.info, "bitrate"):
|
||||
metadata["bitrate"] = video.info.bitrate
|
||||
|
||||
# Extract video-specific properties if available
|
||||
if hasattr(video.info, "width"):
|
||||
metadata["width"] = video.info.width
|
||||
|
||||
if hasattr(video.info, "height"):
|
||||
metadata["height"] = video.info.height
|
||||
|
||||
# Try to extract title from tags
|
||||
if hasattr(video, "tags") and video.tags:
|
||||
tags = video.tags
|
||||
if hasattr(tags, "get"):
|
||||
title = tags.get("title") or tags.get("TITLE") or tags.get("\xa9nam")
|
||||
if title:
|
||||
metadata["title"] = title[0] if isinstance(title, list) else str(title)
|
||||
|
||||
# If no title tag, use filename
|
||||
if "title" not in metadata:
|
||||
metadata["title"] = file_path.stem
|
||||
|
||||
return metadata
|
||||
|
||||
except ImportError:
|
||||
logger.error("mutagen library not installed, cannot extract metadata")
|
||||
return {
|
||||
"error": "mutagen library not installed",
|
||||
"type": "video",
|
||||
"filename": file_path.name,
|
||||
"title": file_path.stem,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Error extracting video metadata from {file_path}: {e}")
|
||||
# Return basic metadata
|
||||
return {
|
||||
"type": "video",
|
||||
"filename": file_path.name,
|
||||
"title": file_path.stem,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def extract_metadata(file_path: Path) -> dict:
|
||||
"""Extract metadata from a media file (auto-detect type).
|
||||
|
||||
Args:
|
||||
file_path: Path to the media file.
|
||||
|
||||
Returns:
|
||||
Dictionary with media metadata.
|
||||
"""
|
||||
from .browser_service import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS
|
||||
|
||||
suffix = file_path.suffix.lower()
|
||||
|
||||
if suffix in AUDIO_EXTENSIONS:
|
||||
return MetadataService.extract_audio_metadata(file_path)
|
||||
elif suffix in VIDEO_EXTENSIONS:
|
||||
return MetadataService.extract_video_metadata(file_path)
|
||||
else:
|
||||
return {
|
||||
"error": "Unsupported file type",
|
||||
"filename": file_path.name,
|
||||
}
|
||||
359
media_server/services/thumbnail_service.py
Normal file
359
media_server/services/thumbnail_service.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""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}")
|
||||
@@ -666,3 +666,24 @@ class WindowsMediaController(MediaController):
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to seek: {e}")
|
||||
return False
|
||||
|
||||
async def open_file(self, file_path: str) -> bool:
|
||||
"""Open a media file with the default system player (Windows).
|
||||
|
||||
Uses os.startfile() to open the file with the default application.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the media file
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, lambda: os.startfile(file_path))
|
||||
logger.info(f"Opened file with default player: {file_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user