Files
media-player-server/media_server/routes/browser.py
T
alexei.dolgolyov d131ba461c
Lint & Test / test (push) Successful in 20s
fix: production-readiness hardening — security, perf, a11y, observability
Security
- Default scripts_management, callbacks_management, links_management, and
  media_folders_management to False so a leaked token cannot escalate to RCE
  through admin CRUD endpoints.
- TokenSpec + scope hierarchy (read | control | admin); legacy bare-string
  api_tokens entries promote to admin for back-compat. Management endpoints
  now require admin scope.
- WebSocket subprotocol auth (Sec-WebSocket-Protocol: media-server.token.<T>)
  preferred over ?token= query so the token no longer lands in URL/history/
  Referer; query fallback retained for HA integration back-compat.
- Origin allow-list check on the WS endpoint (CSWSH defence).
- In-process token-bucket rate limiter: 5/min for failed auths,
  10/min for /api/scripts/execute and /api/callbacks/execute.
- shell=False subprocess path (shlex.split) + per-parameter regex `pattern`
  in ScriptParameterConfig to harden shell=true scripts against parameter
  injection (Windows cmd.exe env-var expansion).
- CSP gains form-action, worker-src, manifest-src directives.
- Refuse cors_origins=["*"] at startup; strip token=... from uvicorn access
  logs; validate Gitea release tag against strict SemVer regex.
- noopener noreferrer + no-referrer referrerpolicy on every outbound link.
- icacls hardening of config.yaml on Windows (current user + SYSTEM +
  Administrators only); 0600 still enforced on POSIX.
- WS volume handler clamps input and never drops the socket on bad messages.

Performance
- Album-art read in windows_media gated by track key — was decoding the
  WinRT thumbnail twice per second regardless of track changes.
- /api/media/artwork returns content-derived ETag + Cache-Control so the
  browser sends If-None-Match and gets 304s on track repeats.
- Foreground-service ctypes argtypes hoisted to one-time module init
  (was re-declaring ~14 prototypes per probe).
- display_service _static_cache keyed by (edid_hash, ...) tuple with
  eviction of disappeared monitors — fixes stale capabilities on hot-plug
  swaps where the new topology has the same monitor count.
- Visualizer rAF loop paused on document.hidden, resumed on visible.

Reliability / bug fixes
- Lifespan rewritten as try/yield/finally so a partial-startup failure
  cannot orphan background tasks or executors.
- _run_callback in routes/media.py keeps a strong task ref (GC-safe) and
  uses the dedicated callback executor instead of the default pool.
- macos_media.set_volume() no longer always returns True.
- TrayManager._restart_requested initialised in __init__; set before
  signalling exit so the main thread observes it correctly.
- Missing static_dir now logs a WARNING instead of silent UI disable.

UX / accessibility / PWA
- manifest.json theme_color and background_color match the Studio Reference
  base (#0E0D0B); added id and scope for PWA installability.
- ARIA on mini-player icon buttons; inner SVGs marked aria-hidden.
- OS mediaSession API wired so headset / lockscreen / Bluetooth buttons
  drive play/pause/next/prev/seek and show track metadata + artwork.

Observability
- X-Request-ID middleware (accept upstream id if it matches a safe regex,
  otherwise UUID4); request_id_var added to ContextVars and included in
  every log line alongside the token label.
- Audit log (append-only JSONL) for every script + callback execution,
  including the on_play/on_pause/etc. event callbacks. Background-thread
  writer; queue capped; flushed in lifespan teardown.

Deployment
- proxy_headers + forwarded_allow_ips plumbed through Settings →
  uvicorn.Config for reverse-proxy installs.
- HTTPS support via ssl_certfile + ssl_keyfile (+ optional password);
  startup refuses to launch with only one of the pair set.
- Thumbnail cache moved from project-root .cache to
  %LOCALAPPDATA%/media-server/cache (Windows) and
  $XDG_CACHE_HOME/media-server/thumbnails (POSIX).

Tests
- 35 new tests across auth scopes, rate limiter, browser path traversal
  (../ NUL UNC absolute), script-param validation incl. regex, Gitea tag
  whitelist, config atomic write + POSIX perms. 47 passed / 4 skipped.
2026-05-22 22:25:54 +03:00

612 lines
20 KiB
Python

"""Browser API routes for media file browsing."""
import asyncio
import logging
import tempfile
from pathlib import Path
from urllib.parse import unquote
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse
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 import get_media_controller
from ..services.browser_service import BrowserService
from ..services.metadata_service import MetadataService
from ..services.thumbnail_service import ThumbnailService
from ..services.websocket_manager import ws_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser", tags=["browser"])
# Strong refs to background tasks so they don't get garbage-collected mid-flight.
_background_tasks: set[asyncio.Task] = set()
def _spawn_background(coro) -> asyncio.Task:
"""Schedule a background coroutine and keep a strong ref to its Task."""
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
return task
def _require_folder_management() -> None:
"""Raise 403 if media folder management is disabled OR caller lacks admin scope."""
if not settings.media_folders_management:
raise HTTPException(
status_code=403,
detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
)
from ..auth import auth_enabled, token_has_scope, token_label_var
if auth_enabled():
label = token_label_var.get("unknown")
if not token_has_scope(label, "admin"):
raise HTTPException(
status_code=403,
detail=f"Token '{label}' lacks required scope: admin",
)
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.
"""
status = None
try:
interval = 0.3
elapsed = 0.0
while elapsed < max_wait:
await asyncio.sleep(interval)
elapsed += interval
try:
status = await controller.get_status()
except Exception as poll_err: # noqa: BLE001 — broadcast is best-effort
logger.debug("get_status during broadcast poll failed: %s", poll_err)
continue
if status.state in ("playing", "paused"):
break
if status is None:
return
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.
Both ``folder_id`` and ``path`` are required so the server can validate
the file lives inside a configured media folder.
"""
folder_id: str = Field(..., description="Media folder ID")
path: str = Field(..., description="Path relative to folder root")
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 with folder configurations and management flag.
"""
folders = {}
for folder_id, config in settings.media_folders.items():
folder_path = Path(config.path)
folders[folder_id] = {
"id": folder_id,
"label": config.label,
"path": config.path,
"enabled": config.enabled,
"available": folder_path.is_dir(),
}
return {
"folders": folders,
"management_enabled": settings.media_folders_management,
}
@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.
"""
_require_folder_management()
try:
# Validate folder_id format (alphanumeric and underscore only).
# Same constraint is enforced when validating paths so traversal can't
# be smuggled through the ID itself.
if not request.folder_id or 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.
"""
_require_folder_management()
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.
"""
_require_folder_management()
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 in a thread — iterdir() + stat() can block on
# network shares for many seconds; never run on the event loop.
result = await asyncio.to_thread(
BrowserService.browse_directory,
folder_id,
decoded_path,
offset,
limit,
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="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(
folder_id: str = Query(..., description="Media folder ID"),
path: str = Query(..., description="Path relative to folder root (URL-encoded)"),
_: str = Depends(verify_token),
):
"""Get metadata for a media file inside a configured media folder.
Args:
folder_id: ID of the media folder.
path: Path relative to folder root (URL-encoded).
Returns:
Media file metadata.
"""
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")
loop = asyncio.get_running_loop()
metadata = await loop.run_in_executor(
None,
MetadataService.extract_metadata,
file_path,
)
return metadata
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 extracting metadata: {e}")
raise HTTPException(status_code=500, detail="Failed to extract metadata")
# Thumbnail Endpoint
@router.get("/thumbnail")
async def get_thumbnail(
folder_id: str = Query(..., description="Media folder ID"),
path: str = Query(..., description="Path relative to folder root (URL-encoded)"),
size: str = Query(default="medium", description='Thumbnail size: "small" or "medium"'),
_: str = Depends(verify_token),
):
"""Get thumbnail for a media file inside a configured media folder."""
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")
if size not in ("small", "medium"):
size = "medium"
thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size)
if thumbnail_data is None:
return Response(status_code=204)
import hashlib
stat = file_path.stat()
etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode()
etag = hashlib.md5(etag_data).hexdigest()
return Response(
content=thumbnail_data,
media_type="image/jpeg",
headers={
"ETag": f'"{etag}"',
"Cache-Control": "public, max-age=86400",
},
)
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 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.
Requires both ``folder_id`` and a folder-relative ``path``; the resolved
file must live inside the configured media folder and be a recognized
media file. This prevents arbitrary OS-handler invocation (e.g.,
``os.startfile`` on Windows ``.lnk``/UNC paths).
"""
try:
decoded_path = unquote(request.path)
file_path = BrowserService.validate_path(request.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")
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")
_spawn_background(_broadcast_after_open(controller, file_path.name))
return {
"success": True,
"message": f"Playing {file_path.name}",
}
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 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")
def _scan(directory: Path) -> list[Path]:
return sorted(
(
f for f in directory.iterdir()
if f.is_file() and BrowserService.is_media_file(f)
),
key=lambda f: f.name.lower(),
)
media_files = await asyncio.to_thread(_scan, full_path)
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.
# Use NamedTemporaryFile to get a fresh per-call path — prevents
# symlink-clobber races between concurrent /play-folder requests
# and any local user pre-creating a fixed temp filename.
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").encode("utf-8-sig")
with tempfile.NamedTemporaryFile(
mode="wb",
prefix=".media_server_playlist_",
suffix=".m3u",
delete=False,
) as f:
f.write(m3u_content)
playlist_path = Path(f.name)
# 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")
_spawn_background(
_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")