From 7c631d09f6ceeac72dd1e45c5ab9eb3eab45dfbb Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 6 Feb 2026 21:30:28 +0300 Subject: [PATCH] Add media browser feature with UI improvements - Refactored index.html: Split into separate HTML (309 lines), CSS (908 lines), and JS (1,286 lines) files - Implemented media browser with folder configuration, recursive navigation, and thumbnail display - Added metadata extraction using mutagen library (title, artist, album, duration, bitrate, codec) - Implemented thumbnail generation and caching with SHA256 hash-based keys and LRU eviction - Added platform-specific file playback (os.startfile on Windows, xdg-open on Linux, open on macOS) - Implemented path validation security to prevent directory traversal attacks - Added smooth thumbnail loading with fade-in animation and loading spinner - Added i18n support for browser (English and Russian) - Updated dependencies: mutagen>=1.47.0, pillow>=10.0.0 - Added comprehensive media browser documentation to README Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 3 + CLAUDE.md | 2 + README.md | 88 + media_server/config.py | 20 + media_server/config_manager.py | 110 +- media_server/main.py | 3 +- media_server/routes/__init__.py | 3 +- media_server/routes/browser.py | 419 ++++ media_server/services/browser_service.py | 216 ++ media_server/services/linux_media.py | 24 + media_server/services/macos_media.py | 24 + media_server/services/media_controller.py | 12 + media_server/services/metadata_service.py | 194 ++ media_server/services/thumbnail_service.py | 359 ++++ media_server/services/windows_media.py | 21 + media_server/static/css/styles.css | 1223 +++++++++++ media_server/static/index.html | 2265 +------------------- media_server/static/js/app.js | 1698 +++++++++++++++ media_server/static/locales/en.json | 26 +- media_server/static/locales/ru.json | 26 +- pyproject.toml | 2 + 21 files changed, 4535 insertions(+), 2203 deletions(-) create mode 100644 media_server/routes/browser.py create mode 100644 media_server/services/browser_service.py create mode 100644 media_server/services/metadata_service.py create mode 100644 media_server/services/thumbnail_service.py create mode 100644 media_server/static/css/styles.css create mode 100644 media_server/static/js/app.js diff --git a/.gitignore b/.gitignore index e9a10d7..0b508b5 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ logs/ # OS .DS_Store Thumbs.db + +# Thumbnail cache +.cache/ diff --git a/CLAUDE.md b/CLAUDE.md index fc0f199..ef9a8d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,8 @@ Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false **Best Practice:** Always restart the server immediately after committing backend changes to verify they work correctly before pushing. +**CRITICAL** Always check acccessibility of WebUI after server restart to ensure that server has started without issues + ## Configuration Copy `config.example.yaml` to `config.yaml` and customize. diff --git a/README.md b/README.md index 1159b1d..ae771d4 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,94 @@ We welcome translations for additional languages! To contribute a new locale: See [CLAUDE.md](CLAUDE.md#internationalization-i18n) for detailed translation guidelines. +## Media Browser + +The Media Browser feature allows you to browse and play media files from configured folders directly through the Web UI. + +### Browser Features + +- **Folder Configuration** - Mount multiple media folders (music/video directories) +- **Recursive Navigation** - Browse through folder hierarchies with breadcrumb navigation +- **Thumbnail Display** - Automatically generated thumbnails from album art +- **Metadata Extraction** - View title, artist, album, duration, bitrate, and more +- **Remote Playback** - Play files on the PC running the media server (not in the browser) +- **Last Path Memory** - Automatically returns to your last browsed location +- **Lazy Loading** - Thumbnails load only when visible for better performance + +### Configuration + +Add media folders in your `config.yaml`: + +```yaml +# Media folders for browser +media_folders: + music: + path: "C:\\Users\\YourUsername\\Music" + label: "My Music" + enabled: true + videos: + path: "C:\\Users\\YourUsername\\Videos" + label: "My Videos" + enabled: true + +# Thumbnail size: "small" (150x150), "medium" (300x300), or "both" +thumbnail_size: "medium" +``` + +### How Playback Works + +When you play a file from the Media Browser: + +1. The file is opened using the **default system media player** on the PC running the media server +2. This is designed for **remote control scenarios** where you browse media from one device (e.g., Home Assistant dashboard, phone) but want audio to play on the PC +3. The media player must support the **Windows Media Session API** for playback tracking + +### Media Player Compatibility + +**⚠️ Important Limitation:** Not all media players expose their playback information to the Windows Media Session API. This means some players will open and play the file, but the Media Server UI won't show playback status, track information, or allow remote control. + +**✅ Compatible Players** (work with playback tracking): + +- **VLC Media Player** - Full support +- **Groove Music** (Windows 10/11 built-in) - Full support +- **Spotify** - Full support (if already running) +- **Chrome/Edge/Firefox** - Full support for web players +- **foobar2000** - Full support (with proper configuration/plugins) + +**❌ Limited/No Support:** + +- **Windows Media Player Classic** - Opens files but doesn't expose session info +- **Windows Media Player** (classic version) - Limited session support + +**Recommendation:** Set **VLC Media Player** or **Groove Music** as your default audio player for the best experience with the Media Browser. + +#### Changing Your Default Media Player (Windows) + +1. Open Windows Settings → Apps → Default apps +2. Search for "Music player" or "Video player" +3. Select VLC Media Player or Groove Music +4. Files opened from Media Browser will now use the selected player + +### API Endpoints + +The Media Browser exposes several REST API endpoints: + +| Endpoint | Method | Description | +|--------------------------|--------|-----------------------------------| +| `/api/browser/folders` | GET | List configured media folders | +| `/api/browser/browse` | GET | Browse directory contents | +| `/api/browser/metadata` | GET | Get media file metadata | +| `/api/browser/thumbnail` | GET | Get thumbnail image | +| `/api/browser/play` | POST | Open file with default player | + +All endpoints require bearer token authentication. + +### Security Notes + +- **Path Traversal Protection** - All paths are validated to prevent directory traversal attacks +- **Folder Restrictions** - Only configured folders are accessible +- **Authentication Required** - All endpoints require a valid API token + ## Requirements - Python 3.10+ diff --git a/media_server/config.py b/media_server/config.py index fe48842..0911e3f 100644 --- a/media_server/config.py +++ b/media_server/config.py @@ -10,6 +10,14 @@ from pydantic import BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict +class MediaFolderConfig(BaseModel): + """Configuration for a media folder.""" + + path: str = Field(..., description="Absolute path to media folder") + label: str = Field(..., description="Human-readable display label") + enabled: bool = Field(default=True, description="Whether this folder is active") + + class CallbackConfig(BaseModel): """Configuration for a callback script (no label/description needed).""" @@ -77,6 +85,18 @@ class Settings(BaseSettings): description="Callback scripts executed by integration events (on_turn_on, on_turn_off, on_toggle)", ) + # Media folders for browsing + media_folders: dict[str, MediaFolderConfig] = Field( + default_factory=dict, + description="Media folders available for browsing in the media browser", + ) + + # Thumbnail settings + thumbnail_size: str = Field( + default="medium", + description='Thumbnail size: "small" (150x150), "medium" (300x300), or "both"', + ) + @classmethod def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings": """Load settings from a YAML configuration file.""" diff --git a/media_server/config_manager.py b/media_server/config_manager.py index 51b1ab3..7bef4b9 100644 --- a/media_server/config_manager.py +++ b/media_server/config_manager.py @@ -8,7 +8,7 @@ from typing import Optional import yaml -from .config import CallbackConfig, ScriptConfig, settings +from .config import CallbackConfig, MediaFolderConfig, ScriptConfig, settings logger = logging.getLogger(__name__) @@ -279,6 +279,114 @@ class ConfigManager: logger.info(f"Callback '{name}' deleted from config") + def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None: + """Add a new media folder to config. + + Args: + folder_id: Folder ID (must be unique). + config: Media folder configuration. + + Raises: + ValueError: If folder already exists. + IOError: If config file cannot be written. + """ + with self._lock: + # Read YAML + if not self._config_path.exists(): + data = {} + else: + with open(self._config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + # Check if folder already exists + if "media_folders" in data and folder_id in data["media_folders"]: + raise ValueError(f"Media folder '{folder_id}' already exists") + + # Add folder + if "media_folders" not in data: + data["media_folders"] = {} + data["media_folders"][folder_id] = config.model_dump(exclude_none=True) + + # Write YAML + self._config_path.parent.mkdir(parents=True, exist_ok=True) + with open(self._config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + # Update in-memory settings + settings.media_folders[folder_id] = config + + logger.info(f"Media folder '{folder_id}' added to config") + + def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None: + """Update an existing media folder. + + Args: + folder_id: Folder ID. + config: New media folder configuration. + + Raises: + ValueError: If folder does not exist. + IOError: If config file cannot be written. + """ + with self._lock: + # Read YAML + if not self._config_path.exists(): + raise ValueError(f"Config file not found: {self._config_path}") + + with open(self._config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + # Check if folder exists + if "media_folders" not in data or folder_id not in data["media_folders"]: + raise ValueError(f"Media folder '{folder_id}' does not exist") + + # Update folder + data["media_folders"][folder_id] = config.model_dump(exclude_none=True) + + # Write YAML + with open(self._config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + # Update in-memory settings + settings.media_folders[folder_id] = config + + logger.info(f"Media folder '{folder_id}' updated in config") + + def delete_media_folder(self, folder_id: str) -> None: + """Delete a media folder from config. + + Args: + folder_id: Folder ID. + + Raises: + ValueError: If folder does not exist. + IOError: If config file cannot be written. + """ + with self._lock: + # Read YAML + if not self._config_path.exists(): + raise ValueError(f"Config file not found: {self._config_path}") + + with open(self._config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + # Check if folder exists + if "media_folders" not in data or folder_id not in data["media_folders"]: + raise ValueError(f"Media folder '{folder_id}' does not exist") + + # Delete folder + del data["media_folders"][folder_id] + + # Write YAML + with open(self._config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + # Update in-memory settings + if folder_id in settings.media_folders: + del settings.media_folders[folder_id] + + logger.info(f"Media folder '{folder_id}' deleted from config") + # Global config manager instance config_manager = ConfigManager() diff --git a/media_server/main.py b/media_server/main.py index 4f6611c..4ecccc1 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -15,7 +15,7 @@ from fastapi.staticfiles import StaticFiles from . import __version__ from .auth import get_token_label, token_label_var from .config import settings, generate_default_config, get_config_dir -from .routes import audio_router, callbacks_router, health_router, media_router, scripts_router +from .routes import audio_router, browser_router, callbacks_router, health_router, media_router, scripts_router from .services import get_media_controller from .services.websocket_manager import ws_manager @@ -110,6 +110,7 @@ def create_app() -> FastAPI: # Register routers app.include_router(audio_router) + app.include_router(browser_router) app.include_router(callbacks_router) app.include_router(health_router) app.include_router(media_router) diff --git a/media_server/routes/__init__.py b/media_server/routes/__init__.py index c386e40..e855572 100644 --- a/media_server/routes/__init__.py +++ b/media_server/routes/__init__.py @@ -1,9 +1,10 @@ """API route modules.""" from .audio import router as audio_router +from .browser import router as browser_router from .callbacks import router as callbacks_router from .health import router as health_router from .media import router as media_router from .scripts import router as scripts_router -__all__ = ["audio_router", "callbacks_router", "health_router", "media_router", "scripts_router"] +__all__ = ["audio_router", "browser_router", "callbacks_router", "health_router", "media_router", "scripts_router"] diff --git a/media_server/routes/browser.py b/media_server/routes/browser.py new file mode 100644 index 0000000..2f81722 --- /dev/null +++ b/media_server/routes/browser.py @@ -0,0 +1,419 @@ +"""Browser API routes for media file browsing.""" + +import asyncio +import logging +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 StreamingResponse +from pydantic import BaseModel, Field + +from ..auth import verify_token +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"]) + + +# 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") + + +# 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 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 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"), + _: 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, + ) + + 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 Exception as e: + logger.error(f"Error browsing directory: {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: + raise HTTPException(status_code=404, detail="Thumbnail not available") + + # 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") + + # Wait for media player to start and register with Windows Media Session API + # This allows the UI to update immediately with the new playback state + await asyncio.sleep(1.5) + + # Get updated status and broadcast to all connected clients + 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 update after opening file: {file_path.name}") + except Exception as e: + logger.warning(f"Failed to broadcast status after opening file: {e}") + + 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") diff --git a/media_server/services/browser_service.py b/media_server/services/browser_service.py new file mode 100644 index 0000000..9ef5b51 --- /dev/null +++ b/media_server/services/browser_service.py @@ -0,0 +1,216 @@ +"""Browser service for media file browsing and path validation.""" + +import logging +import os +from datetime import datetime +from pathlib import Path +from typing import Optional + +from ..config import settings + +logger = logging.getLogger(__name__) + +# Media file extensions +AUDIO_EXTENSIONS = {".mp3", ".m4a", ".flac", ".wav", ".ogg", ".aac", ".wma", ".opus"} +VIDEO_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm", ".flv"} +MEDIA_EXTENSIONS = AUDIO_EXTENSIONS | VIDEO_EXTENSIONS + + +class BrowserService: + """Service for browsing media files with path validation.""" + + @staticmethod + def validate_path(folder_id: str, requested_path: str) -> Path: + """Validate and resolve a path within an allowed folder. + + Args: + folder_id: ID of the configured media folder. + requested_path: Path to validate (relative to folder root or absolute). + + Returns: + Resolved absolute Path object. + + Raises: + ValueError: If folder_id invalid or path traversal attempted. + FileNotFoundError: If path does not exist. + """ + # Get folder config + if folder_id not in settings.media_folders: + raise ValueError(f"Media folder '{folder_id}' not configured") + + folder_config = settings.media_folders[folder_id] + if not folder_config.enabled: + raise ValueError(f"Media folder '{folder_id}' is disabled") + + # Get base path + base_path = Path(folder_config.path).resolve() + if not base_path.exists(): + raise FileNotFoundError(f"Media folder path does not exist: {base_path}") + if not base_path.is_dir(): + raise ValueError(f"Media folder path is not a directory: {base_path}") + + # Handle relative vs absolute paths + if requested_path.startswith("/") or requested_path.startswith("\\"): + # Relative to folder root (remove leading slash) + requested_path = requested_path.lstrip("/\\") + + # Build and resolve full path + if requested_path: + full_path = (base_path / requested_path).resolve() + else: + full_path = base_path + + # Security check: Ensure resolved path is within base path + try: + full_path.relative_to(base_path) + except ValueError: + logger.warning( + f"Path traversal attempt detected: {requested_path} " + f"(resolved to {full_path}, base: {base_path})" + ) + raise ValueError("Path traversal not allowed") + + # Check if path exists + if not full_path.exists(): + raise FileNotFoundError(f"Path does not exist: {requested_path}") + + return full_path + + @staticmethod + def is_media_file(path: Path) -> bool: + """Check if a file is a media file based on extension. + + Args: + path: Path to check. + + Returns: + True if file is a media file, False otherwise. + """ + return path.suffix.lower() in MEDIA_EXTENSIONS + + @staticmethod + def get_file_type(path: Path) -> str: + """Get the file type (folder, audio, video, other). + + Args: + path: Path to check. + + Returns: + File type string: "folder", "audio", "video", or "other". + """ + if path.is_dir(): + return "folder" + + suffix = path.suffix.lower() + if suffix in AUDIO_EXTENSIONS: + return "audio" + elif suffix in VIDEO_EXTENSIONS: + return "video" + else: + return "other" + + @staticmethod + def browse_directory( + folder_id: str, + path: str = "", + offset: int = 0, + limit: int = 100, + ) -> dict: + """Browse a directory and return items with metadata. + + Args: + folder_id: ID of the configured media folder. + path: Path to browse (relative to folder root). + offset: Pagination offset (default: 0). + limit: Maximum items to return (default: 100). + + Returns: + Dictionary with: + - current_path: Current path (relative to folder root) + - parent_path: Parent path (None if at root) + - items: List of file/folder items + - total: Total number of items + - offset: Current offset + - limit: Current limit + - folder_id: Folder ID + + Raises: + ValueError: If path validation fails. + FileNotFoundError: If path does not exist. + """ + # Validate path + full_path = BrowserService.validate_path(folder_id, path) + + # Get base path for relative path calculation + folder_config = settings.media_folders[folder_id] + base_path = Path(folder_config.path).resolve() + + # Check if it's a directory + if not full_path.is_dir(): + raise ValueError(f"Path is not a directory: {path}") + + # Calculate relative path + try: + relative_path = full_path.relative_to(base_path) + current_path = "/" + str(relative_path).replace("\\", "/") if str(relative_path) != "." else "/" + except ValueError: + current_path = "/" + + # Calculate parent path + if full_path == base_path: + parent_path = None + else: + parent_relative = full_path.parent.relative_to(base_path) + parent_path = "/" + str(parent_relative).replace("\\", "/") if str(parent_relative) != "." else "/" + + # List directory contents + try: + all_items = [] + for item in full_path.iterdir(): + # Skip hidden files (starting with .) + if item.name.startswith("."): + continue + + # Get file type + file_type = BrowserService.get_file_type(item) + + # Skip non-media files (but include folders) + if file_type == "other" and not item.is_dir(): + continue + + # Get file info + try: + stat = item.stat() + size = stat.st_size if item.is_file() else None + modified = datetime.fromtimestamp(stat.st_mtime).isoformat() + except (OSError, PermissionError): + size = None + modified = None + + all_items.append({ + "name": item.name, + "type": file_type, + "size": size, + "modified": modified, + "is_media": file_type in ("audio", "video"), + }) + + # Sort: folders first, then by name + all_items.sort(key=lambda x: (x["type"] != "folder", x["name"].lower())) + + # Apply pagination + total = len(all_items) + items = all_items[offset:offset + limit] + + return { + "folder_id": folder_id, + "current_path": current_path, + "parent_path": parent_path, + "items": items, + "total": total, + "offset": offset, + "limit": limit, + } + + except PermissionError: + raise ValueError(f"Permission denied accessing path: {path}") diff --git a/media_server/services/linux_media.py b/media_server/services/linux_media.py index e9d1f1d..a25eeec 100644 --- a/media_server/services/linux_media.py +++ b/media_server/services/linux_media.py @@ -293,3 +293,27 @@ class LinuxMediaController(MediaController): except Exception as e: logger.error(f"Failed to seek: {e}") return False + + async def open_file(self, file_path: str) -> bool: + """Open a media file with the default system player (Linux). + + Uses xdg-open to open the file with the default application. + + Args: + file_path: Absolute path to the media file + + Returns: + True if successful, False otherwise + """ + try: + process = await asyncio.create_subprocess_exec( + 'xdg-open', file_path, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL + ) + await process.wait() + logger.info(f"Opened file with default player: {file_path}") + return True + except Exception as e: + logger.error(f"Failed to open file {file_path}: {e}") + return False diff --git a/media_server/services/macos_media.py b/media_server/services/macos_media.py index edb390b..3107851 100644 --- a/media_server/services/macos_media.py +++ b/media_server/services/macos_media.py @@ -294,3 +294,27 @@ class MacOSMediaController(MediaController): ) return True return False + + async def open_file(self, file_path: str) -> bool: + """Open a media file with the default system player (macOS). + + Uses the 'open' command to open the file with the default application. + + Args: + file_path: Absolute path to the media file + + Returns: + True if successful, False otherwise + """ + try: + process = await asyncio.create_subprocess_exec( + 'open', file_path, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL + ) + await process.wait() + logger.info(f"Opened file with default player: {file_path}") + return True + except Exception as e: + logger.error(f"Failed to open file {file_path}: {e}") + return False diff --git a/media_server/services/media_controller.py b/media_server/services/media_controller.py index 0c8ce62..d69ac55 100644 --- a/media_server/services/media_controller.py +++ b/media_server/services/media_controller.py @@ -94,3 +94,15 @@ class MediaController(ABC): True if successful, False otherwise """ pass + + @abstractmethod + async def open_file(self, file_path: str) -> bool: + """Open a media file with the default system player. + + Args: + file_path: Absolute path to the media file + + Returns: + True if successful, False otherwise + """ + pass diff --git a/media_server/services/metadata_service.py b/media_server/services/metadata_service.py new file mode 100644 index 0000000..3f4b923 --- /dev/null +++ b/media_server/services/metadata_service.py @@ -0,0 +1,194 @@ +"""Metadata extraction service for media files.""" + +import logging +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +class MetadataService: + """Service for extracting metadata from media files.""" + + @staticmethod + def extract_audio_metadata(file_path: Path) -> dict: + """Extract metadata from an audio file. + + Args: + file_path: Path to the audio file. + + Returns: + Dictionary with audio metadata. + """ + try: + import mutagen + from mutagen import File as MutagenFile + + audio = MutagenFile(str(file_path), easy=True) + if audio is None: + return {"error": "Unable to read audio file"} + + metadata = { + "type": "audio", + "filename": file_path.name, + "path": str(file_path), + } + + # Extract duration + if hasattr(audio.info, "length"): + metadata["duration"] = round(audio.info.length, 2) + + # Extract bitrate + if hasattr(audio.info, "bitrate"): + metadata["bitrate"] = audio.info.bitrate + + # Extract sample rate + if hasattr(audio.info, "sample_rate"): + metadata["sample_rate"] = audio.info.sample_rate + elif hasattr(audio.info, "samplerate"): + metadata["sample_rate"] = audio.info.samplerate + + # Extract channels + if hasattr(audio.info, "channels"): + metadata["channels"] = audio.info.channels + + # Extract tags (use easy=True for consistent tag names) + if audio is not None and hasattr(audio, "tags") and audio.tags: + # Easy tags provide lists, so we take the first item + tags = audio.tags + + if "title" in tags: + metadata["title"] = tags["title"][0] if isinstance(tags["title"], list) else tags["title"] + + if "artist" in tags: + metadata["artist"] = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"] + + if "album" in tags: + metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"] + + if "albumartist" in tags: + metadata["album_artist"] = tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"] + + if "date" in tags: + metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"] + + if "genre" in tags: + metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"] + + if "tracknumber" in tags: + metadata["track_number"] = tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"] + + # If no title tag, use filename + if "title" not in metadata: + metadata["title"] = file_path.stem + + return metadata + + except ImportError: + logger.error("mutagen library not installed, cannot extract metadata") + return {"error": "mutagen library not installed"} + except Exception as e: + logger.error(f"Error extracting audio metadata from {file_path}: {e}") + return { + "error": str(e), + "filename": file_path.name, + "title": file_path.stem, + } + + @staticmethod + def extract_video_metadata(file_path: Path) -> dict: + """Extract basic metadata from a video file. + + Args: + file_path: Path to the video file. + + Returns: + Dictionary with video metadata. + """ + try: + import mutagen + from mutagen import File as MutagenFile + + video = MutagenFile(str(file_path)) + if video is None: + return { + "type": "video", + "filename": file_path.name, + "title": file_path.stem, + } + + metadata = { + "type": "video", + "filename": file_path.name, + "path": str(file_path), + } + + # Extract duration + if hasattr(video.info, "length"): + metadata["duration"] = round(video.info.length, 2) + + # Extract bitrate + if hasattr(video.info, "bitrate"): + metadata["bitrate"] = video.info.bitrate + + # Extract video-specific properties if available + if hasattr(video.info, "width"): + metadata["width"] = video.info.width + + if hasattr(video.info, "height"): + metadata["height"] = video.info.height + + # Try to extract title from tags + if hasattr(video, "tags") and video.tags: + tags = video.tags + if hasattr(tags, "get"): + title = tags.get("title") or tags.get("TITLE") or tags.get("\xa9nam") + if title: + metadata["title"] = title[0] if isinstance(title, list) else str(title) + + # If no title tag, use filename + if "title" not in metadata: + metadata["title"] = file_path.stem + + return metadata + + except ImportError: + logger.error("mutagen library not installed, cannot extract metadata") + return { + "error": "mutagen library not installed", + "type": "video", + "filename": file_path.name, + "title": file_path.stem, + } + except Exception as e: + logger.debug(f"Error extracting video metadata from {file_path}: {e}") + # Return basic metadata + return { + "type": "video", + "filename": file_path.name, + "title": file_path.stem, + } + + @staticmethod + def extract_metadata(file_path: Path) -> dict: + """Extract metadata from a media file (auto-detect type). + + Args: + file_path: Path to the media file. + + Returns: + Dictionary with media metadata. + """ + from .browser_service import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS + + suffix = file_path.suffix.lower() + + if suffix in AUDIO_EXTENSIONS: + return MetadataService.extract_audio_metadata(file_path) + elif suffix in VIDEO_EXTENSIONS: + return MetadataService.extract_video_metadata(file_path) + else: + return { + "error": "Unsupported file type", + "filename": file_path.name, + } diff --git a/media_server/services/thumbnail_service.py b/media_server/services/thumbnail_service.py new file mode 100644 index 0000000..dc13808 --- /dev/null +++ b/media_server/services/thumbnail_service.py @@ -0,0 +1,359 @@ +"""Thumbnail generation and caching service.""" + +import asyncio +import hashlib +import logging +import os +import shutil +import subprocess +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +# Thumbnail sizes +THUMBNAIL_SIZES = { + "small": (150, 150), + "medium": (300, 300), +} + +# Cache size limit (500MB) +CACHE_SIZE_LIMIT = 500 * 1024 * 1024 # 500MB in bytes + + +class ThumbnailService: + """Service for generating and caching thumbnails.""" + + @staticmethod + def get_cache_dir() -> Path: + """Get the thumbnail cache directory path. + + Returns: + Path to the cache directory (project-local). + """ + # Store cache in project directory: media-server/.cache/thumbnails/ + project_root = Path(__file__).parent.parent.parent + cache_dir = project_root / ".cache" / "thumbnails" + + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir + + @staticmethod + def get_cache_key(file_path: Path) -> str: + """Generate cache key from file path. + + Args: + file_path: Path to the media file. + + Returns: + SHA256 hash of the absolute file path. + """ + absolute_path = str(file_path.resolve()) + return hashlib.sha256(absolute_path.encode()).hexdigest() + + @staticmethod + def get_cached_thumbnail(file_path: Path, size: str) -> Optional[bytes]: + """Get cached thumbnail if valid. + + Args: + file_path: Path to the media file. + size: Thumbnail size ("small" or "medium"). + + Returns: + Thumbnail bytes if cached and valid, None otherwise. + """ + cache_dir = ThumbnailService.get_cache_dir() + cache_key = ThumbnailService.get_cache_key(file_path) + cache_path = cache_dir / cache_key / f"{size}.jpg" + + if not cache_path.exists(): + return None + + # Check if file has been modified since cache was created + try: + file_mtime = file_path.stat().st_mtime + cache_mtime = cache_path.stat().st_mtime + + if file_mtime > cache_mtime: + logger.debug(f"Cache invalidated for {file_path.name} (file modified)") + return None + + # Read cached thumbnail + with open(cache_path, "rb") as f: + return f.read() + + except (OSError, PermissionError) as e: + logger.error(f"Error reading cached thumbnail: {e}") + return None + + @staticmethod + def cache_thumbnail(file_path: Path, size: str, image_data: bytes) -> None: + """Cache a thumbnail. + + Args: + file_path: Path to the media file. + size: Thumbnail size ("small" or "medium"). + image_data: Thumbnail image data (JPEG bytes). + """ + cache_dir = ThumbnailService.get_cache_dir() + cache_key = ThumbnailService.get_cache_key(file_path) + cache_folder = cache_dir / cache_key + cache_folder.mkdir(parents=True, exist_ok=True) + + cache_path = cache_folder / f"{size}.jpg" + + try: + with open(cache_path, "wb") as f: + f.write(image_data) + logger.debug(f"Cached thumbnail for {file_path.name} ({size})") + except (OSError, PermissionError) as e: + logger.error(f"Error caching thumbnail: {e}") + + @staticmethod + def generate_audio_thumbnail(file_path: Path, size: str) -> Optional[bytes]: + """Generate thumbnail from audio file (extract album art). + + Args: + file_path: Path to the audio file. + size: Thumbnail size ("small" or "medium"). + + Returns: + Thumbnail bytes (JPEG) or None if no album art. + """ + try: + import mutagen + from mutagen import File as MutagenFile + from PIL import Image + from io import BytesIO + + audio = MutagenFile(str(file_path)) + if audio is None: + return None + + # Extract album art + art_data = None + + # Try different tag types for album art + if hasattr(audio, "pictures") and audio.pictures: + # FLAC, Ogg Vorbis + art_data = audio.pictures[0].data + elif hasattr(audio, "tags"): + tags = audio.tags + if tags is not None: + # MP3 (ID3) + if hasattr(tags, "getall"): + apic_frames = tags.getall("APIC") + if apic_frames: + art_data = apic_frames[0].data + # MP4/M4A + elif "covr" in tags: + art_data = bytes(tags["covr"][0]) + # Try other common keys + elif "APIC:" in tags: + art_data = tags["APIC:"].data + + if art_data is None: + return None + + # Resize image + img = Image.open(BytesIO(art_data)) + + # Convert to RGB if necessary (handle RGBA, grayscale, etc.) + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") + + # Resize with maintaining aspect ratio and center crop + target_size = THUMBNAIL_SIZES[size] + img.thumbnail((target_size[0] * 2, target_size[1] * 2), Image.Resampling.LANCZOS) + + # Center crop to square + width, height = img.size + min_dim = min(width, height) + left = (width - min_dim) // 2 + top = (height - min_dim) // 2 + right = left + min_dim + bottom = top + min_dim + img = img.crop((left, top, right, bottom)) + + # Final resize + img = img.resize(target_size, Image.Resampling.LANCZOS) + + # Save as JPEG + output = BytesIO() + img.save(output, format="JPEG", quality=85, optimize=True) + return output.getvalue() + + except ImportError: + logger.error("Required libraries (mutagen, Pillow) not installed") + return None + except Exception as e: + logger.debug(f"Error generating audio thumbnail for {file_path.name}: {e}") + return None + + @staticmethod + async def generate_video_thumbnail(file_path: Path, size: str) -> Optional[bytes]: + """Generate thumbnail from video file using ffmpeg. + + Args: + file_path: Path to the video file. + size: Thumbnail size ("small" or "medium"). + + Returns: + Thumbnail bytes (JPEG) or None if ffmpeg not available. + """ + try: + from PIL import Image + from io import BytesIO + + # Check if ffmpeg is available + if not shutil.which("ffmpeg"): + logger.debug("ffmpeg not available, cannot generate video thumbnail") + return None + + # Extract frame at 10% duration + target_size = THUMBNAIL_SIZES[size] + + # Use ffmpeg to extract a frame + cmd = [ + "ffmpeg", + "-i", str(file_path), + "-vf", f"thumbnail,scale={target_size[0]}:{target_size[1]}:force_original_aspect_ratio=increase,crop={target_size[0]}:{target_size[1]}", + "-frames:v", "1", + "-f", "image2pipe", + "-vcodec", "mjpeg", + "-" + ] + + # Run ffmpeg with timeout + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + + try: + stdout, _ = await asyncio.wait_for(process.communicate(), timeout=10.0) + if process.returncode == 0 and stdout: + # ffmpeg output is already JPEG, but let's ensure proper quality + img = Image.open(BytesIO(stdout)) + + # Convert to RGB if necessary + if img.mode != "RGB": + img = img.convert("RGB") + + # Save as JPEG with consistent quality + output = BytesIO() + img.save(output, format="JPEG", quality=85, optimize=True) + return output.getvalue() + + except asyncio.TimeoutError: + logger.warning(f"ffmpeg timeout for {file_path.name}") + process.kill() + await process.wait() + + return None + + except ImportError: + logger.error("Pillow library not installed") + return None + except Exception as e: + logger.debug(f"Error generating video thumbnail for {file_path.name}: {e}") + return None + + @staticmethod + async def get_thumbnail(file_path: Path, size: str = "medium") -> Optional[bytes]: + """Get thumbnail for a media file (from cache or generate). + + Args: + file_path: Path to the media file. + size: Thumbnail size ("small" or "medium"). + + Returns: + Thumbnail bytes (JPEG) or None if unavailable. + """ + from .browser_service import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS + + # Validate size + if size not in THUMBNAIL_SIZES: + size = "medium" + + # Check cache first + cached = ThumbnailService.get_cached_thumbnail(file_path, size) + if cached: + return cached + + # Generate thumbnail based on file type + suffix = file_path.suffix.lower() + thumbnail_data = None + + if suffix in AUDIO_EXTENSIONS: + # Audio files - run in executor (sync operation) + loop = asyncio.get_event_loop() + thumbnail_data = await loop.run_in_executor( + None, + ThumbnailService.generate_audio_thumbnail, + file_path, + size, + ) + elif suffix in VIDEO_EXTENSIONS: + # Video files - already async + thumbnail_data = await ThumbnailService.generate_video_thumbnail(file_path, size) + + # Cache if generated successfully + if thumbnail_data: + ThumbnailService.cache_thumbnail(file_path, size, thumbnail_data) + + return thumbnail_data + + @staticmethod + def cleanup_cache() -> None: + """Clean up cache if it exceeds size limit. + + Removes oldest thumbnails by access time. + """ + cache_dir = ThumbnailService.get_cache_dir() + + try: + # Calculate total cache size + total_size = 0 + cache_items = [] + + for folder in cache_dir.iterdir(): + if folder.is_dir(): + for file in folder.iterdir(): + if file.is_file(): + stat = file.stat() + total_size += stat.st_size + cache_items.append((file, stat.st_atime, stat.st_size)) + + # If cache is within limit, no cleanup needed + if total_size <= CACHE_SIZE_LIMIT: + return + + logger.info(f"Cache size {total_size / 1024 / 1024:.2f}MB exceeds limit, cleaning up...") + + # Sort by access time (oldest first) + cache_items.sort(key=lambda x: x[1]) + + # Remove oldest items until under limit + for file, _, size in cache_items: + if total_size <= CACHE_SIZE_LIMIT: + break + + try: + file.unlink() + total_size -= size + logger.debug(f"Removed cached thumbnail: {file}") + + # Remove empty parent folder + parent = file.parent + if parent != cache_dir and not any(parent.iterdir()): + parent.rmdir() + + except (OSError, PermissionError) as e: + logger.error(f"Error removing cache file: {e}") + + logger.info(f"Cache cleanup complete, new size: {total_size / 1024 / 1024:.2f}MB") + + except Exception as e: + logger.error(f"Error during cache cleanup: {e}") diff --git a/media_server/services/windows_media.py b/media_server/services/windows_media.py index 440b0ae..00d2d63 100644 --- a/media_server/services/windows_media.py +++ b/media_server/services/windows_media.py @@ -666,3 +666,24 @@ class WindowsMediaController(MediaController): except Exception as e: logger.error(f"Failed to seek: {e}") return False + + async def open_file(self, file_path: str) -> bool: + """Open a media file with the default system player (Windows). + + Uses os.startfile() to open the file with the default application. + + Args: + file_path: Absolute path to the media file + + Returns: + True if successful, False otherwise + """ + try: + import os + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, lambda: os.startfile(file_path)) + logger.info(f"Opened file with default player: {file_path}") + return True + except Exception as e: + logger.error(f"Failed to open file {file_path}: {e}") + return False diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css new file mode 100644 index 0000000..f48d40c --- /dev/null +++ b/media_server/static/css/styles.css @@ -0,0 +1,1223 @@ + :root { + --bg-primary: #121212; + --bg-secondary: #1e1e1e; + --bg-tertiary: #282828; + --text-primary: #ffffff; + --text-secondary: #b3b3b3; + --text-muted: #6a6a6a; + --accent: #1db954; + --accent-hover: #1ed760; + --border: #404040; + --error: #e74c3c; + } + + :root[data-theme="light"] { + --bg-primary: #ffffff; + --bg-secondary: #f5f5f5; + --bg-tertiary: #e8e8e8; + --text-primary: #1a1a1a; + --text-secondary: #4a4a4a; + --text-muted: #888888; + --accent: #1db954; + --accent-hover: #1ed760; + --border: #d0d0d0; + --error: #e74c3c; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + } + + /* Prevent flash of untranslated content */ + body.loading-translations { + opacity: 0; + transition: opacity 0.1s ease-in; + } + + body.translations-loaded { + opacity: 1; + } + + /* Prevent scrolling when dialog is open */ + body.dialog-open { + overflow: hidden; + } + + .container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; + } + + header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); + } + + h1 { + font-size: 1.5rem; + font-weight: 600; + } + + .status-indicator { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--text-secondary); + } + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--error); + transition: background 0.3s; + } + + .status-dot.connected { + background: var(--accent); + } + + .theme-toggle { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s; + width: 40px; + height: 40px; + } + + .theme-toggle:hover { + background: var(--border); + } + + .theme-toggle svg { + width: 20px; + height: 20px; + fill: var(--text-primary); + } + + #locale-select { + background: var(--bg-tertiary); + border: 1px solid var(--border); + color: var(--text-primary); + border-radius: 6px; + padding: 6px 12px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.3s ease; + } + + #locale-select:hover { + border-color: var(--accent); + } + + #locale-select:focus { + outline: none; + border-color: var(--accent); + } + + #locale-select option { + background: var(--bg-secondary); + color: var(--text-primary); + } + + .player-container { + background: var(--bg-secondary); + border-radius: 12px; + padding: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + } + + .album-art-container { + display: flex; + justify-content: center; + margin-bottom: 2rem; + } + + #album-art { + width: 300px; + height: 300px; + object-fit: cover; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + background: var(--bg-tertiary); + } + + .track-info { + text-align: center; + margin-bottom: 2rem; + } + + #track-title { + font-size: 1.75rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text-primary); + } + + #artist { + font-size: 1.125rem; + color: var(--text-secondary); + margin-bottom: 0.25rem; + } + + #album { + font-size: 0.875rem; + color: var(--text-muted); + } + + .playback-state { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--text-secondary); + margin-top: 0.5rem; + } + + .state-icon { + width: 16px; + height: 16px; + } + + .progress-container { + margin-bottom: 2rem; + } + + .time-display { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; + } + + .progress-bar { + width: 100%; + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + cursor: pointer; + position: relative; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + width: 0; + transition: width 0.1s linear; + } + + .controls { + display: flex; + gap: 1rem; + justify-content: center; + align-items: center; + margin-bottom: 2rem; + } + + button { + background: var(--bg-tertiary); + border: none; + color: var(--text-primary); + cursor: pointer; + border-radius: 50%; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + } + + button:hover:not(:disabled) { + background: var(--accent); + transform: scale(1.05); + } + + button:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + button.primary { + width: 56px; + height: 56px; + background: var(--accent); + } + + button.primary:hover:not(:disabled) { + background: var(--accent-hover); + transform: scale(1.1); + } + + .volume-container { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--bg-tertiary); + border-radius: 8px; + margin-bottom: 1rem; + } + + #volume-slider { + flex: 1; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: var(--bg-primary); + border-radius: 3px; + outline: none; + } + + #volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; + } + + #volume-slider::-moz-range-thumb { + width: 16px; + height: 16px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; + border: none; + } + + .volume-display { + font-size: 0.875rem; + color: var(--text-secondary); + min-width: 40px; + text-align: right; + } + + .mute-btn { + width: 40px; + height: 40px; + } + + .source-info { + text-align: center; + font-size: 0.75rem; + color: var(--text-muted); + padding-top: 1rem; + border-top: 1px solid var(--border); + } + + /* Scripts Section */ + .scripts-container { + background: var(--bg-secondary); + border-radius: 12px; + padding: 2rem; + margin-top: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + } + + .scripts-container h2 { + font-size: 1.25rem; + margin-bottom: 1rem; + color: var(--text-primary); + } + + .scripts-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; + } + + .script-btn { + width: 100%; + height: auto; + min-height: 80px; + padding: 1rem; + border-radius: 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + } + + .script-btn:hover:not(:disabled) { + background: var(--accent); + border-color: var(--accent); + transform: translateY(-2px); + } + + .script-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + .script-btn .script-label { + font-weight: 600; + font-size: 0.875rem; + } + + .script-btn .script-description { + font-size: 0.75rem; + color: var(--text-secondary); + text-align: center; + } + + .script-btn.executing { + opacity: 0.6; + pointer-events: none; + } + + /* Script Management Styles */ + .script-management { + background: var(--bg-secondary); + border-radius: 12px; + padding: 2rem; + margin-top: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + } + + .script-management h2 { + font-size: 1.25rem; + margin-bottom: 1rem; + color: var(--text-primary); + } + + .script-management-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + } + + .add-script-btn { + padding: 0.5rem 1.5rem; + border-radius: 6px; + background: var(--accent); + border: none; + color: var(--text-primary); + cursor: pointer; + font-size: 0.875rem; + font-weight: 600; + transition: background 0.2s; + min-width: 140px; + } + + .add-script-btn:hover { + background: var(--accent-hover); + } + + .scripts-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + } + + .scripts-table th { + text-align: left; + padding: 0.75rem; + border-bottom: 2px solid var(--border); + color: var(--text-secondary); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + } + + .scripts-table td { + padding: 0.75rem; + border-bottom: 1px solid var(--border); + } + + .scripts-table tr:hover { + background: var(--bg-tertiary); + } + + .scripts-table code { + background: var(--bg-tertiary); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + color: var(--accent); + } + + .action-btn { + padding: 0.5rem; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-tertiary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .action-btn svg { + width: 16px; + height: 16px; + fill: currentColor; + } + + .action-btn:hover { + background: var(--accent); + border-color: var(--accent); + transform: translateY(-1px); + } + + .action-btn.delete:hover { + background: var(--error); + border-color: var(--error); + } + + .action-buttons { + display: flex; + gap: 0.5rem; + align-items: center; + } + + .action-btn.execute:hover { + background: #3b82f6; + border-color: #3b82f6; + } + + /* Execution Result Dialog */ + .execution-result { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + background: var(--bg-primary); + border-radius: 6px; + padding: 1rem; + margin: 0.5rem 0; + max-height: 400px; + overflow-y: auto; + } + + .execution-result pre { + margin: 0; + white-space: pre-wrap; + word-wrap: break-word; + font-size: 0.813rem; + line-height: 1.5; + } + + .execution-status { + display: flex; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; + } + + .status-item { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .status-item label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + font-weight: 600; + } + + .status-item value { + font-size: 0.875rem; + color: var(--text-primary); + font-weight: 500; + } + + .status-item.success value { + color: var(--accent); + } + + .status-item.error value { + color: var(--error); + } + + .result-section { + margin-bottom: 1rem; + } + + .result-section h4 { + font-size: 0.875rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; + font-weight: 600; + } + + .loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid var(--bg-tertiary); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + /* Dialog Styles */ + dialog { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 0; + max-width: 500px; + width: 90%; + margin: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8); + } + + /* Ensure dialogs are hidden until explicitly opened */ + dialog:not([open]) { + display: none; + } + + dialog::backdrop { + background: rgba(0, 0, 0, 0.8); + } + + .dialog-header { + padding: 1.5rem; + border-bottom: 1px solid var(--border); + } + + .dialog-header h3 { + margin: 0; + font-size: 1.25rem; + } + + .dialog-body { + padding: 1.5rem; + } + + .dialog-body label { + display: block; + margin-bottom: 1rem; + color: var(--text-secondary); + font-size: 0.875rem; + } + + .dialog-body input, + .dialog-body textarea, + .dialog-body select { + display: block; + width: 100%; + padding: 0.5rem; + margin-top: 0.25rem; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-family: inherit; + font-size: 0.875rem; + } + + .dialog-body textarea { + min-height: 80px; + resize: vertical; + } + + .dialog-body input:focus, + .dialog-body textarea:focus, + .dialog-body select:focus { + outline: none; + border-color: var(--accent); + } + + .dialog-footer { + padding: 1.5rem; + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } + + .dialog-footer button { + padding: 0.625rem 1.5rem; + border-radius: 6px; + border: none; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + min-width: 100px; + white-space: nowrap; + } + + .dialog-footer .btn-primary { + background: var(--accent); + color: var(--text-primary); + } + + .dialog-footer .btn-primary:hover { + background: var(--accent-hover); + } + + .dialog-footer .btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); + } + + .dialog-footer .btn-secondary:hover { + background: var(--border); + } + + .empty-state { + text-align: center; + padding: 2rem; + color: var(--text-muted); + } + + .scripts-empty { + text-align: center; + color: var(--text-muted); + padding: 2rem; + font-size: 0.875rem; + } + + .toast { + position: fixed; + bottom: 2rem; + right: 2rem; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.5rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + opacity: 0; + transform: translateY(20px); + transition: all 0.3s; + pointer-events: none; + z-index: 1000; + } + + .toast.show { + opacity: 1; + transform: translateY(0); + } + + .toast.success { + border-color: var(--accent); + } + + .toast.error { + border-color: var(--error); + } + + /* Auth Modal */ + #auth-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + #auth-overlay.hidden { + display: none; + } + + .auth-modal { + background: var(--bg-secondary); + padding: 2rem; + border-radius: 12px; + max-width: 400px; + width: 90%; + } + + .auth-modal h2 { + margin-bottom: 1rem; + font-size: 1.5rem; + } + + .auth-modal p { + margin-bottom: 1rem; + color: var(--text-secondary); + font-size: 0.875rem; + } + + #token-input { + 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; + margin-bottom: 1rem; + } + + #token-input:focus { + outline: none; + border-color: var(--accent); + } + + .btn-connect { + width: 100%; + height: auto; + padding: 0.75rem; + border-radius: 6px; + background: var(--accent); + font-weight: 600; + } + + .btn-connect:hover { + background: var(--accent-hover); + transform: none; + } + + .help-text { + background: var(--bg-tertiary); + padding: 0.75rem; + border-radius: 6px; + margin-top: 1rem; + } + + .help-text code { + background: var(--bg-primary); + padding: 0.25rem 0.5rem; + border-radius: 3px; + font-family: monospace; + font-size: 0.875rem; + } + + .error-message { + color: var(--error); + font-size: 0.875rem; + margin-top: 0.5rem; + display: none; + } + + .error-message.visible { + display: block; + } + + .clear-token-btn { + position: fixed; + top: 1rem; + right: 1rem; + width: auto; + height: auto; + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.75rem; + background: var(--bg-tertiary); + opacity: 0.7; + } + + .clear-token-btn:hover { + opacity: 1; + background: var(--error); + } + + /* SVG Icons */ + svg { + width: 24px; + height: 24px; + fill: currentColor; + } + + button.primary svg { + width: 28px; + height: 28px; + } + + @media (max-width: 600px) { + .container { + padding: 1rem; + } + + #album-art { + width: 250px; + height: 250px; + } + + #track-title { + font-size: 1.5rem; + } + } + + /* Footer */ + footer { + text-align: center; + padding: 2rem 1rem; + margin-top: 3rem; + border-top: 1px solid var(--border); + color: var(--text-secondary); + font-size: 0.875rem; + } + + footer a { + color: var(--accent); + text-decoration: none; + transition: color 0.2s; + } + + footer a:hover { + color: var(--accent-hover); + text-decoration: underline; + } + + footer .separator { + margin: 0 0.5rem; + color: var(--text-muted); + } + +/* ======================================== + Media Browser Styles + ======================================== */ + +.browser-container { + background: var(--bg-secondary); + border-radius: 12px; + padding: 2rem; + margin-top: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +.browser-header-section { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.browser-header-section h2 { + font-size: 1.25rem; + color: var(--text-primary); + 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; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + padding: 0.75rem; + background: var(--bg-tertiary); + border-radius: 6px; + font-size: 0.875rem; + overflow-x: auto; + white-space: nowrap; +} + +.breadcrumb:empty { + display: none; +} + +.breadcrumb-item { + color: var(--accent); + cursor: pointer; + transition: color 0.2s; +} + +.breadcrumb-item:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +.breadcrumb-separator { + color: var(--text-muted); + margin: 0 0.25rem; +} + +/* Browser Grid */ +.browser-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; + min-height: 200px; +} + +.browser-empty { + grid-column: 1 / -1; + text-align: center; + padding: 3rem 2rem; + color: var(--text-muted); + font-size: 0.875rem; +} + +.browser-item { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + position: relative; +} + +.browser-item:hover { + background: var(--border); + border-color: var(--accent); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.browser-item.selected { + border-color: var(--accent); + background: var(--border); +} + +/* Thumbnail Display */ +.browser-thumbnail { + width: 120px; + height: 120px; + object-fit: cover; + border-radius: 6px; + background: var(--bg-primary); + display: block; +} + +.browser-thumbnail.loading { + background: linear-gradient( + 90deg, + var(--bg-primary) 25%, + var(--bg-tertiary) 50%, + var(--bg-primary) 75% + ); + background-size: 200% 100%; + animation: loading 1.5s infinite; + position: relative; + opacity: 0; +} + +.browser-thumbnail.loading::after { + content: '⏳'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 2rem; + opacity: 0.6; + animation: pulse 1.5s infinite; +} + +.browser-thumbnail.loaded { + animation: fadeIn 0.5s ease-out forwards; +} + +@keyframes loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@keyframes pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.8; } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* File/Folder Icons */ +.browser-icon { + width: 120px; + height: 120px; + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + border-radius: 6px; + background: var(--bg-primary); +} + +.browser-item-info { + width: 100%; + text-align: center; +} + +.browser-item-name { + font-size: 0.813rem; + font-weight: 500; + color: var(--text-primary); + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.browser-item-meta { + font-size: 0.75rem; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.browser-item-type { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: var(--bg-primary); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.625rem; + text-transform: uppercase; + font-weight: 600; + color: var(--text-secondary); + z-index: 10; +} + +.browser-item-type.audio { + color: var(--accent); +} + +.browser-item-type.video { + color: #3b82f6; +} + +/* Pagination */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.pagination button { + padding: 0.5rem 1.5rem; + border-radius: 6px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + color: var(--text-primary); + cursor: pointer; + font-size: 0.875rem; + font-weight: 600; + transition: all 0.2s; + width: auto; + height: auto; +} + +.pagination button:hover:not(:disabled) { + background: var(--accent); + border-color: var(--accent); + transform: none; +} + +.pagination button:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.pagination #pageInfo { + font-size: 0.875rem; + color: var(--text-secondary); +} + +/* Responsive Design */ +@media (max-width: 600px) { + .browser-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 0.75rem; + } + + .browser-thumbnail, + .browser-icon { + width: 100px; + height: 100px; + } + + .browser-icon { + font-size: 2.5rem; + } + + .browser-item { + padding: 0.75rem; + } + + .browser-header-section { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .browser-header-section button { + width: 100%; + } +} diff --git a/media_server/static/index.html b/media_server/static/index.html index 93d7f51..da4c816 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -5,916 +5,7 @@ Media Server - + @@ -1018,6 +109,33 @@ + +
+

Media Browser

+ + +
+ +
+ + + + + +
+
Select a folder to browse media files
+
+ + + +
+ + + +
+

Add Media Folder

+
+
+
+ + + + + + + + + + +
+ +
+
+
@@ -1213,1293 +369,6 @@ - + diff --git a/media_server/static/js/app.js b/media_server/static/js/app.js new file mode 100644 index 0000000..f84d7c7 --- /dev/null +++ b/media_server/static/js/app.js @@ -0,0 +1,1698 @@ + // Theme management + function initTheme() { + const savedTheme = localStorage.getItem('theme') || 'dark'; + setTheme(savedTheme); + } + + function setTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('theme', theme); + + const sunIcon = document.getElementById('theme-icon-sun'); + const moonIcon = document.getElementById('theme-icon-moon'); + + if (theme === 'light') { + sunIcon.style.display = 'none'; + moonIcon.style.display = 'block'; + } else { + sunIcon.style.display = 'block'; + moonIcon.style.display = 'none'; + } + } + + function toggleTheme() { + const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark'; + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + setTheme(newTheme); + } + + // Locale management + let currentLocale = 'en'; + let translations = {}; + const supportedLocales = { + 'en': 'English', + 'ru': 'Русский' + }; + + // Minimal inline fallback for critical UI elements + const fallbackTranslations = { + 'app.title': 'Media Server', + 'auth.connect': 'Connect', + 'auth.placeholder': 'Enter API Token', + 'player.status.connected': 'Connected', + 'player.status.disconnected': 'Disconnected' + }; + + // Translation function + function t(key, params = {}) { + let text = translations[key] || fallbackTranslations[key] || key; + + // Replace parameters like {name}, {value}, etc. + Object.keys(params).forEach(param => { + text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]); + }); + + return text; + } + + // Load translation file + async function loadTranslations(locale) { + try { + const response = await fetch(`/static/locales/${locale}.json`); + if (!response.ok) { + throw new Error(`Failed to load ${locale}.json`); + } + return await response.json(); + } catch (error) { + console.error(`Error loading translations for ${locale}:`, error); + // Fallback to English if loading fails + if (locale !== 'en') { + return await loadTranslations('en'); + } + return {}; + } + } + + // Detect browser locale + function detectBrowserLocale() { + const browserLang = navigator.language || navigator.languages?.[0] || 'en'; + const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru' + + // Only return if we support it + return supportedLocales[langCode] ? langCode : 'en'; + } + + // Initialize locale + async function initLocale() { + const savedLocale = localStorage.getItem('locale') || detectBrowserLocale(); + await setLocale(savedLocale); + } + + // Set locale + async function setLocale(locale) { + if (!supportedLocales[locale]) { + locale = 'en'; + } + + // Load translations for the locale + translations = await loadTranslations(locale); + + currentLocale = locale; + document.documentElement.setAttribute('data-locale', locale); + document.documentElement.setAttribute('lang', locale); + localStorage.setItem('locale', locale); + + // Update all text + updateAllText(); + + // Update locale select dropdown (if visible) + updateLocaleSelect(); + + // Remove loading class and show content + document.body.classList.remove('loading-translations'); + document.body.classList.add('translations-loaded'); + } + + // Change locale from dropdown + function changeLocale() { + const select = document.getElementById('locale-select'); + const newLocale = select.value; + if (newLocale && newLocale !== currentLocale) { + localStorage.setItem('locale', newLocale); + setLocale(newLocale); + } + } + + // Update locale select dropdown + function updateLocaleSelect() { + const select = document.getElementById('locale-select'); + if (select) { + select.value = currentLocale; + } + } + + // Update all text on page + function updateAllText() { + // Update all elements with data-i18n attribute + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + el.textContent = t(key); + }); + + // Update all elements with data-i18n-placeholder attribute + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + const key = el.getAttribute('data-i18n-placeholder'); + el.placeholder = t(key); + }); + + // Update all elements with data-i18n-title attribute + document.querySelectorAll('[data-i18n-title]').forEach(el => { + const key = el.getAttribute('data-i18n-title'); + el.title = t(key); + }); + + // Re-apply dynamic content with new translations + // Update playback state + updatePlaybackState(currentState); + + // Update connection status + const connected = ws && ws.readyState === WebSocket.OPEN; + updateConnectionStatus(connected); + + // Re-apply last media status if available + if (lastStatus) { + document.getElementById('track-title').textContent = lastStatus.title || t('player.no_media'); + document.getElementById('source').textContent = lastStatus.source || t('player.unknown_source'); + } + + // Reload tables to get translated content + const token = localStorage.getItem('media_server_token'); + if (token) { + loadScriptsTable(); + loadCallbacksTable(); + } + } + + let ws = null; + let reconnectTimeout = null; + let currentState = 'idle'; + let currentDuration = 0; + let currentPosition = 0; + let isUserAdjustingVolume = false; + let volumeUpdateTimer = null; // Timer for throttling volume updates + let scripts = []; + let lastStatus = null; // Store last status for locale switching + + // Dialog dirty state tracking + let scriptFormDirty = false; + let callbackFormDirty = false; + + // Position interpolation + let lastPositionUpdate = 0; + let lastPositionValue = 0; + let interpolationInterval = null; + + // Initialize on page load + window.addEventListener('DOMContentLoaded', async () => { + // Initialize theme + initTheme(); + + // Initialize locale (async - loads JSON file) + await initLocale(); + + const token = localStorage.getItem('media_server_token'); + if (token) { + connectWebSocket(token); + loadScripts(); + loadScriptsTable(); + loadCallbacksTable(); + } else { + showAuthForm(); + } + + // Volume slider event + const volumeSlider = document.getElementById('volume-slider'); + volumeSlider.addEventListener('input', (e) => { + isUserAdjustingVolume = true; + const volume = parseInt(e.target.value); + document.getElementById('volume-display').textContent = `${volume}%`; + + // Throttle volume updates while dragging (update every 50ms) + if (volumeUpdateTimer) { + clearTimeout(volumeUpdateTimer); + } + volumeUpdateTimer = setTimeout(() => { + setVolume(volume); + volumeUpdateTimer = null; + }, 50); + }); + + volumeSlider.addEventListener('change', (e) => { + // Clear any pending throttled update + if (volumeUpdateTimer) { + clearTimeout(volumeUpdateTimer); + volumeUpdateTimer = null; + } + + // Send final volume update immediately + const volume = parseInt(e.target.value); + setVolume(volume); + setTimeout(() => { isUserAdjustingVolume = false; }, 500); + }); + + // Progress bar click to seek + const progressBar = document.getElementById('progress-bar'); + progressBar.addEventListener('click', (e) => { + if (currentDuration > 0) { + const rect = progressBar.getBoundingClientRect(); + const x = e.clientX - rect.left; + const percent = x / rect.width; + const seekPos = percent * currentDuration; + seek(seekPos); + } + }); + + // Enter key in token input + document.getElementById('token-input').addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + authenticate(); + } + }); + + // Script form dirty state tracking + const scriptForm = document.getElementById('scriptForm'); + scriptForm.addEventListener('input', () => { + scriptFormDirty = true; + }); + scriptForm.addEventListener('change', () => { + scriptFormDirty = true; + }); + + // Callback form dirty state tracking + const callbackForm = document.getElementById('callbackForm'); + callbackForm.addEventListener('input', () => { + callbackFormDirty = true; + }); + callbackForm.addEventListener('change', () => { + callbackFormDirty = true; + }); + + // Script dialog backdrop click to close + const scriptDialog = document.getElementById('scriptDialog'); + scriptDialog.addEventListener('click', (e) => { + // Check if click is on the backdrop (not the dialog content) + if (e.target === scriptDialog) { + closeScriptDialog(); + } + }); + + // Callback dialog backdrop click to close + const callbackDialog = document.getElementById('callbackDialog'); + callbackDialog.addEventListener('click', (e) => { + // Check if click is on the backdrop (not the dialog content) + if (e.target === callbackDialog) { + closeCallbackDialog(); + } + }); + }); + + function showAuthForm(errorMessage = '') { + const overlay = document.getElementById('auth-overlay'); + overlay.classList.remove('hidden'); + + const errorEl = document.getElementById('auth-error'); + if (errorMessage) { + errorEl.textContent = errorMessage; + errorEl.classList.add('visible'); + } else { + errorEl.classList.remove('visible'); + } + } + + function hideAuthForm() { + document.getElementById('auth-overlay').classList.add('hidden'); + } + + function authenticate() { + const token = document.getElementById('token-input').value.trim(); + if (!token) { + showAuthForm(t('auth.required')); + return; + } + + localStorage.setItem('media_server_token', token); + connectWebSocket(token); + } + + function clearToken() { + localStorage.removeItem('media_server_token'); + if (ws) { + ws.close(); + } + showAuthForm(t('auth.cleared')); + } + + function connectWebSocket(token) { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`; + + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + console.log('WebSocket connected'); + updateConnectionStatus(true); + hideAuthForm(); + loadScripts(); + loadScriptsTable(); + loadCallbacksTable(); + }; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + + if (msg.type === 'status' || msg.type === 'status_update') { + updateUI(msg.data); + } else if (msg.type === 'scripts_changed') { + console.log('Scripts changed, reloading...'); + loadScripts(); // Reload Quick Actions + loadScriptsTable(); // Reload Script Management table + } else if (msg.type === 'error') { + console.error('WebSocket error:', msg.message); + } + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + updateConnectionStatus(false); + }; + + ws.onclose = (event) => { + console.log('WebSocket closed:', event.code); + updateConnectionStatus(false); + stopPositionInterpolation(); + + if (event.code === 4001) { + // Invalid token + localStorage.removeItem('media_server_token'); + showAuthForm(t('auth.invalid')); + } else if (event.code !== 1000) { + // Abnormal closure - attempt reconnect + reconnectTimeout = setTimeout(() => { + const savedToken = localStorage.getItem('media_server_token'); + if (savedToken) { + console.log('Attempting to reconnect...'); + connectWebSocket(savedToken); + } + }, 3000); + } + }; + + // Send keepalive ping every 30 seconds + setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } + }, 30000); + } + + function updateConnectionStatus(connected) { + const dot = document.getElementById('status-dot'); + const text = document.getElementById('status-text'); + + if (connected) { + dot.classList.add('connected'); + text.textContent = t('player.status.connected'); + } else { + dot.classList.remove('connected'); + text.textContent = t('player.status.disconnected'); + } + } + + function updateUI(status) { + // Store status for locale switching + lastStatus = status; + + // Update track info + document.getElementById('track-title').textContent = status.title || t('player.no_media'); + document.getElementById('artist').textContent = status.artist || ''; + document.getElementById('album').textContent = status.album || ''; + + // Update state + const previousState = currentState; + currentState = status.state; + updatePlaybackState(status.state); + + // Update album art + const artImg = document.getElementById('album-art'); + if (status.album_art_url) { + const token = localStorage.getItem('media_server_token'); + artImg.src = `/api/media/artwork?token=${encodeURIComponent(token)}&_=${Date.now()}`; + } else { + artImg.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E"; + } + + // Update progress + if (status.duration && status.position !== null) { + currentDuration = status.duration; + currentPosition = status.position; + + // Track position update for interpolation + lastPositionUpdate = Date.now(); + lastPositionValue = status.position; + + updateProgress(status.position, status.duration); + } + + // Update volume + if (!isUserAdjustingVolume) { + document.getElementById('volume-slider').value = status.volume; + document.getElementById('volume-display').textContent = `${status.volume}%`; + } + + // Update mute state + updateMuteIcon(status.muted); + + // Update source + document.getElementById('source').textContent = status.source || t('player.unknown_source'); + + // Enable/disable controls based on state + const hasMedia = status.state !== 'idle'; + document.getElementById('btn-play-pause').disabled = !hasMedia; + document.getElementById('btn-next').disabled = !hasMedia; + document.getElementById('btn-previous').disabled = !hasMedia; + + // Start/stop position interpolation based on playback state + if (status.state === 'playing' && previousState !== 'playing') { + startPositionInterpolation(); + } else if (status.state !== 'playing' && previousState === 'playing') { + stopPositionInterpolation(); + } + } + + function updatePlaybackState(state) { + const stateText = document.getElementById('playback-state'); + const stateIcon = document.getElementById('state-icon'); + const playPauseIcon = document.getElementById('play-pause-icon'); + + switch(state) { + case 'playing': + stateText.textContent = t('state.playing'); + stateIcon.innerHTML = ''; + playPauseIcon.innerHTML = ''; + break; + case 'paused': + stateText.textContent = t('state.paused'); + stateIcon.innerHTML = ''; + playPauseIcon.innerHTML = ''; + break; + case 'stopped': + stateText.textContent = t('state.stopped'); + stateIcon.innerHTML = ''; + playPauseIcon.innerHTML = ''; + break; + default: + stateText.textContent = t('state.idle'); + stateIcon.innerHTML = ''; + playPauseIcon.innerHTML = ''; + } + } + + function updateProgress(position, duration) { + const percent = (position / duration) * 100; + document.getElementById('progress-fill').style.width = `${percent}%`; + document.getElementById('current-time').textContent = formatTime(position); + document.getElementById('total-time').textContent = formatTime(duration); + document.getElementById('progress-bar').dataset.duration = duration; + } + + function startPositionInterpolation() { + // Clear any existing interval + if (interpolationInterval) { + clearInterval(interpolationInterval); + } + + // Update position every 100ms for smooth animation + interpolationInterval = setInterval(() => { + if (currentState === 'playing' && currentDuration > 0 && lastPositionUpdate > 0) { + // Calculate elapsed time since last position update + const elapsed = (Date.now() - lastPositionUpdate) / 1000; + const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration); + + // Update UI with interpolated position + updateProgress(interpolatedPosition, currentDuration); + } + }, 100); + } + + function stopPositionInterpolation() { + if (interpolationInterval) { + clearInterval(interpolationInterval); + interpolationInterval = null; + } + } + + function updateMuteIcon(muted) { + const muteIcon = document.getElementById('mute-icon'); + if (muted) { + muteIcon.innerHTML = ''; + } else { + muteIcon.innerHTML = ''; + } + } + + function formatTime(seconds) { + if (!seconds || seconds < 0) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + } + + // API Commands + async function sendCommand(endpoint, body = null) { + const token = localStorage.getItem('media_server_token'); + + const options = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }; + + if (body) { + options.body = JSON.stringify(body); + } + + try { + const response = await fetch(`/api/media/${endpoint}`, options); + if (!response.ok) { + console.error(`Command ${endpoint} failed:`, response.status); + } + } catch (error) { + console.error(`Error sending command ${endpoint}:`, error); + } + } + + function togglePlayPause() { + if (currentState === 'playing') { + sendCommand('pause'); + } else { + sendCommand('play'); + } + } + + function nextTrack() { + sendCommand('next'); + } + + function previousTrack() { + sendCommand('previous'); + } + + function setVolume(volume) { + sendCommand('volume', { volume: volume }); + } + + function toggleMute() { + sendCommand('mute'); + } + + function seek(position) { + sendCommand('seek', { position: position }); + } + + // Scripts functionality + async function loadScripts() { + const token = localStorage.getItem('media_server_token'); + + try { + const response = await fetch('/api/scripts/list', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + scripts = await response.json(); + displayScripts(); + } + } catch (error) { + console.error('Error loading scripts:', error); + } + } + + function displayScripts() { + const container = document.getElementById('scripts-container'); + const grid = document.getElementById('scripts-grid'); + + if (scripts.length === 0) { + container.style.display = 'none'; + return; + } + + container.style.display = 'block'; + grid.innerHTML = ''; + + scripts.forEach(script => { + const button = document.createElement('button'); + button.className = 'script-btn'; + button.onclick = () => executeScript(script.name, button); + + const label = document.createElement('div'); + label.className = 'script-label'; + label.textContent = script.label || script.name; + + button.appendChild(label); + + if (script.description) { + const description = document.createElement('div'); + description.className = 'script-description'; + description.textContent = script.description; + button.appendChild(description); + } + + grid.appendChild(button); + }); + } + + async function executeScript(scriptName, buttonElement) { + const token = localStorage.getItem('media_server_token'); + + // Add executing state + buttonElement.classList.add('executing'); + + try { + const response = await fetch(`/api/scripts/execute/${scriptName}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ args: [] }) + }); + + const result = await response.json(); + + if (response.ok && result.success) { + showToast(`${scriptName} executed successfully`, 'success'); + } else { + showToast(`Failed to execute ${scriptName}`, 'error'); + } + } catch (error) { + console.error(`Error executing script ${scriptName}:`, error); + showToast(`Error executing ${scriptName}`, 'error'); + } finally { + // Remove executing state + buttonElement.classList.remove('executing'); + } + } + + function showToast(message, type = 'success') { + const toast = document.getElementById('toast'); + toast.textContent = message; + toast.className = `toast ${type} show`; + + setTimeout(() => { + toast.classList.remove('show'); + }, 3000); + } + + // Script Management Functions + + async function loadScriptsTable() { + const token = localStorage.getItem('media_server_token'); + const tbody = document.getElementById('scriptsTableBody'); + + try { + const response = await fetch('/api/scripts/list', { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) { + throw new Error('Failed to fetch scripts'); + } + + const scriptsList = await response.json(); + + if (scriptsList.length === 0) { + tbody.innerHTML = 'No scripts configured. Click "Add Script" to create one.'; + return; + } + + tbody.innerHTML = scriptsList.map(script => ` + + ${script.name} + ${script.label || script.name} + ${escapeHtml(script.command || 'N/A')} + ${script.timeout}s + +
+ + + +
+ + + `).join(''); + } catch (error) { + console.error('Error loading scripts:', error); + tbody.innerHTML = 'Failed to load scripts'; + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function showAddScriptDialog() { + const dialog = document.getElementById('scriptDialog'); + const form = document.getElementById('scriptForm'); + const title = document.getElementById('dialogTitle'); + + // Reset form + form.reset(); + document.getElementById('scriptOriginalName').value = ''; + document.getElementById('scriptIsEdit').value = 'false'; + document.getElementById('scriptName').disabled = false; + title.textContent = t('scripts.dialog.add'); + + // Reset dirty state + scriptFormDirty = false; + + document.body.classList.add('dialog-open'); + dialog.showModal(); + } + + async function showEditScriptDialog(scriptName) { + const token = localStorage.getItem('media_server_token'); + const dialog = document.getElementById('scriptDialog'); + const title = document.getElementById('dialogTitle'); + + try { + // Fetch current script details + const response = await fetch('/api/scripts/list', { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) { + throw new Error('Failed to fetch script details'); + } + + const scriptsList = await response.json(); + const script = scriptsList.find(s => s.name === scriptName); + + if (!script) { + showToast('Script not found', 'error'); + return; + } + + // Populate form + document.getElementById('scriptOriginalName').value = scriptName; + document.getElementById('scriptIsEdit').value = 'true'; + document.getElementById('scriptName').value = scriptName; + document.getElementById('scriptName').disabled = true; // Can't change name + document.getElementById('scriptLabel').value = script.label || ''; + document.getElementById('scriptCommand').value = script.command || ''; + document.getElementById('scriptDescription').value = script.description || ''; + document.getElementById('scriptIcon').value = script.icon || ''; + document.getElementById('scriptTimeout').value = script.timeout || 30; + + title.textContent = t('scripts.dialog.edit'); + + // Reset dirty state + scriptFormDirty = false; + + document.body.classList.add('dialog-open'); + dialog.showModal(); + } catch (error) { + console.error('Error loading script for edit:', error); + showToast('Failed to load script details', 'error'); + } + } + + function closeScriptDialog() { + // Check if form has unsaved changes + if (scriptFormDirty) { + if (!confirm(t('scripts.confirm.unsaved'))) { + return; // User cancelled, don't close + } + } + + const dialog = document.getElementById('scriptDialog'); + scriptFormDirty = false; // Reset dirty state + dialog.close(); + document.body.classList.remove('dialog-open'); + } + + async function saveScript(event) { + event.preventDefault(); + + const token = localStorage.getItem('media_server_token'); + const isEdit = document.getElementById('scriptIsEdit').value === 'true'; + const scriptName = isEdit ? + document.getElementById('scriptOriginalName').value : + document.getElementById('scriptName').value; + + const data = { + command: document.getElementById('scriptCommand').value, + label: document.getElementById('scriptLabel').value || null, + description: document.getElementById('scriptDescription').value || '', + icon: document.getElementById('scriptIcon').value || null, + timeout: parseInt(document.getElementById('scriptTimeout').value) || 30, + shell: true + }; + + const endpoint = isEdit ? + `/api/scripts/update/${scriptName}` : + `/api/scripts/create/${scriptName}`; + + const method = isEdit ? 'PUT' : 'POST'; + + try { + const response = await fetch(endpoint, { + method, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (response.ok && result.success) { + showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success'); + scriptFormDirty = false; // Reset dirty state before closing + closeScriptDialog(); + // Don't reload manually - WebSocket will trigger it + } else { + showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error'); + } + } catch (error) { + console.error('Error saving script:', error); + showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error'); + } + } + + async function deleteScriptConfirm(scriptName) { + if (!confirm(`Are you sure you want to delete the script "${scriptName}"?`)) { + return; + } + + const token = localStorage.getItem('media_server_token'); + + try { + const response = await fetch(`/api/scripts/delete/${scriptName}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + const result = await response.json(); + + if (response.ok && result.success) { + showToast('Script deleted successfully', 'success'); + // Don't reload manually - WebSocket will trigger it + } else { + showToast(result.detail || 'Failed to delete script', 'error'); + } + } catch (error) { + console.error('Error deleting script:', error); + showToast('Error deleting script', 'error'); + } + } + + // Callback Management Functions + + async function loadCallbacksTable() { + const token = localStorage.getItem('media_server_token'); + const tbody = document.getElementById('callbacksTableBody'); + + try { + const response = await fetch('/api/callbacks/list', { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) { + throw new Error('Failed to fetch callbacks'); + } + + const callbacksList = await response.json(); + + if (callbacksList.length === 0) { + tbody.innerHTML = 'No callbacks configured. Click "Add Callback" to create one.'; + return; + } + + tbody.innerHTML = callbacksList.map(callback => ` + + ${callback.name} + ${escapeHtml(callback.command)} + ${callback.timeout}s + +
+ + + +
+ + + `).join(''); + } catch (error) { + console.error('Error loading callbacks:', error); + tbody.innerHTML = 'Failed to load callbacks'; + } + } + + function showAddCallbackDialog() { + const dialog = document.getElementById('callbackDialog'); + const form = document.getElementById('callbackForm'); + const title = document.getElementById('callbackDialogTitle'); + + // Reset form + form.reset(); + document.getElementById('callbackIsEdit').value = 'false'; + document.getElementById('callbackName').disabled = false; + title.textContent = t('callbacks.dialog.add'); + + // Reset dirty state + callbackFormDirty = false; + + document.body.classList.add('dialog-open'); + dialog.showModal(); + } + + async function showEditCallbackDialog(callbackName) { + const token = localStorage.getItem('media_server_token'); + const dialog = document.getElementById('callbackDialog'); + const title = document.getElementById('callbackDialogTitle'); + + try { + // Fetch current callback details + const response = await fetch('/api/callbacks/list', { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) { + throw new Error('Failed to fetch callback details'); + } + + const callbacksList = await response.json(); + const callback = callbacksList.find(c => c.name === callbackName); + + if (!callback) { + showToast('Callback not found', 'error'); + return; + } + + // Populate form + document.getElementById('callbackIsEdit').value = 'true'; + document.getElementById('callbackName').value = callbackName; + document.getElementById('callbackName').disabled = true; // Can't change event name + document.getElementById('callbackCommand').value = callback.command; + document.getElementById('callbackTimeout').value = callback.timeout; + document.getElementById('callbackWorkingDir').value = callback.working_dir || ''; + + title.textContent = t('callbacks.dialog.edit'); + + // Reset dirty state + callbackFormDirty = false; + + document.body.classList.add('dialog-open'); + dialog.showModal(); + } catch (error) { + console.error('Error loading callback for edit:', error); + showToast('Failed to load callback details', 'error'); + } + } + + function closeCallbackDialog() { + // Check if form has unsaved changes + if (callbackFormDirty) { + if (!confirm(t('callbacks.confirm.unsaved'))) { + return; // User cancelled, don't close + } + } + + const dialog = document.getElementById('callbackDialog'); + callbackFormDirty = false; // Reset dirty state + dialog.close(); + document.body.classList.remove('dialog-open'); + } + + async function saveCallback(event) { + event.preventDefault(); + + const token = localStorage.getItem('media_server_token'); + const isEdit = document.getElementById('callbackIsEdit').value === 'true'; + const callbackName = document.getElementById('callbackName').value; + + const data = { + command: document.getElementById('callbackCommand').value, + timeout: parseInt(document.getElementById('callbackTimeout').value) || 30, + working_dir: document.getElementById('callbackWorkingDir').value || null, + shell: true + }; + + const endpoint = isEdit ? + `/api/callbacks/update/${callbackName}` : + `/api/callbacks/create/${callbackName}`; + + const method = isEdit ? 'PUT' : 'POST'; + + try { + const response = await fetch(endpoint, { + method, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (response.ok && result.success) { + showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success'); + callbackFormDirty = false; // Reset dirty state before closing + closeCallbackDialog(); + loadCallbacksTable(); + } else { + showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error'); + } + } catch (error) { + console.error('Error saving callback:', error); + showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error'); + } + } + + async function deleteCallbackConfirm(callbackName) { + if (!confirm(`Are you sure you want to delete the callback "${callbackName}"?`)) { + return; + } + + const token = localStorage.getItem('media_server_token'); + + try { + const response = await fetch(`/api/callbacks/delete/${callbackName}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + const result = await response.json(); + + if (response.ok && result.success) { + showToast('Callback deleted successfully', 'success'); + loadCallbacksTable(); + } else { + showToast(result.detail || 'Failed to delete callback', 'error'); + } + } catch (error) { + console.error('Error deleting callback:', error); + showToast('Error deleting callback', 'error'); + } + } + + // Execution Result Dialog Functions + + function closeExecutionDialog() { + const dialog = document.getElementById('executionDialog'); + dialog.close(); + document.body.classList.remove('dialog-open'); + } + + function showExecutionResult(name, result, type = 'script') { + const dialog = document.getElementById('executionDialog'); + const title = document.getElementById('executionDialogTitle'); + const statusDiv = document.getElementById('executionStatus'); + const outputSection = document.getElementById('outputSection'); + const errorSection = document.getElementById('errorSection'); + const outputPre = document.getElementById('executionOutput'); + const errorPre = document.getElementById('executionError'); + + // Set title + title.textContent = `Execution Result: ${name}`; + + // Build status display + const success = result.success && result.exit_code === 0; + const statusClass = success ? 'success' : 'error'; + const statusText = success ? 'Success' : 'Failed'; + + statusDiv.innerHTML = ` +
+ + ${statusText} +
+
+ + ${result.exit_code !== undefined ? result.exit_code : 'N/A'} +
+
+ + ${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'} +
+ `; + + // Always show output section + outputSection.style.display = 'block'; + if (result.stdout && result.stdout.trim()) { + outputPre.textContent = result.stdout; + } else { + outputPre.textContent = '(no output)'; + outputPre.style.fontStyle = 'italic'; + outputPre.style.color = 'var(--text-secondary)'; + } + + // Show error output if present + if (result.stderr && result.stderr.trim()) { + errorSection.style.display = 'block'; + errorPre.textContent = result.stderr; + errorPre.style.fontStyle = 'normal'; + errorPre.style.color = 'var(--error)'; + } else if (!success && result.error) { + errorSection.style.display = 'block'; + errorPre.textContent = result.error; + errorPre.style.fontStyle = 'normal'; + errorPre.style.color = 'var(--error)'; + } else { + errorSection.style.display = 'none'; + } + + dialog.showModal(); + } + + async function executeScriptDebug(scriptName) { + const token = localStorage.getItem('media_server_token'); + const dialog = document.getElementById('executionDialog'); + const title = document.getElementById('executionDialogTitle'); + const statusDiv = document.getElementById('executionStatus'); + + // Show dialog with loading state + title.textContent = `Executing: ${scriptName}`; + statusDiv.innerHTML = ` +
+ + Running... +
+ `; + document.getElementById('outputSection').style.display = 'none'; + document.getElementById('errorSection').style.display = 'none'; + document.body.classList.add('dialog-open'); + dialog.showModal(); + + try { + const response = await fetch(`/api/scripts/execute/${scriptName}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ args: [] }) + }); + + const result = await response.json(); + + if (response.ok) { + showExecutionResult(scriptName, result, 'script'); + } else { + showExecutionResult(scriptName, { + success: false, + exit_code: -1, + error: result.detail || 'Execution failed', + stderr: result.detail || 'Unknown error' + }, 'script'); + } + } catch (error) { + console.error(`Error executing script ${scriptName}:`, error); + showExecutionResult(scriptName, { + success: false, + exit_code: -1, + error: error.message, + stderr: `Network error: ${error.message}` + }, 'script'); + } + } + + async function executeCallbackDebug(callbackName) { + const token = localStorage.getItem('media_server_token'); + const dialog = document.getElementById('executionDialog'); + const title = document.getElementById('executionDialogTitle'); + const statusDiv = document.getElementById('executionStatus'); + + // Show dialog with loading state + title.textContent = `Executing: ${callbackName}`; + statusDiv.innerHTML = ` +
+ + Running... +
+ `; + document.getElementById('outputSection').style.display = 'none'; + document.getElementById('errorSection').style.display = 'none'; + document.body.classList.add('dialog-open'); + dialog.showModal(); + + try { + // For callbacks, we'll execute them directly via the callback endpoint + // We need to trigger the callback as if the event occurred + const response = await fetch(`/api/callbacks/execute/${callbackName}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + const result = await response.json(); + + if (response.ok) { + showExecutionResult(callbackName, result, 'callback'); + } else { + showExecutionResult(callbackName, { + success: false, + exit_code: -1, + error: result.detail || 'Execution failed', + stderr: result.detail || 'Unknown error' + }, 'callback'); + } + } catch (error) { + console.error(`Error executing callback ${callbackName}:`, error); + showExecutionResult(callbackName, { + success: false, + exit_code: -1, + error: error.message, + stderr: `Network error: ${error.message}` + }, 'callback'); + } + } + + +// ======================================== +// Media Browser Functionality +// ======================================== + +// Browser state +let currentFolderId = null; +let currentPath = ''; +let currentOffset = 0; +const ITEMS_PER_PAGE = 100; +let totalItems = 0; +let mediaFolders = {}; +let selectedItem = null; + +// Load media folders on page load +async function loadMediaFolders() { + try { + const token = localStorage.getItem('media_server_token'); + if (!token) { + console.error('No API token found'); + return; + } + + const response = await fetch('/api/browser/folders', { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) throw new Error('Failed to load folders'); + + mediaFolders = await response.json(); + renderFolderSelect(); + + // Load last browsed path + loadLastBrowserPath(); + } catch (error) { + console.error('Error loading media folders:', error); + showToast(t('browser.error_loading_folders'), 'error'); + } +} + +function renderFolderSelect() { + const select = document.getElementById('folderSelect'); + select.innerHTML = ``; + + Object.entries(mediaFolders).forEach(([id, folder]) => { + if (folder.enabled) { + const option = document.createElement('option'); + option.value = id; + option.textContent = folder.label; + select.appendChild(option); + } + }); +} + +function onFolderSelected() { + const select = document.getElementById('folderSelect'); + currentFolderId = select.value; + + if (currentFolderId) { + currentPath = ''; + currentOffset = 0; + browsePath(currentFolderId, currentPath); + } else { + clearBrowserGrid(); + } +} + +async function browsePath(folderId, path, offset = 0) { + try { + const token = localStorage.getItem('media_server_token'); + if (!token) { + console.error('No API token found'); + return; + } + + const encodedPath = encodeURIComponent(path); + const response = await fetch( + `/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${ITEMS_PER_PAGE}`, + { headers: { 'Authorization': `Bearer ${token}` } } + ); + + if (!response.ok) throw new Error('Failed to browse path'); + + const data = await response.json(); + currentPath = data.current_path; + currentOffset = offset; + totalItems = data.total; + + renderBreadcrumbs(data.current_path, data.parent_path); + renderBrowserGrid(data.items); + renderPagination(); + + // Save last path + saveLastBrowserPath(folderId, currentPath); + } catch (error) { + console.error('Error browsing path:', error); + showToast(t('browser.error_loading'), 'error'); + clearBrowserGrid(); + } +} + +function renderBreadcrumbs(currentPath, parentPath) { + const breadcrumb = document.getElementById('breadcrumb'); + breadcrumb.innerHTML = ''; + + if (!currentPath || currentPath === '/') return; + + const parts = currentPath.split('/').filter(p => p); + let path = '/'; + + // Root + const root = document.createElement('span'); + root.className = 'breadcrumb-item'; + root.textContent = mediaFolders[currentFolderId]?.label || 'Root'; + root.onclick = () => browsePath(currentFolderId, ''); + breadcrumb.appendChild(root); + + // Path parts + parts.forEach((part, index) => { + // Separator + const separator = document.createElement('span'); + separator.className = 'breadcrumb-separator'; + separator.textContent = '›'; + breadcrumb.appendChild(separator); + + // Part + path += (path === '/' ? '' : '/') + part; + const item = document.createElement('span'); + item.className = 'breadcrumb-item'; + item.textContent = part; + const itemPath = path; + item.onclick = () => browsePath(currentFolderId, itemPath); + breadcrumb.appendChild(item); + }); +} + +function renderBrowserGrid(items) { + const grid = document.getElementById('browserGrid'); + grid.innerHTML = ''; + + if (!items || items.length === 0) { + grid.innerHTML = `
${t('browser.no_items')}
`; + return; + } + + items.forEach(item => { + const div = document.createElement('div'); + div.className = 'browser-item'; + div.dataset.name = item.name; + div.dataset.type = item.type; + + // Type badge + if (item.type !== 'folder') { + const typeBadge = document.createElement('div'); + typeBadge.className = `browser-item-type ${item.type}`; + typeBadge.textContent = item.type; + div.appendChild(typeBadge); + } + + // Thumbnail or icon + if (item.is_media && item.type === 'audio') { + const thumbnail = document.createElement('img'); + thumbnail.className = 'browser-thumbnail loading'; + thumbnail.alt = item.name; + div.appendChild(thumbnail); + + // Lazy load thumbnail + loadThumbnail(thumbnail, item.name); + } else { + const icon = document.createElement('div'); + icon.className = 'browser-icon'; + icon.textContent = getFileIcon(item.type); + div.appendChild(icon); + } + + // Info + const info = document.createElement('div'); + info.className = 'browser-item-info'; + + const name = document.createElement('div'); + name.className = 'browser-item-name'; + name.textContent = item.name; + info.appendChild(name); + + if (item.size !== null && item.type !== 'folder') { + const meta = document.createElement('div'); + meta.className = 'browser-item-meta'; + meta.textContent = formatFileSize(item.size); + info.appendChild(meta); + } + + div.appendChild(info); + + // Events + div.onclick = () => handleItemClick(item, div); + div.ondblclick = () => handleItemDoubleClick(item); + + grid.appendChild(div); + }); +} + +function getFileIcon(type) { + const icons = { + 'folder': '📁', + 'audio': '🎵', + 'video': '🎬', + 'other': '📄' + }; + return icons[type] || icons.other; +} + +function formatFileSize(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i]; +} + +async function loadThumbnail(imgElement, fileName) { + try { + const token = localStorage.getItem('media_server_token'); + if (!token) { + console.error('No API token found'); + return; + } + + const fullPath = currentPath === '/' + ? '/' + fileName + : currentPath + '/' + fileName; + const encodedPath = encodeURIComponent( + mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\') + ); + + const response = await fetch( + `/api/browser/thumbnail?path=${encodedPath}&size=medium`, + { headers: { 'Authorization': `Bearer ${token}` } } + ); + + if (response.ok) { + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + + // Wait for image to actually load before showing it + imgElement.onload = () => { + imgElement.classList.remove('loading'); + imgElement.classList.add('loaded'); + }; + + imgElement.src = url; + } else { + // Fallback to icon + const parent = imgElement.parentElement; + imgElement.remove(); + const icon = document.createElement('div'); + icon.className = 'browser-icon'; + icon.textContent = '🎵'; + parent.insertBefore(icon, parent.firstChild); + } + } catch (error) { + console.error('Error loading thumbnail:', error); + imgElement.classList.remove('loading'); + } +} + +function handleItemClick(item, element) { + // Clear previous selection + document.querySelectorAll('.browser-item.selected').forEach(el => { + el.classList.remove('selected'); + }); + + // Select current item + element.classList.add('selected'); + selectedItem = item; +} + +function handleItemDoubleClick(item) { + if (item.type === 'folder') { + // Navigate into folder + const newPath = currentPath === '/' + ? '/' + item.name + : currentPath + '/' + item.name; + browsePath(currentFolderId, newPath); + } else if (item.is_media) { + // Play media file + playMediaFile(item.name); + } +} + +async function playMediaFile(fileName) { + try { + const token = localStorage.getItem('media_server_token'); + if (!token) { + console.error('No API token found'); + return; + } + + const fullPath = currentPath === '/' + ? '/' + fileName + : currentPath + '/' + fileName; + const absolutePath = mediaFolders[currentFolderId].path + fullPath.replace(/\//g, '\\'); + + const response = await fetch('/api/browser/play', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ path: absolutePath }) + }); + + if (!response.ok) throw new Error('Failed to play file'); + + const data = await response.json(); + showToast(t('browser.play_success', { filename: fileName }), 'success'); + } catch (error) { + console.error('Error playing file:', error); + showToast(t('browser.play_error'), 'error'); + } +} + +function renderPagination() { + const pagination = document.getElementById('browserPagination'); + const prevBtn = document.getElementById('prevPage'); + const nextBtn = document.getElementById('nextPage'); + const pageInfo = document.getElementById('pageInfo'); + + const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); + const currentPage = Math.floor(currentOffset / ITEMS_PER_PAGE) + 1; + + if (totalPages <= 1) { + pagination.style.display = 'none'; + return; + } + + pagination.style.display = 'flex'; + pageInfo.textContent = `${currentPage} / ${totalPages}`; + + prevBtn.disabled = currentPage === 1; + nextBtn.disabled = currentPage === totalPages; +} + +function previousPage() { + if (currentOffset >= ITEMS_PER_PAGE) { + browsePath(currentFolderId, currentPath, currentOffset - ITEMS_PER_PAGE); + } +} + +function nextPage() { + if (currentOffset + ITEMS_PER_PAGE < totalItems) { + browsePath(currentFolderId, currentPath, currentOffset + ITEMS_PER_PAGE); + } +} + +function clearBrowserGrid() { + const grid = document.getElementById('browserGrid'); + grid.innerHTML = `
${t('browser.no_folder_selected')}
`; + document.getElementById('breadcrumb').innerHTML = ''; + document.getElementById('browserPagination').style.display = 'none'; +} + +// LocalStorage for last path +function saveLastBrowserPath(folderId, path) { + try { + localStorage.setItem('mediaBrowser.lastFolderId', folderId); + localStorage.setItem('mediaBrowser.lastPath', path); + } catch (e) { + console.error('Failed to save last browser path:', e); + } +} + +function loadLastBrowserPath() { + try { + const lastFolderId = localStorage.getItem('mediaBrowser.lastFolderId'); + const lastPath = localStorage.getItem('mediaBrowser.lastPath'); + + if (lastFolderId && mediaFolders[lastFolderId]) { + document.getElementById('folderSelect').value = lastFolderId; + currentFolderId = lastFolderId; + browsePath(lastFolderId, lastPath || ''); + } + } catch (e) { + console.error('Failed to load last browser path:', e); + } +} + +// Folder Management +function showManageFoldersDialog() { + // TODO: Implement folder management UI + // For now, show a simple alert + showToast(t('browser.manage_folders_hint'), 'info'); +} + +function closeFolderDialog() { + document.getElementById('folderDialog').close(); +} + +async function saveFolder(event) { + event.preventDefault(); + // TODO: Implement folder save functionality + closeFolderDialog(); +} + +// Initialize browser on page load +window.addEventListener('DOMContentLoaded', () => { + // Load media folders after authentication + const token = localStorage.getItem('media_server_token'); + if (token) { + loadMediaFolders(); + } +}); diff --git a/media_server/static/locales/en.json b/media_server/static/locales/en.json index 1caf307..0b6f74c 100644 --- a/media_server/static/locales/en.json +++ b/media_server/static/locales/en.json @@ -107,5 +107,29 @@ "callbacks.msg.load_failed": "Failed to load callback details", "callbacks.msg.list_failed": "Failed to load callbacks", "callbacks.confirm.delete": "Are you sure you want to delete the callback \"{name}\"?", - "callbacks.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?" + "callbacks.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?", + "browser.title": "Media Browser", + "browser.manage_folders": "Manage Folders", + "browser.select_folder": "Select a folder...", + "browser.select_folder_option": "Select a folder...", + "browser.no_folder_selected": "Select a folder to browse media files", + "browser.no_items": "No media files found in this folder", + "browser.previous": "Previous", + "browser.next": "Next", + "browser.play_success": "Playing {filename}", + "browser.play_error": "Failed to play file", + "browser.error_loading": "Error loading directory", + "browser.error_loading_folders": "Failed to load media folders", + "browser.manage_folders_hint": "Folder management coming soon! For now, edit config.yaml to add media folders.", + "browser.folder_dialog.title_add": "Add Media Folder", + "browser.folder_dialog.title_edit": "Edit Media Folder", + "browser.folder_dialog.folder_id": "Folder ID *", + "browser.folder_dialog.folder_id_help": "Alphanumeric and underscore only", + "browser.folder_dialog.label": "Label *", + "browser.folder_dialog.label_help": "Display name for this folder", + "browser.folder_dialog.path": "Path *", + "browser.folder_dialog.path_help": "Absolute path to media directory", + "browser.folder_dialog.enabled": "Enabled", + "browser.folder_dialog.cancel": "Cancel", + "browser.folder_dialog.save": "Save" } diff --git a/media_server/static/locales/ru.json b/media_server/static/locales/ru.json index 75ea136..77f37e2 100644 --- a/media_server/static/locales/ru.json +++ b/media_server/static/locales/ru.json @@ -107,5 +107,29 @@ "callbacks.msg.load_failed": "Не удалось загрузить данные обратного вызова", "callbacks.msg.list_failed": "Не удалось загрузить обратные вызовы", "callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?", - "callbacks.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?" + "callbacks.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?", + "browser.title": "Медиа Браузер", + "browser.manage_folders": "Управление папками", + "browser.select_folder": "Выберите папку...", + "browser.select_folder_option": "Выберите папку...", + "browser.no_folder_selected": "Выберите папку для просмотра медиафайлов", + "browser.no_items": "В этой папке не найдено медиафайлов", + "browser.previous": "Предыдущая", + "browser.next": "Следующая", + "browser.play_success": "Воспроизведение {filename}", + "browser.play_error": "Не удалось воспроизвести файл", + "browser.error_loading": "Ошибка загрузки каталога", + "browser.error_loading_folders": "Не удалось загрузить медиа папки", + "browser.manage_folders_hint": "Управление папками скоро появится! Пока редактируйте config.yaml для добавления медиа папок.", + "browser.folder_dialog.title_add": "Добавить медиа папку", + "browser.folder_dialog.title_edit": "Редактировать медиа папку", + "browser.folder_dialog.folder_id": "ID папки *", + "browser.folder_dialog.folder_id_help": "Только буквы, цифры и подчеркивание", + "browser.folder_dialog.label": "Метка *", + "browser.folder_dialog.label_help": "Отображаемое имя папки", + "browser.folder_dialog.path": "Путь *", + "browser.folder_dialog.path_help": "Абсолютный путь к медиа каталогу", + "browser.folder_dialog.enabled": "Включено", + "browser.folder_dialog.cancel": "Отмена", + "browser.folder_dialog.save": "Сохранить" } diff --git a/pyproject.toml b/pyproject.toml index 364dd4a..dd0ffd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ dependencies = [ "pydantic>=2.0", "pydantic-settings>=2.0", "pyyaml>=6.0", + "mutagen>=1.47.0", + "pillow>=10.0.0", ] [project.optional-dependencies]