diff --git a/media_server/routes/browser.py b/media_server/routes/browser.py index 1a52a8c..54c040a 100644 --- a/media_server/routes/browser.py +++ b/media_server/routes/browser.py @@ -2,6 +2,7 @@ import asyncio import logging +import tempfile from pathlib import Path from typing import Optional from urllib.parse import unquote @@ -48,6 +49,13 @@ class PlayRequest(BaseModel): 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)): @@ -423,6 +431,89 @@ async def play_file( 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 + m3u_content = "#EXTM3U\r\n" + for f in media_files: + m3u_content += f"#EXTINF:-1,{f.stem}\r\n" + m3u_content += f"{f}\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") + + # Wait for media player to start + await asyncio.sleep(1.5) + + # Broadcast status update + 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 after opening playlist with {len(media_files)} files") + except Exception as e: + logger.warning(f"Failed to broadcast status after opening playlist: {e}") + + 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( diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 90c5246..fabfd81 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -1130,31 +1130,6 @@ margin: 0; } -.browser-controls { - margin-bottom: 1rem; -} - -.browser-controls select { - width: 100%; - padding: 0.75rem; - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: 6px; - color: var(--text-primary); - font-size: 0.875rem; - cursor: pointer; - transition: all 0.3s; -} - -.browser-controls select:hover { - border-color: var(--accent); -} - -.browser-controls select:focus { - outline: none; - border-color: var(--accent); -} - /* Breadcrumb Navigation */ .breadcrumb { display: flex; @@ -1184,6 +1159,15 @@ text-decoration: underline; } +.breadcrumb-home { + display: flex; + align-items: center; +} + +.breadcrumb-home:hover { + text-decoration: none; +} + .breadcrumb-separator { color: var(--text-muted); margin: 0 0.25rem; @@ -1243,6 +1227,29 @@ fill: currentColor; } +.browser-play-all-btn { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.7rem; + background: var(--accent); + border: none; + border-radius: 6px; + color: #fff; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + width: auto; + height: auto; + margin-left: 0.5rem; +} + +.browser-play-all-btn:hover { + background: var(--accent-hover); + transform: none; +} + .items-per-page-label { display: flex; align-items: center; @@ -1272,6 +1279,7 @@ gap: 1rem; margin-bottom: 1.5rem; min-height: 200px; + align-items: start; } /* Compact Grid */ @@ -1432,6 +1440,18 @@ text-align: right; } +.browser-loading { + grid-column: 1 / -1; + display: flex; + justify-content: center; + padding: 3rem; +} + +.browser-loading .loading-spinner { + width: 28px; + height: 28px; +} + .browser-empty { grid-column: 1 / -1; text-align: center; @@ -1620,9 +1640,8 @@ /* Compact grid overrides */ .browser-grid-compact .browser-thumb-wrapper { - width: 100%; - height: auto; - aspect-ratio: 1; + width: 64px; + height: 64px; } .browser-grid-compact .browser-play-overlay svg { diff --git a/media_server/static/index.html b/media_server/static/index.html index 4d1b4be..b77e79e 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -147,13 +147,6 @@