Files
media-player-server/media_server/routes/browser.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

420 lines
12 KiB
Python

"""Browser API routes for media file browsing."""
import asyncio
import logging
from pathlib import Path
from typing import Optional
from urllib.parse import unquote
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..config import MediaFolderConfig, settings
from ..config_manager import config_manager
from ..services.browser_service import BrowserService
from ..services.metadata_service import MetadataService
from ..services.thumbnail_service import ThumbnailService
from ..services import get_media_controller
from ..services.websocket_manager import ws_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser", tags=["browser"])
# Request/Response Models
class FolderCreateRequest(BaseModel):
"""Request model for creating a media folder."""
folder_id: str = Field(..., description="Unique folder ID")
label: str = Field(..., description="Display label")
path: str = Field(..., description="Absolute path to media folder")
enabled: bool = Field(default=True, description="Whether folder is enabled")
class FolderUpdateRequest(BaseModel):
"""Request model for updating a media folder."""
label: str = Field(..., description="Display label")
path: str = Field(..., description="Absolute path to media folder")
enabled: bool = Field(default=True, description="Whether folder is enabled")
class PlayRequest(BaseModel):
"""Request model for playing a media file."""
path: str = Field(..., description="Full path to the media file")
# Folder Management Endpoints
@router.get("/folders")
async def list_folders(_: str = Depends(verify_token)):
"""List all configured media folders.
Returns:
Dictionary of folder configurations.
"""
folders = {}
for folder_id, config in settings.media_folders.items():
folders[folder_id] = {
"id": folder_id,
"label": config.label,
"path": config.path,
"enabled": config.enabled,
}
return folders
@router.post("/folders/create")
async def create_folder(
request: FolderCreateRequest,
_: str = Depends(verify_token),
):
"""Create a new media folder configuration.
Args:
request: Folder creation request.
Returns:
Success message.
Raises:
HTTPException: If folder already exists or validation fails.
"""
try:
# Validate folder_id format (alphanumeric and underscore only)
if not request.folder_id.replace("_", "").isalnum():
raise HTTPException(
status_code=400,
detail="Folder ID must contain only alphanumeric characters and underscores",
)
# Validate path exists
path = Path(request.path)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Path does not exist: {request.path}")
if not path.is_dir():
raise HTTPException(status_code=400, detail=f"Path is not a directory: {request.path}")
# Create config
config = MediaFolderConfig(
path=request.path,
label=request.label,
enabled=request.enabled,
)
# Add to config manager
config_manager.add_media_folder(request.folder_id, config)
return {
"success": True,
"message": f"Media folder '{request.folder_id}' created successfully",
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error creating media folder: {e}")
raise HTTPException(status_code=500, detail="Failed to create media folder")
@router.put("/folders/update/{folder_id}")
async def update_folder(
folder_id: str,
request: FolderUpdateRequest,
_: str = Depends(verify_token),
):
"""Update an existing media folder configuration.
Args:
folder_id: ID of the folder to update.
request: Folder update request.
Returns:
Success message.
Raises:
HTTPException: If folder doesn't exist or validation fails.
"""
try:
# Validate path exists
path = Path(request.path)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Path does not exist: {request.path}")
if not path.is_dir():
raise HTTPException(status_code=400, detail=f"Path is not a directory: {request.path}")
# Create config
config = MediaFolderConfig(
path=request.path,
label=request.label,
enabled=request.enabled,
)
# Update config manager
config_manager.update_media_folder(folder_id, config)
return {
"success": True,
"message": f"Media folder '{folder_id}' updated successfully",
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error updating media folder: {e}")
raise HTTPException(status_code=500, detail="Failed to update media folder")
@router.delete("/folders/delete/{folder_id}")
async def delete_folder(
folder_id: str,
_: str = Depends(verify_token),
):
"""Delete a media folder configuration.
Args:
folder_id: ID of the folder to delete.
Returns:
Success message.
Raises:
HTTPException: If folder doesn't exist.
"""
try:
config_manager.delete_media_folder(folder_id)
return {
"success": True,
"message": f"Media folder '{folder_id}' deleted successfully",
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Error deleting media folder: {e}")
raise HTTPException(status_code=500, detail="Failed to delete media folder")
# Browse Endpoints
@router.get("/browse")
async def browse(
folder_id: str = Query(..., description="Media folder ID"),
path: str = Query(default="", description="Path relative to folder root"),
offset: int = Query(default=0, ge=0, description="Pagination offset"),
limit: int = Query(default=100, ge=1, le=1000, description="Pagination limit"),
_: str = Depends(verify_token),
):
"""Browse a directory and list files/folders.
Args:
folder_id: ID of the media folder.
path: Path to browse (URL-encoded, relative to folder root).
offset: Pagination offset.
limit: Maximum items to return.
Returns:
Directory listing with items and metadata.
Raises:
HTTPException: If path validation fails or directory not accessible.
"""
try:
# URL decode the path
decoded_path = unquote(path)
# Browse directory
result = BrowserService.browse_directory(
folder_id=folder_id,
path=decoded_path,
offset=offset,
limit=limit,
)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Error browsing directory: {e}")
raise HTTPException(status_code=500, detail="Failed to browse directory")
# Metadata Endpoint
@router.get("/metadata")
async def get_metadata(
path: str = Query(..., description="Full path to media file (URL-encoded)"),
_: str = Depends(verify_token),
):
"""Get metadata for a media file.
Args:
path: Full path to the media file (URL-encoded).
Returns:
Media file metadata.
Raises:
HTTPException: If file not found or metadata extraction fails.
"""
try:
# URL decode the path
decoded_path = unquote(path)
file_path = Path(decoded_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if not file_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a file")
# Extract metadata in executor (blocking operation)
loop = asyncio.get_event_loop()
metadata = await loop.run_in_executor(
None,
MetadataService.extract_metadata,
file_path,
)
return metadata
except HTTPException:
raise
except Exception as e:
logger.error(f"Error extracting metadata: {e}")
raise HTTPException(status_code=500, detail="Failed to extract metadata")
# Thumbnail Endpoint
@router.get("/thumbnail")
async def get_thumbnail(
path: str = Query(..., description="Full path to media file (URL-encoded)"),
size: str = Query(default="medium", description='Thumbnail size: "small" or "medium"'),
_: str = Depends(verify_token),
):
"""Get thumbnail for a media file.
Args:
path: Full path to the media file (URL-encoded).
size: Thumbnail size ("small" or "medium").
Returns:
JPEG image bytes.
Raises:
HTTPException: If file not found or thumbnail generation fails.
"""
try:
# URL decode the path
decoded_path = unquote(path)
file_path = Path(decoded_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if not file_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a file")
# Validate size
if size not in ("small", "medium"):
size = "medium"
# Get thumbnail
thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size)
if thumbnail_data is None:
raise HTTPException(status_code=404, detail="Thumbnail not available")
# Calculate ETag (hash of path + mtime)
import hashlib
stat = file_path.stat()
etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode()
etag = hashlib.md5(etag_data).hexdigest()
# Return image with caching headers
return Response(
content=thumbnail_data,
media_type="image/jpeg",
headers={
"ETag": f'"{etag}"',
"Cache-Control": "public, max-age=86400", # 24 hours
},
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error generating thumbnail: {e}")
raise HTTPException(status_code=500, detail="Failed to generate thumbnail")
# Playback Endpoint
@router.post("/play")
async def play_file(
request: PlayRequest,
_: str = Depends(verify_token),
):
"""Open a media file with the default system player.
Args:
request: Play request with file path.
Returns:
Success message.
Raises:
HTTPException: If file not found or playback fails.
"""
try:
file_path = Path(request.path)
# Validate file exists
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if not file_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a file")
# Validate file is a media file
if not BrowserService.is_media_file(file_path):
raise HTTPException(status_code=400, detail="File is not a media file")
# Get media controller and open file
controller = get_media_controller()
success = await controller.open_file(str(file_path))
if not success:
raise HTTPException(status_code=500, detail="Failed to open file")
# Wait for media player to start and register with Windows Media Session API
# This allows the UI to update immediately with the new playback state
await asyncio.sleep(1.5)
# Get updated status and broadcast to all connected clients
try:
status = await controller.get_status()
status_dict = status.model_dump()
await ws_manager.broadcast({
"type": "status",
"data": status_dict
})
logger.info(f"Broadcasted status update after opening file: {file_path.name}")
except Exception as e:
logger.warning(f"Failed to broadcast status after opening file: {e}")
return {
"success": True,
"message": f"Playing {file_path.name}",
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error playing file: {e}")
raise HTTPException(status_code=500, detail="Failed to play file")