Backend optimizations: - GZip middleware for compressed responses - Concurrent WebSocket broadcast - Skip status polling when no clients connected - Deduplicated token validation with caching - Fire-and-forget HA state callbacks - Single stat() per browser item - Metadata caching (LRU) - M3U playlist optimization - Autostart setup (Task Scheduler + hidden VBS launcher) Frontend code optimizations: - Fix thumbnail blob URL memory leak - Fix WebSocket ping interval leak on reconnect - Skip artwork re-fetch when same track playing - Deduplicate volume slider logic - Extract magic numbers into named constants - Standardize error handling with toast notifications - Cache play/pause SVG constants - Loading state management for async buttons - Request deduplication for rapid clicks - Cache 30+ DOM element references - Deduplicate volume updates over WebSocket Frontend design improvements: - Progress bar seek thumb and hover expansion - Custom themed scrollbars - Toast notification accent border strips - Keyboard focus-visible states - Album art ambient glow effect - Animated sliding tab indicator - Mini-player top progress line - Empty state SVG illustrations - Responsive tablet breakpoint (601-900px) - Horizontal player layout on wide screens (>900px) - Glassmorphism mini-player with backdrop blur - Vinyl spin animation (toggleable) - Table horizontal scroll on narrow screens Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
568 lines
18 KiB
Python
568 lines
18 KiB
Python
"""Browser API routes for media file browsing."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import tempfile
|
|
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 FileResponse, StreamingResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ..auth import verify_token, verify_token_or_query
|
|
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"])
|
|
|
|
|
|
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
|
|
"""Poll until media session registers, then broadcast status update.
|
|
|
|
Fires as a background task so the HTTP response returns immediately.
|
|
"""
|
|
try:
|
|
interval = 0.3
|
|
elapsed = 0.0
|
|
while elapsed < max_wait:
|
|
await asyncio.sleep(interval)
|
|
elapsed += interval
|
|
status = await controller.get_status()
|
|
if status.state in ("playing", "paused"):
|
|
break
|
|
|
|
status_dict = status.model_dump()
|
|
await ws_manager.broadcast({"type": "status", "data": status_dict})
|
|
logger.info(f"Broadcasted status update after opening: {label}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to broadcast status after opening {label}: {e}")
|
|
|
|
|
|
# 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")
|
|
|
|
|
|
class PlayFolderRequest(BaseModel):
|
|
"""Request model for playing all media files in a folder."""
|
|
|
|
folder_id: str = Field(..., description="Media folder ID")
|
|
path: str = Field(default="", description="Path relative to folder root")
|
|
|
|
|
|
# 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 HTTPException:
|
|
raise
|
|
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 HTTPException:
|
|
raise
|
|
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"),
|
|
nocache: bool = Query(default=False, description="Bypass directory cache"),
|
|
_: 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,
|
|
nocache=nocache,
|
|
)
|
|
|
|
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 (OSError, PermissionError) as e:
|
|
# Network share unavailable or access denied
|
|
logger.warning(f"Folder temporarily unavailable: {e}")
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=f"Folder is temporarily unavailable. It may be a network share that is not accessible at the moment."
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error browsing directory (type: {type(e).__name__}): {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:
|
|
return Response(status_code=204)
|
|
|
|
# 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")
|
|
|
|
# Poll until player registers with media session API (up to 2s)
|
|
asyncio.create_task(_broadcast_after_open(controller, file_path.name))
|
|
|
|
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")
|
|
|
|
|
|
# Play Folder Endpoint (M3U playlist)
|
|
@router.post("/play-folder")
|
|
async def play_folder(
|
|
request: PlayFolderRequest,
|
|
_: str = Depends(verify_token),
|
|
):
|
|
"""Play all media files in a folder by generating an M3U playlist.
|
|
|
|
Args:
|
|
request: Play folder request with folder_id and path.
|
|
|
|
Returns:
|
|
Success message with file count.
|
|
|
|
Raises:
|
|
HTTPException: If folder not found or playback fails.
|
|
"""
|
|
try:
|
|
decoded_path = unquote(request.path)
|
|
full_path = BrowserService.validate_path(request.folder_id, decoded_path)
|
|
|
|
if not full_path.is_dir():
|
|
raise HTTPException(status_code=400, detail="Path is not a directory")
|
|
|
|
# Collect all media files sorted by name
|
|
media_files = sorted(
|
|
[f for f in full_path.iterdir() if f.is_file() and BrowserService.is_media_file(f)],
|
|
key=lambda f: f.name.lower(),
|
|
)
|
|
|
|
if not media_files:
|
|
raise HTTPException(status_code=404, detail="No media files found in this folder")
|
|
|
|
# Generate M3U playlist with absolute paths and EXTINF entries
|
|
# Written to local temp dir to avoid extra SMB file handle on network shares
|
|
# Uses utf-8-sig (BOM) so players detect encoding properly
|
|
lines = ["#EXTM3U"]
|
|
for f in media_files:
|
|
lines.append(f"#EXTINF:-1,{f.stem}")
|
|
lines.append(str(f))
|
|
m3u_content = "\r\n".join(lines) + "\r\n"
|
|
|
|
playlist_path = Path(tempfile.gettempdir()) / ".media_server_playlist.m3u"
|
|
playlist_path.write_text(m3u_content, encoding="utf-8-sig")
|
|
|
|
# Open playlist with default player
|
|
controller = get_media_controller()
|
|
success = await controller.open_file(playlist_path)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=500, detail="Failed to open playlist")
|
|
|
|
# Poll until player registers with media session API (up to 2s)
|
|
asyncio.create_task(_broadcast_after_open(controller, f"playlist ({len(media_files)} files)"))
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Playing {len(media_files)} files",
|
|
"count": len(media_files),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
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 playing folder: {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to play folder")
|
|
|
|
|
|
# Download Endpoint
|
|
@router.get("/download")
|
|
async def download_file(
|
|
folder_id: str = Query(..., description="Media folder ID"),
|
|
path: str = Query(..., description="File path relative to folder root (URL-encoded)"),
|
|
_: str = Depends(verify_token_or_query),
|
|
):
|
|
"""Download a media file.
|
|
|
|
Args:
|
|
folder_id: ID of the media folder.
|
|
path: Path to the file (URL-encoded, relative to folder root).
|
|
|
|
Returns:
|
|
File download response.
|
|
|
|
Raises:
|
|
HTTPException: If file not found or not a media file.
|
|
"""
|
|
try:
|
|
decoded_path = unquote(path)
|
|
file_path = BrowserService.validate_path(folder_id, decoded_path)
|
|
|
|
if not file_path.is_file():
|
|
raise HTTPException(status_code=400, detail="Path is not a file")
|
|
|
|
if not BrowserService.is_media_file(file_path):
|
|
raise HTTPException(status_code=400, detail="File is not a media file")
|
|
|
|
return FileResponse(
|
|
path=file_path,
|
|
filename=file_path.name,
|
|
media_type="application/octet-stream",
|
|
)
|
|
|
|
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 HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error downloading file: {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to download file")
|