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:
419
media_server/routes/browser.py
Normal file
419
media_server/routes/browser.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user