"""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")