d131ba461c
Lint & Test / test (push) Successful in 20s
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.
612 lines
20 KiB
Python
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")
|