diff --git a/media_server/__init__.py b/media_server/__init__.py index 8069df0..48e7669 100644 --- a/media_server/__init__.py +++ b/media_server/__init__.py @@ -1,18 +1,32 @@ """Media Server - REST API for controlling system media playback.""" +import re from importlib.metadata import PackageNotFoundError, version from pathlib import Path +_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE) + def _detect_version() -> str: - # 1. Package metadata (works when pip-installed in dev) + # 1. Live pyproject.toml — only present in dev checkouts. Prefer this + # over installed package metadata so `pip install -e .` users don't + # see stale versions after editing pyproject.toml without reinstalling. + pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" + if pyproject.is_file(): + try: + match = _VERSION_RE.search(pyproject.read_text(encoding="utf-8")) + if match: + return match.group(1) + except OSError: + pass + + # 2. Package metadata (works for any pip-installed copy). try: return version("media-server") except PackageNotFoundError: pass - # 2. VERSION file written by build scripts (production builds) - # Located at install root, two levels up from this package + # 3. VERSION file written by build scripts (production builds). version_file = Path(__file__).resolve().parent.parent.parent / "VERSION" if version_file.is_file(): return version_file.read_text().strip() diff --git a/media_server/config.py b/media_server/config.py index 7edd4c8..2820ba9 100644 --- a/media_server/config.py +++ b/media_server/config.py @@ -1,6 +1,8 @@ """Configuration management for the media server.""" +import logging import os +import secrets from pathlib import Path from typing import Optional @@ -8,6 +10,8 @@ import yaml from pydantic import BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict +logger = logging.getLogger(__name__) + class MediaFolderConfig(BaseModel): """Configuration for a media folder.""" @@ -81,8 +85,35 @@ class Settings(BaseSettings): ) # Server settings - host: str = Field(default="0.0.0.0", description="Server bind address") + host: str = Field( + default="127.0.0.1", + description=( + "Server bind address. Use 127.0.0.1 for loopback-only (default, safest)," + " or 0.0.0.0 to expose on the LAN (requires api_tokens to be set)." + ), + ) port: int = Field(default=8765, description="Server port") + allow_lan_without_auth: bool = Field( + default=False, + description=( + "Allow binding to a non-loopback address with no api_tokens configured." + " Off by default to prevent unauthenticated LAN exposure." + ), + ) + cors_origins: list[str] = Field( + default_factory=list, + description=( + "Allowed CORS origins. Empty (default) means only same-origin requests" + " from http://localhost: and http://127.0.0.1:." + ), + ) + + # Admin-grade operations (script / callback / link / folder create/update/delete). + # When True the same token used for read/play can also persist arbitrary shell + # commands. Disable to make the API read+execute only. + scripts_management: bool = Field(default=True, description="Allow scripts CRUD via API") + callbacks_management: bool = Field(default=True, description="Allow callbacks CRUD via API") + links_management: bool = Field(default=True, description="Allow links CRUD via API") # Authentication (empty = auth disabled, anyone can access the API) api_tokens: dict[str, str] = Field( @@ -218,21 +249,25 @@ def get_config_dir() -> Path: def generate_default_config(path: Optional[Path] = None) -> Path: - """Generate a default configuration file with a new API token.""" + """Generate a default configuration file with a freshly generated API token. + + The token is written into ``api_tokens.default`` and printed to the logger + so first-run users can copy it. Subsequent runs preserve whatever the user + has set. + """ if path is None: path = get_config_dir() / "config.yaml" + default_token = secrets.token_urlsafe(32) + config = { - "host": "0.0.0.0", + "host": "127.0.0.1", "port": 8765, - # "api_tokens": { - # "default": "your-secret-token-here", - # }, + "api_tokens": { + "default": default_token, + }, "poll_interval": 1.0, "log_level": "INFO", - # Audio device to control (use GET /api/audio/devices to list available devices) - # Set to null or remove to use default device - # "audio_device": "Speakers (Realtek", "scripts": { "example_script": { "command": "echo Hello from Media Server!", @@ -240,26 +275,38 @@ def generate_default_config(path: Optional[Path] = None) -> Path: "timeout": 10, "shell": True, }, - # Add your custom scripts here: - # "shutdown": { - # "command": "shutdown /s /t 60", - # "description": "Shutdown computer in 60 seconds", - # "timeout": 5, - # }, - # "lock_screen": { - # "command": "rundll32.exe user32.dll,LockWorkStation", - # "description": "Lock the workstation", - # "timeout": 5, - # }, }, } path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - yaml.dump(config, f, default_flow_style=False, sort_keys=False) + _write_yaml_atomic(path, config) + _restrict_config_perms(path) + + logger.info("Generated default config at %s", path) + logger.info("API token (label=default): %s", default_token) return path +def _write_yaml_atomic(path: Path, data: dict) -> None: + """Write YAML to disk atomically via tmp file + rename, with restricted perms.""" + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + _restrict_config_perms(tmp) + os.replace(tmp, path) + + +def _restrict_config_perms(path: Path) -> None: + """On POSIX, ensure config file is readable only by owner (0600).""" + if os.name == "nt": + return + try: + os.chmod(path, 0o600) + os.chmod(path.parent, 0o700) + except OSError: + logger.debug("Could not chmod %s", path, exc_info=True) + + # Global settings instance settings = Settings.load_from_yaml() diff --git a/media_server/config_manager.py b/media_server/config_manager.py index 0bbf56b..575c4ce 100644 --- a/media_server/config_manager.py +++ b/media_server/config_manager.py @@ -1,52 +1,50 @@ -"""Thread-safe configuration file manager for runtime script updates.""" +"""Thread-safe configuration file manager for runtime updates.""" import logging import os import threading from pathlib import Path -from typing import Optional +from typing import Any, Optional import yaml -from .config import CallbackConfig, LinkConfig, MediaFolderConfig, ScriptConfig, settings +from .config import ( + CallbackConfig, + LinkConfig, + MediaFolderConfig, + ScriptConfig, + _restrict_config_perms, + _write_yaml_atomic, + settings, +) logger = logging.getLogger(__name__) class ConfigManager: - """Thread-safe configuration file manager.""" + """Thread-safe configuration file manager. + + All writes go through ``_save()`` which writes to ``config.yaml.tmp`` and + then ``os.replace()``s it into place so a crash mid-write cannot corrupt + the only persistent user data. On POSIX the file is also chmodded to 0600 + so co-tenant users cannot read the API token. + """ def __init__(self, config_path: Optional[Path] = None): - """Initialize the config manager. - - Args: - config_path: Path to config file. If None, will search standard locations. - """ self._lock = threading.Lock() self._config_path = config_path or self._find_config_path() logger.info(f"ConfigManager initialized with path: {self._config_path}") - def _find_config_path(self) -> Path: - """Find the active config file path. + @staticmethod + def _find_config_path() -> Path: + """Find the active config file path (or the default if none exists yet).""" + search_paths = [Path("config.yaml"), Path("config.yml")] - Returns: - Path to the config file. - - Raises: - FileNotFoundError: If no config file is found. - """ - # Same search logic as Settings.load_from_yaml() - search_paths = [ - Path("config.yaml"), - Path("config.yml"), - ] - - # Add platform-specific config directory - if os.name == "nt": # Windows + if os.name == "nt": appdata = os.environ.get("APPDATA", "") if appdata: search_paths.append(Path(appdata) / "media-server" / "config.yaml") - else: # Linux/Unix/macOS + else: search_paths.append(Path.home() / ".config" / "media-server" / "config.yaml") search_paths.append(Path("/etc/media-server/config.yaml")) @@ -54,7 +52,6 @@ class ConfigManager: if search_path.exists(): return search_path - # If not found, use the default location if os.name == "nt": default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml" else: @@ -63,422 +60,170 @@ class ConfigManager: logger.warning(f"No config file found, using default path: {default_path}") return default_path - def add_script(self, name: str, config: ScriptConfig) -> None: - """Add a new script to config. + def _load(self) -> dict[str, Any]: + """Read the config YAML, returning an empty dict if the file is missing.""" + if not self._config_path.exists(): + return {} + with open(self._config_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} - Args: - name: Script name (must be unique). - config: Script configuration. + def _save(self, data: dict[str, Any]) -> None: + """Atomically write the config YAML and lock down its permissions.""" + self._config_path.parent.mkdir(parents=True, exist_ok=True) + _write_yaml_atomic(self._config_path, data) + _restrict_config_perms(self._config_path) - Raises: - ValueError: If script already exists. - IOError: If config file cannot be written. - """ + # --- Generic per-section CRUD -------------------------------------- + + def _upsert( + self, + section: str, + key: str, + value: Any, + *, + require_absent: bool = False, + require_present: bool = False, + in_memory_target: dict[str, Any] | None = None, + verb: str = "set", + ) -> None: 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 {} + data = self._load() + existing = data.get(section, {}) + if require_absent and key in existing: + raise ValueError(f"{section[:-1].title()} '{key}' already exists") + if require_present and (not isinstance(existing, dict) or key not in existing): + raise ValueError(f"{section[:-1].title()} '{key}' does not exist") - # Check if script already exists - if "scripts" in data and name in data["scripts"]: - raise ValueError(f"Script '{name}' already exists") + if not isinstance(existing, dict): + existing = {} + existing[key] = value.model_dump(exclude_none=True) + data[section] = existing - # Add script - if "scripts" not in data: - data["scripts"] = {} - data["scripts"][name] = config.model_dump(exclude_none=True) + self._save(data) - # 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) + if in_memory_target is not None: + in_memory_target[key] = value + logger.info(f"{section[:-1].title()} '{key}' {verb} in config") - # Update in-memory settings - settings.scripts[name] = config + def _delete( + self, + section: str, + key: str, + *, + in_memory_target: dict[str, Any] | None = None, + ) -> None: + with self._lock: + data = self._load() + existing = data.get(section, {}) + if not isinstance(existing, dict) or key not in existing: + raise ValueError(f"{section[:-1].title()} '{key}' does not exist") + del existing[key] + data[section] = existing - logger.info(f"Script '{name}' added to config") + self._save(data) + + if in_memory_target is not None and key in in_memory_target: + del in_memory_target[key] + logger.info(f"{section[:-1].title()} '{key}' deleted from config") + + # --- Scripts ------------------------------------------------------- + + def add_script(self, name: str, config: ScriptConfig) -> None: + self._upsert( + "scripts", name, config, + require_absent=True, + in_memory_target=settings.scripts, + verb="added", + ) def update_script(self, name: str, config: ScriptConfig) -> None: - """Update an existing script. - - Args: - name: Script name. - config: New script configuration. - - Raises: - ValueError: If script 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 script exists - if "scripts" not in data or name not in data["scripts"]: - raise ValueError(f"Script '{name}' does not exist") - - # Update script - data["scripts"][name] = 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.scripts[name] = config - - logger.info(f"Script '{name}' updated in config") + self._upsert( + "scripts", name, config, + require_present=True, + in_memory_target=settings.scripts, + verb="updated", + ) def delete_script(self, name: str) -> None: - """Delete a script from config. + self._delete("scripts", name, in_memory_target=settings.scripts) - Args: - name: Script name. - - Raises: - ValueError: If script 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 script exists - if "scripts" not in data or name not in data["scripts"]: - raise ValueError(f"Script '{name}' does not exist") - - # Delete script - del data["scripts"][name] - - # 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 name in settings.scripts: - del settings.scripts[name] - - logger.info(f"Script '{name}' deleted from config") + # --- Callbacks ----------------------------------------------------- def add_callback(self, name: str, config: CallbackConfig) -> None: - """Add a new callback to config. - - Args: - name: Callback name (must be unique). - config: Callback configuration. - - Raises: - ValueError: If callback 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 callback already exists - if "callbacks" in data and name in data["callbacks"]: - raise ValueError(f"Callback '{name}' already exists") - - # Add callback - if "callbacks" not in data: - data["callbacks"] = {} - data["callbacks"][name] = 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.callbacks[name] = config - - logger.info(f"Callback '{name}' added to config") + self._upsert( + "callbacks", name, config, + require_absent=True, + in_memory_target=settings.callbacks, + verb="added", + ) def update_callback(self, name: str, config: CallbackConfig) -> None: - """Update an existing callback. - - Args: - name: Callback name. - config: New callback configuration. - - Raises: - ValueError: If callback 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 callback exists - if "callbacks" not in data or name not in data["callbacks"]: - raise ValueError(f"Callback '{name}' does not exist") - - # Update callback - data["callbacks"][name] = 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.callbacks[name] = config - - logger.info(f"Callback '{name}' updated in config") + self._upsert( + "callbacks", name, config, + require_present=True, + in_memory_target=settings.callbacks, + verb="updated", + ) def delete_callback(self, name: str) -> None: - """Delete a callback from config. + self._delete("callbacks", name, in_memory_target=settings.callbacks) - Args: - name: Callback name. - - Raises: - ValueError: If callback 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 callback exists - if "callbacks" not in data or name not in data["callbacks"]: - raise ValueError(f"Callback '{name}' does not exist") - - # Delete callback - del data["callbacks"][name] - - # 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 name in settings.callbacks: - del settings.callbacks[name] - - logger.info(f"Callback '{name}' deleted from config") + # --- Media folders ------------------------------------------------- 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") + self._upsert( + "media_folders", folder_id, config, + require_absent=True, + in_memory_target=settings.media_folders, + verb="added", + ) 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") + self._upsert( + "media_folders", folder_id, config, + require_present=True, + in_memory_target=settings.media_folders, + verb="updated", + ) def delete_media_folder(self, folder_id: str) -> None: - """Delete a media folder from config. + self._delete("media_folders", folder_id, in_memory_target=settings.media_folders) - 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") + # --- Links --------------------------------------------------------- def add_link(self, name: str, config: LinkConfig) -> None: - """Add a new link to config.""" - with self._lock: - 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 {} - - if "links" in data and name in data["links"]: - raise ValueError(f"Link '{name}' already exists") - - if "links" not in data: - data["links"] = {} - data["links"][name] = config.model_dump(exclude_none=True) - - 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) - - settings.links[name] = config - logger.info(f"Link '{name}' added to config") + self._upsert( + "links", name, config, + require_absent=True, + in_memory_target=settings.links, + verb="added", + ) def update_link(self, name: str, config: LinkConfig) -> None: - """Update an existing link.""" - with self._lock: - 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 {} - - if "links" not in data or name not in data["links"]: - raise ValueError(f"Link '{name}' does not exist") - - data["links"][name] = config.model_dump(exclude_none=True) - - with open(self._config_path, "w", encoding="utf-8") as f: - yaml.dump(data, f, default_flow_style=False, sort_keys=False) - - settings.links[name] = config - logger.info(f"Link '{name}' updated in config") + self._upsert( + "links", name, config, + require_present=True, + in_memory_target=settings.links, + verb="updated", + ) def delete_link(self, name: str) -> None: - """Delete a link from config.""" + self._delete("links", name, in_memory_target=settings.links) + + # --- Top-level settings -------------------------------------------- + + def set_setting(self, key: str, value: Any) -> None: + """Set a top-level config setting and persist to YAML.""" with self._lock: - 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 {} - - if "links" not in data or name not in data["links"]: - raise ValueError(f"Link '{name}' does not exist") - - del data["links"][name] - - with open(self._config_path, "w", encoding="utf-8") as f: - yaml.dump(data, f, default_flow_style=False, sort_keys=False) - - if name in settings.links: - del settings.links[name] - logger.info(f"Link '{name}' deleted from config") - - def set_setting(self, key: str, value) -> None: - """Set a top-level config setting and persist to YAML. - - Args: - key: Setting name (e.g., "visualizer_device"). - value: Setting value (None removes the key). - """ - with self._lock: - 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 {} - + data = self._load() if value is None: data.pop(key, None) else: data[key] = value - - 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 + self._save(data) if hasattr(settings, key): setattr(settings, key, value) logger.info("Setting '%s' updated to: %s", key, value) -# Global config manager instance config_manager = ConfigManager() diff --git a/media_server/main.py b/media_server/main.py index d6fd69b..59f9ae6 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -63,10 +63,10 @@ async def lifespan(app: FastAPI): logger = logging.getLogger(__name__) logger.info(f"Media Server starting on {settings.host}:{settings.port}") - # Log authentication status + # Log authentication status — never log full or partial token material. if settings.api_tokens: - for label, token in settings.api_tokens.items(): - logger.info(f"API Token [{label}]: {token[:8]}...") + labels = ", ".join(settings.api_tokens.keys()) + logger.info(f"Authentication enabled. Tokens configured: [{labels}]") else: logger.warning("No API tokens configured — authentication is DISABLED") @@ -87,6 +87,24 @@ async def lifespan(app: FastAPI): # Store globally so health endpoint can access cached result app.state.update_checker = update_checker + # Schedule periodic thumbnail cache cleanup so the 500 MB cap is actually + # enforced. Runs once at startup and then hourly until shutdown. + from .services.thumbnail_service import ThumbnailService + + async def _thumbnail_cleanup_loop() -> None: + while True: + try: + await asyncio.to_thread(ThumbnailService.cleanup_cache) + except Exception as e: + logger.warning("Thumbnail cache cleanup failed: %s", e) + try: + await asyncio.sleep(3600) + except asyncio.CancelledError: + break + + import asyncio + cleanup_task = asyncio.create_task(_thumbnail_cleanup_loop()) + # Register audio visualizer (capture starts on-demand when clients subscribe) analyzer = None if settings.visualizer_enabled: @@ -109,6 +127,13 @@ async def lifespan(app: FastAPI): if update_checker is not None: await update_checker.stop() + # Cancel periodic thumbnail cleanup + cleanup_task.cancel() + try: + await cleanup_task + except asyncio.CancelledError: + pass + # Stop audio visualizer await ws_manager.stop_audio_monitor() if analyzer and analyzer.running: @@ -117,6 +142,13 @@ async def lifespan(app: FastAPI): # Stop WebSocket status monitor await ws_manager.stop_status_monitor() + # Shut down dedicated thread pools so pending scripts don't leak threads + from .routes.callbacks import shutdown_callback_executor + from .routes.scripts import shutdown_script_executor + + shutdown_script_executor() + shutdown_callback_executor() + # Clean up platform-specific resources import platform as _platform if _platform.system() == "Windows": @@ -138,16 +170,43 @@ def create_app() -> FastAPI: # Compress responses > 1KB app.add_middleware(GZipMiddleware, minimum_size=1000) - # Add CORS middleware for cross-origin requests - # Token auth is via Authorization header, not cookies, so credentials are not needed + # CORS — restrict to same-origin by default; users that integrate the API + # from another origin (e.g. Home Assistant on a different host) can set + # cors_origins in config.yaml. + cors_origins = settings.cors_origins or [ + f"http://localhost:{settings.port}", + f"http://127.0.0.1:{settings.port}", + ] app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=cors_origins, allow_credentials=False, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type"], ) + # Security headers — strict CSP for the bundled UI, disallow framing, hide referrer. + @app.middleware("http") + async def security_headers_middleware(request: Request, call_next): + response = await call_next(request) + response.headers.setdefault( + "Content-Security-Policy", + ( + "default-src 'self'; " + "img-src 'self' data: blob: https://api.iconify.design; " + "connect-src 'self' https://api.iconify.design ws: wss:; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline'; " + "font-src 'self' data:; " + "frame-ancestors 'none'; " + "base-uri 'self'" + ), + ) + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("Referrer-Policy", "no-referrer") + return response + # Add token logging middleware @app.middleware("http") async def token_logging_middleware(request: Request, call_next): @@ -247,7 +306,8 @@ def main(): if args.generate_config: config_path = generate_default_config() print(f"Configuration file generated at: {config_path}") - print("Authentication is disabled by default. Add api_tokens to enable it.") + print("A random API token was generated under api_tokens.default.") + print("Run `python -m media_server.main --show-token` to view it.") return if args.show_token: @@ -260,6 +320,32 @@ def main(): print("\nAuthentication is DISABLED (no tokens configured)") return + # First-run bootstrap: if no config has ever been written, generate one + # with a random token instead of starting in the insecure "no-auth" mode. + config_path = get_config_dir() / "config.yaml" + if not config_path.exists() and not settings.api_tokens: + try: + generate_default_config(config_path) + print( + f"\nFirst run: generated default config at {config_path}.\n" + "Run --show-token to retrieve the API token, then restart.", + file=sys.stderr, + ) + sys.exit(0) + except OSError as e: + print(f"WARNING: could not bootstrap config: {e}", file=sys.stderr) + + # Refuse to bind a non-loopback address with no tokens, unless explicitly opted in. + non_loopback = args.host not in ("127.0.0.1", "localhost", "::1") + if non_loopback and not settings.api_tokens and not settings.allow_lan_without_auth: + print( + "ERROR: refusing to bind a non-loopback address with no api_tokens configured.\n" + "Either set api_tokens in config.yaml, bind to 127.0.0.1," + " or set allow_lan_without_auth: true in config.yaml to override.", + file=sys.stderr, + ) + sys.exit(1) + # Check if port is available before starting with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: try: diff --git a/media_server/routes/browser.py b/media_server/routes/browser.py index a0b0edb..5986bd0 100644 --- a/media_server/routes/browser.py +++ b/media_server/routes/browser.py @@ -23,6 +23,17 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/browser", tags=["browser"]) +# Strong refs to background tasks so they don't get garbage-collected mid-flight. +_background_tasks: set[asyncio.Task] = set() + + +def _spawn_background(coro) -> asyncio.Task: + """Schedule a background coroutine and keep a strong ref to its Task.""" + task = asyncio.create_task(coro) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) + return task + def _require_folder_management() -> None: """Raise 403 if media folder management is disabled in config.""" @@ -38,16 +49,23 @@ async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) - Fires as a background task so the HTTP response returns immediately. """ + status = None try: interval = 0.3 elapsed = 0.0 while elapsed < max_wait: await asyncio.sleep(interval) elapsed += interval - status = await controller.get_status() + try: + status = await controller.get_status() + except Exception as poll_err: # noqa: BLE001 — broadcast is best-effort + logger.debug("get_status during broadcast poll failed: %s", poll_err) + continue if status.state in ("playing", "paused"): break + if status is None: + return status_dict = status.model_dump() await ws_manager.broadcast({"type": "status", "data": status_dict}) logger.info(f"Broadcasted status update after opening: {label}") @@ -74,9 +92,14 @@ class FolderUpdateRequest(BaseModel): class PlayRequest(BaseModel): - """Request model for playing a media file.""" + """Request model for playing a media file. - path: str = Field(..., description="Full path to the media file") + Both ``folder_id`` and ``path`` are required so the server can validate + the file lives inside a configured media folder. + """ + + folder_id: str = Field(..., description="Media folder ID") + path: str = Field(..., description="Path relative to folder root") class PlayFolderRequest(BaseModel): @@ -128,8 +151,10 @@ async def create_folder( """ _require_folder_management() try: - # Validate folder_id format (alphanumeric and underscore only) - if not request.folder_id.replace("_", "").isalnum(): + # Validate folder_id format (alphanumeric and underscore only). + # Same constraint is enforced when validating paths so traversal can't + # be smuggled through the ID itself. + if not request.folder_id or not request.folder_id.replace("_", "").isalnum(): raise HTTPException( status_code=400, detail="Folder ID must contain only alphanumeric characters and underscores", @@ -277,13 +302,15 @@ async def browse( # URL decode the path decoded_path = unquote(path) - # Browse directory - result = BrowserService.browse_directory( - folder_id=folder_id, - path=decoded_path, - offset=offset, - limit=limit, - nocache=nocache, + # Browse directory in a thread — iterdir() + stat() can block on + # network shares for many seconds; never run on the event loop. + result = await asyncio.to_thread( + BrowserService.browse_directory, + folder_id, + decoded_path, + offset, + limit, + nocache, ) return result @@ -307,41 +334,40 @@ async def browse( # Metadata Endpoint @router.get("/metadata") async def get_metadata( - path: str = Query(..., description="Full path to media file (URL-encoded)"), + folder_id: str = Query(..., description="Media folder ID"), + path: str = Query(..., description="Path relative to folder root (URL-encoded)"), _: str = Depends(verify_token), ): - """Get metadata for a media file. + """Get metadata for a media file inside a configured media folder. Args: - path: Full path to the media file (URL-encoded). + folder_id: ID of the media folder. + path: Path relative to folder root (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") + file_path = BrowserService.validate_path(folder_id, decoded_path) if not file_path.is_file(): raise HTTPException(status_code=400, detail="Path is not a file") + if not BrowserService.is_media_file(file_path): + raise HTTPException(status_code=400, detail="File is not a media file") - # Extract metadata in executor (blocking operation) - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() metadata = await loop.run_in_executor( None, MetadataService.extract_metadata, file_path, ) - return metadata + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) except HTTPException: raise except Exception as e: @@ -352,59 +378,47 @@ async def get_metadata( # Thumbnail Endpoint @router.get("/thumbnail") async def get_thumbnail( - path: str = Query(..., description="Full path to media file (URL-encoded)"), + folder_id: str = Query(..., description="Media folder ID"), + path: str = Query(..., description="Path relative to folder root (URL-encoded)"), size: str = Query(default="medium", description='Thumbnail size: "small" or "medium"'), _: str = Depends(verify_token), ): - """Get thumbnail for a media file. - - 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. - """ + """Get thumbnail for a media file inside a configured media folder.""" 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") + file_path = BrowserService.validate_path(folder_id, decoded_path) if not file_path.is_file(): raise HTTPException(status_code=400, detail="Path is not a file") + if not BrowserService.is_media_file(file_path): + raise HTTPException(status_code=400, detail="File is not a media file") - # Validate size if size not in ("small", "medium"): size = "medium" - # Get thumbnail thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size) if thumbnail_data is None: return Response(status_code=204) - # Calculate ETag (hash of path + mtime) import hashlib stat = file_path.stat() etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode() etag = hashlib.md5(etag_data).hexdigest() - # Return image with caching headers return Response( content=thumbnail_data, media_type="image/jpeg", headers={ "ETag": f'"{etag}"', - "Cache-Control": "public, max-age=86400", # 24 hours + "Cache-Control": "public, max-age=86400", }, ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) except HTTPException: raise except Exception as e: @@ -420,44 +434,37 @@ async def play_file( ): """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. + Requires both ``folder_id`` and a folder-relative ``path``; the resolved + file must live inside the configured media folder and be a recognized + media file. This prevents arbitrary OS-handler invocation (e.g., + ``os.startfile`` on Windows ``.lnk``/UNC paths). """ try: - file_path = Path(request.path) - - # Validate file exists - if not file_path.exists(): - raise HTTPException(status_code=404, detail="File not found") + decoded_path = unquote(request.path) + file_path = BrowserService.validate_path(request.folder_id, decoded_path) if not file_path.is_file(): raise HTTPException(status_code=400, detail="Path is not a file") - - # Validate file is a media file if not BrowserService.is_media_file(file_path): raise HTTPException(status_code=400, detail="File is not a media file") - # Get media controller and open file controller = get_media_controller() success = await controller.open_file(str(file_path)) if not success: raise HTTPException(status_code=500, detail="Failed to open file") - # Poll until player registers with media session API (up to 2s) - asyncio.create_task(_broadcast_after_open(controller, file_path.name)) + _spawn_background(_broadcast_after_open(controller, file_path.name)) return { "success": True, "message": f"Playing {file_path.name}", } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) except HTTPException: raise except Exception as e: @@ -489,26 +496,38 @@ async def play_folder( if not full_path.is_dir(): raise HTTPException(status_code=400, detail="Path is not a directory") - # Collect all media files sorted by name - media_files = sorted( - [f for f in full_path.iterdir() if f.is_file() and BrowserService.is_media_file(f)], - key=lambda f: f.name.lower(), - ) + def _scan(directory: Path) -> list[Path]: + return sorted( + ( + f for f in directory.iterdir() + if f.is_file() and BrowserService.is_media_file(f) + ), + key=lambda f: f.name.lower(), + ) + + media_files = await asyncio.to_thread(_scan, full_path) if not media_files: raise HTTPException(status_code=404, detail="No media files found in this folder") - # Generate M3U playlist with absolute paths and EXTINF entries - # Written to local temp dir to avoid extra SMB file handle on network shares - # Uses utf-8-sig (BOM) so players detect encoding properly + # Generate M3U playlist with absolute paths and EXTINF entries. + # Use NamedTemporaryFile to get a fresh per-call path — prevents + # symlink-clobber races between concurrent /play-folder requests + # and any local user pre-creating a fixed temp filename. lines = ["#EXTM3U"] for f in media_files: lines.append(f"#EXTINF:-1,{f.stem}") lines.append(str(f)) - m3u_content = "\r\n".join(lines) + "\r\n" + m3u_content = ("\r\n".join(lines) + "\r\n").encode("utf-8-sig") - playlist_path = Path(tempfile.gettempdir()) / ".media_server_playlist.m3u" - playlist_path.write_text(m3u_content, encoding="utf-8-sig") + with tempfile.NamedTemporaryFile( + mode="wb", + prefix=".media_server_playlist_", + suffix=".m3u", + delete=False, + ) as f: + f.write(m3u_content) + playlist_path = Path(f.name) # Open playlist with default player controller = get_media_controller() @@ -517,8 +536,9 @@ async def play_folder( if not success: raise HTTPException(status_code=500, detail="Failed to open playlist") - # Poll until player registers with media session API (up to 2s) - asyncio.create_task(_broadcast_after_open(controller, f"playlist ({len(media_files)} files)")) + _spawn_background( + _broadcast_after_open(controller, f"playlist ({len(media_files)} files)") + ) return { "success": True, diff --git a/media_server/routes/callbacks.py b/media_server/routes/callbacks.py index 677321d..09ca170 100644 --- a/media_server/routes/callbacks.py +++ b/media_server/routes/callbacks.py @@ -3,6 +3,7 @@ import asyncio import logging import subprocess +import sys import time from concurrent.futures import ThreadPoolExecutor from typing import Any @@ -21,6 +22,22 @@ logger = logging.getLogger(__name__) _callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback") +def shutdown_callback_executor() -> None: + """Shut down the callback executor cleanly on application teardown.""" + _callback_executor.shutdown(wait=False, cancel_futures=True) + + +def _require_callbacks_management() -> None: + if not settings.callbacks_management: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + "Callbacks management is disabled. Set callbacks_management: true" + " in config.yaml to enable." + ), + ) + + class CallbackInfo(BaseModel): """Information about a configured callback.""" @@ -131,7 +148,7 @@ async def execute_callback( try: # Execute in dedicated thread pool to not block the default executor - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() result = await loop.run_in_executor( _callback_executor, lambda: _run_callback( @@ -178,6 +195,11 @@ def _run_callback( Dict with exit_code, stdout, stderr, execution_time """ start_time = time.time() + popen_kwargs: dict[str, Any] = {} + if sys.platform == "win32": + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True try: result = subprocess.run( command, @@ -186,6 +208,7 @@ def _run_callback( capture_output=True, text=True, timeout=timeout, + **popen_kwargs, ) execution_time = time.time() - start_time return { @@ -230,7 +253,7 @@ async def create_callback( Raises: HTTPException: If callback already exists or name is invalid. """ - # Validate name + _require_callbacks_management() _validate_callback_name(callback_name) # Check if callback already exists @@ -278,7 +301,7 @@ async def update_callback( Raises: HTTPException: If callback does not exist. """ - # Validate name + _require_callbacks_management() _validate_callback_name(callback_name) # Check if callback exists @@ -324,7 +347,7 @@ async def delete_callback( Raises: HTTPException: If callback does not exist. """ - # Validate name + _require_callbacks_management() _validate_callback_name(callback_name) # Check if callback exists diff --git a/media_server/routes/display.py b/media_server/routes/display.py index 00ca07b..c7cbc98 100644 --- a/media_server/routes/display.py +++ b/media_server/routes/display.py @@ -1,5 +1,6 @@ """Display brightness, power, contrast, input-source, color-preset and picture-mode API.""" +import asyncio import logging from fastapi import APIRouter, Depends @@ -45,19 +46,21 @@ class PictureModeRequest(BaseModel): code: int = Field(ge=0, le=255) +# DDC/CI hardware writes open a per-monitor handle and can take seconds — +# every public endpoint dispatches into a worker thread so the event loop +# stays responsive. + + @router.get("/monitors") async def get_monitors( refresh: bool = False, rediscover: bool = False, _: str = Depends(verify_token), ) -> list[dict]: - """List all connected monitors with their reported DDC/CI capabilities. - - - `refresh=true` bypasses the response TTL cache (re-reads current state). - - `rediscover=true` also drops the per-monitor capability cache, forcing - a full DDC/CI capability probe. Use after a monitor hot-swap. - """ - monitors = list_monitors(force_refresh=refresh, rediscover=rediscover) + """List all connected monitors with their reported DDC/CI capabilities.""" + monitors = await asyncio.to_thread( + list_monitors, force_refresh=refresh, rediscover=rediscover + ) logger.debug("Found %d monitors", len(monitors)) return [m.to_dict() for m in monitors] @@ -67,7 +70,7 @@ async def set_monitor_brightness( monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token) ) -> dict: """Set brightness for a specific monitor.""" - success = set_brightness(monitor_id, request.brightness) + success = await asyncio.to_thread(set_brightness, monitor_id, request.brightness) if success: logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness) return {"success": success} @@ -79,7 +82,7 @@ async def set_monitor_power( ) -> dict: """Turn a monitor on or off.""" action = "on" if request.on else "off" - success = set_power(monitor_id, request.on) + success = await asyncio.to_thread(set_power, monitor_id, request.on) if success: logger.info("Set monitor %d power %s", monitor_id, action) return {"success": success} @@ -90,7 +93,7 @@ async def set_monitor_contrast( monitor_id: int, request: ContrastRequest, _: str = Depends(verify_token) ) -> dict: """Set DDC/CI contrast for a specific monitor.""" - success = set_contrast(monitor_id, request.contrast) + success = await asyncio.to_thread(set_contrast, monitor_id, request.contrast) if success: logger.info("Set monitor %d contrast to %d", monitor_id, request.contrast) return {"success": success} @@ -101,7 +104,7 @@ async def set_monitor_input_source( monitor_id: int, request: InputSourceRequest, _: str = Depends(verify_token) ) -> dict: """Switch a monitor's DDC/CI input source (e.g. HDMI1, DP1).""" - success = set_input_source(monitor_id, request.source) + success = await asyncio.to_thread(set_input_source, monitor_id, request.source) if success: logger.info("Set monitor %d input source to %s", monitor_id, request.source) return {"success": success} @@ -112,7 +115,7 @@ async def set_monitor_color_preset( monitor_id: int, request: ColorPresetRequest, _: str = Depends(verify_token) ) -> dict: """Apply a DDC/CI color preset (color temperature) to the monitor.""" - success = set_color_preset(monitor_id, request.preset) + success = await asyncio.to_thread(set_color_preset, monitor_id, request.preset) if success: logger.info("Set monitor %d color preset to %s", monitor_id, request.preset) return {"success": success} @@ -123,7 +126,7 @@ async def set_monitor_picture_mode( monitor_id: int, request: PictureModeRequest, _: str = Depends(verify_token) ) -> dict: """Apply a DDC/CI picture/scene mode (VCP 0xDC) by raw code.""" - success = set_picture_mode(monitor_id, request.code) + success = await asyncio.to_thread(set_picture_mode, monitor_id, request.code) if success: logger.info("Set monitor %d picture mode to code %d", monitor_id, request.code) return {"success": success} diff --git a/media_server/routes/links.py b/media_server/routes/links.py index e8e2891..87ea4c9 100644 --- a/media_server/routes/links.py +++ b/media_server/routes/links.py @@ -3,9 +3,10 @@ import logging import re from typing import Any +from urllib.parse import urlparse from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from ..auth import verify_token from ..config import LinkConfig, settings @@ -15,6 +16,35 @@ from ..services.websocket_manager import ws_manager router = APIRouter(prefix="/api/links", tags=["links"]) logger = logging.getLogger(__name__) +# Only allow MDI iconify slugs and safe `http(s)`-ish URLs through the API. +_MDI_ICON_RE = re.compile(r"^mdi:[a-z0-9][a-z0-9-]{0,63}$") +_ALLOWED_URL_SCHEMES = {"http", "https"} + + +def _validate_url(url: str) -> str: + """Ensure the URL is well-formed http(s) — no ``javascript:`` etc.""" + parsed = urlparse(url) + if parsed.scheme.lower() not in _ALLOWED_URL_SCHEMES: + raise ValueError("URL must start with http:// or https://") + if not parsed.netloc: + raise ValueError("URL must include a host") + return url + + +def _validate_icon(icon: str) -> str: + """Restrict icon names to safe Material Design Icons slugs.""" + if not _MDI_ICON_RE.match(icon): + raise ValueError("Icon must be of the form 'mdi:'") + return icon + + +def _require_links_management() -> None: + if not settings.links_management: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Links management is disabled. Set links_management: true in config.yaml to enable.", + ) + class LinkInfo(BaseModel): """Information about a configured link.""" @@ -29,22 +59,25 @@ class LinkInfo(BaseModel): class LinkCreateRequest(BaseModel): """Request model for creating or updating a link.""" - url: str = Field(..., description="URL to open", min_length=1) + url: str = Field(..., description="URL to open", min_length=1, max_length=2048) icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')") - label: str = Field(default="", description="Tooltip text") - description: str = Field(default="", description="Optional description") + label: str = Field(default="", description="Tooltip text", max_length=128) + description: str = Field(default="", description="Optional description", max_length=512) + + @field_validator("url") + @classmethod + def _check_url(cls, v: str) -> str: + return _validate_url(v) + + @field_validator("icon") + @classmethod + def _check_icon(cls, v: str) -> str: + return _validate_icon(v) def _validate_link_name(name: str) -> None: - """Validate link name. - - Args: - name: Link name to validate. - - Raises: - HTTPException: If name is invalid. - """ - if not re.match(r'^[a-zA-Z0-9_]+$', name): + """Validate link name.""" + if not re.match(r"^[a-zA-Z0-9_]+$", name): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Link name must contain only letters, numbers, and underscores", @@ -90,6 +123,7 @@ async def create_link( Returns: Success response with link name. """ + _require_links_management() _validate_link_name(link_name) if link_name in settings.links: @@ -129,6 +163,7 @@ async def update_link( Returns: Success response with link name. """ + _require_links_management() _validate_link_name(link_name) if link_name not in settings.links: @@ -166,6 +201,7 @@ async def delete_link( Returns: Success response with link name. """ + _require_links_management() _validate_link_name(link_name) if link_name not in settings.links: diff --git a/media_server/routes/media.py b/media_server/routes/media.py index 745f644..67c346b 100644 --- a/media_server/routes/media.py +++ b/media_server/routes/media.py @@ -27,7 +27,7 @@ def _run_callback(callback_name: str) -> None: try: callback = settings.callbacks[callback_name] - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() result = await loop.run_in_executor( None, lambda: _run_script( @@ -285,7 +285,7 @@ async def visualizer_devices(_: str = Depends(verify_token)) -> list[dict[str, s """List available loopback audio devices for the visualizer.""" from ..services.audio_analyzer import AudioAnalyzer - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices) diff --git a/media_server/routes/scripts.py b/media_server/routes/scripts.py index c2b8c76..53516f5 100644 --- a/media_server/routes/scripts.py +++ b/media_server/routes/scripts.py @@ -2,8 +2,10 @@ import asyncio import logging +import os import re import subprocess +import sys import time from concurrent.futures import ThreadPoolExecutor from typing import Any @@ -23,6 +25,22 @@ _script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script" logger = logging.getLogger(__name__) +def shutdown_script_executor() -> None: + """Shut down the dedicated executor cleanly on application teardown.""" + _script_executor.shutdown(wait=False, cancel_futures=True) + + +def _require_scripts_management() -> None: + if not settings.scripts_management: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + "Scripts management is disabled. Set scripts_management: true" + " in config.yaml to enable." + ), + ) + + class ScriptExecuteRequest(BaseModel): """Request model for script execution with optional parameters.""" @@ -233,7 +251,7 @@ async def execute_script( try: # Execute in dedicated thread pool to not block the default executor - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() result = await loop.run_in_executor( _script_executor, lambda: _run_script( @@ -285,8 +303,16 @@ def _run_script( start_time = time.time() env = None if extra_env: - import os env = {**os.environ, **extra_env} + + # Spawn the script in its own process group / job so a timeout kills the + # whole tree, not just the shell (POSIX) and not just the parent (Windows). + popen_kwargs: dict[str, Any] = {} + if sys.platform == "win32": + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True + try: result = subprocess.run( command, @@ -296,6 +322,7 @@ def _run_script( text=True, timeout=timeout, env=env, + **popen_kwargs, ) execution_time = time.time() - start_time return { @@ -455,7 +482,7 @@ async def create_script( Raises: HTTPException: If script already exists or name is invalid. """ - # Validate name + _require_scripts_management() _validate_script_name(script_name) # Check if script already exists @@ -511,7 +538,7 @@ async def update_script( Raises: HTTPException: If script does not exist. """ - # Validate name + _require_scripts_management() _validate_script_name(script_name) # Check if script exists @@ -565,7 +592,7 @@ async def delete_script( Raises: HTTPException: If script does not exist. """ - # Validate name + _require_scripts_management() _validate_script_name(script_name) # Check if script exists diff --git a/media_server/services/audio_analyzer.py b/media_server/services/audio_analyzer.py index 321abc6..b711b88 100644 --- a/media_server/services/audio_analyzer.py +++ b/media_server/services/audio_analyzer.py @@ -72,6 +72,11 @@ class AudioAnalyzer: self._lifecycle_lock = threading.Lock() self._data: dict | None = None self._current_device_name: str | None = None + # Sticky "no usable device" flag — flipped to True if a capture + # attempt fails because no loopback device exists. Prevents the + # WebSocket manager from looping on start()/stop()/start() forever + # when there's nothing to capture. Cleared by set_device(). + self._unavailable = False # Generation counter — bumped each time _data is refreshed. # Lets the broadcast loop dedupe without comparing dict identity # (which is fragile because we always allocate a new dict). @@ -123,6 +128,10 @@ class AudioAnalyzer: return True if not self.available: return False + if self._unavailable: + # We already tried and failed to acquire a device. Don't + # spin a new capture thread for each new subscriber. + return False # Reset AGC envelope so a long silent gap between sessions # doesn't make the first new transients clip at the ceiling. @@ -235,6 +244,9 @@ class AudioAnalyzer: self.device_name = device_name self._current_device_name = None + # Clear the "no device" sticky flag — the user is asking for a + # different device so it's worth attempting capture again. + self._unavailable = False if was_running: return self.start() @@ -269,6 +281,7 @@ class AudioAnalyzer: if device is None: logger.warning("No loopback audio device found - visualizer disabled") self._running = False + self._unavailable = True return interval = 1.0 / self.target_fps diff --git a/media_server/services/browser_service.py b/media_server/services/browser_service.py index 0be8f43..a11ff0a 100644 --- a/media_server/services/browser_service.py +++ b/media_server/services/browser_service.py @@ -63,14 +63,28 @@ class BrowserService: 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("/\\") + # Reject absolute paths, drive letters, UNC paths, and NUL bytes outright. + # Only true folder-relative paths are accepted. + if "\x00" in requested_path: + raise ValueError("Path contains NUL byte") + + # Strip a single leading "/" or "\\" (legacy callers send "/sub/dir") but + # then refuse anything that still looks absolute. + cleaned = requested_path.lstrip("/\\") + # Detect Windows drive letter like "C:/..." after stripping. + if len(cleaned) >= 2 and cleaned[1] == ":": + raise ValueError("Absolute paths are not allowed") + # Detect raw UNC ("\\\\server\\share") — the lstrip above strips at most + # one leading slash, so a UNC original starts with another "\\" or "/". + if cleaned.startswith("\\") or cleaned.startswith("/"): + raise ValueError("Absolute paths are not allowed") + candidate = Path(cleaned) if cleaned else None + if candidate is not None and candidate.is_absolute(): + raise ValueError("Absolute paths are not allowed") # Build and resolve full path - if requested_path: - full_path = (base_path / requested_path).resolve() + if cleaned: + full_path = (base_path / cleaned).resolve() else: full_path = base_path diff --git a/media_server/services/linux_media.py b/media_server/services/linux_media.py index c6628e8..b022677 100644 --- a/media_server/services/linux_media.py +++ b/media_server/services/linux_media.py @@ -151,22 +151,19 @@ class LinuxMediaController(MediaController): logger.error(f"Failed to toggle mute: {e}") return False - async def get_status(self) -> MediaStatus: - """Get current media playback status.""" + def _sync_get_status(self) -> MediaStatus: + """Synchronous status read (called from a worker thread).""" status = MediaStatus() - # Get system volume volume, muted = self._get_volume_pulseaudio() status.volume = volume status.muted = muted - # Get active player player_name = self._get_active_player() if player_name is None: status.state = MediaState.IDLE return status - # Get playback status playback_status = self._get_property(player_name, "PlaybackStatus") if playback_status == "Playing": status.state = MediaState.PLAYING @@ -177,114 +174,70 @@ class LinuxMediaController(MediaController): else: status.state = MediaState.IDLE - # Get metadata metadata = self._get_property(player_name, "Metadata") if metadata: status.title = str(metadata.get("xesam:title", "")) or None - artists = metadata.get("xesam:artist", []) if artists: status.artist = str(artists[0]) if isinstance(artists, list) else str(artists) - status.album = str(metadata.get("xesam:album", "")) or None status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None - - # Duration in microseconds length = metadata.get("mpris:length", 0) if length: status.duration = int(length) / 1_000_000 - # Get position (in microseconds) position = self._get_property(player_name, "Position") if position is not None: status.position = int(position) / 1_000_000 - # Get source name status.source = player_name.replace(self.MPRIS_PREFIX, "") - return status - async def play(self) -> bool: - """Resume playback.""" + async def get_status(self) -> MediaStatus: + """Get current media playback status (off the event loop).""" + # pactl + DBus calls each take 5-100ms on a Pi and would block every + # other coroutine on the server. Run them in a worker thread. + return await asyncio.to_thread(self._sync_get_status) + + def _call_player(self, method_name: str) -> bool: player_name = self._get_active_player() if player_name is None: return False try: player = self._get_player_interface(player_name) - player.Play() + getattr(player, method_name)() return True except Exception as e: - logger.error(f"Failed to play: {e}") + logger.error(f"Failed to call player.{method_name}: {e}") return False + async def play(self) -> bool: + return await asyncio.to_thread(self._call_player, "Play") + async def pause(self) -> bool: - """Pause playback.""" - player_name = self._get_active_player() - if player_name is None: - return False - try: - player = self._get_player_interface(player_name) - player.Pause() - return True - except Exception as e: - logger.error(f"Failed to pause: {e}") - return False + return await asyncio.to_thread(self._call_player, "Pause") async def stop(self) -> bool: - """Stop playback.""" - player_name = self._get_active_player() - if player_name is None: - return False - try: - player = self._get_player_interface(player_name) - player.Stop() - return True - except Exception as e: - logger.error(f"Failed to stop: {e}") - return False + return await asyncio.to_thread(self._call_player, "Stop") async def next_track(self) -> bool: - """Skip to next track.""" - player_name = self._get_active_player() - if player_name is None: - return False - try: - player = self._get_player_interface(player_name) - player.Next() - return True - except Exception as e: - logger.error(f"Failed to skip next: {e}") - return False + return await asyncio.to_thread(self._call_player, "Next") async def previous_track(self) -> bool: - """Go to previous track.""" - player_name = self._get_active_player() - if player_name is None: - return False - try: - player = self._get_player_interface(player_name) - player.Previous() - return True - except Exception as e: - logger.error(f"Failed to skip previous: {e}") - return False + return await asyncio.to_thread(self._call_player, "Previous") async def set_volume(self, volume: int) -> bool: - """Set system volume.""" - return self._set_volume_pulseaudio(volume) + return await asyncio.to_thread(self._set_volume_pulseaudio, volume) async def toggle_mute(self) -> bool: - """Toggle mute state.""" - return self._toggle_mute_pulseaudio() + return await asyncio.to_thread(self._toggle_mute_pulseaudio) - async def seek(self, position: float) -> bool: - """Seek to position in seconds.""" + def _sync_seek(self, position: float) -> bool: player_name = self._get_active_player() if player_name is None: return False try: player = self._get_player_interface(player_name) - # MPRIS expects position in microseconds player.SetPosition( self._get_property(player_name, "Metadata").get("mpris:trackid", "/"), int(position * 1_000_000), @@ -294,6 +247,9 @@ class LinuxMediaController(MediaController): logger.error(f"Failed to seek: {e}") return False + async def seek(self, position: float) -> bool: + return await asyncio.to_thread(self._sync_seek, position) + async def open_file(self, file_path: str) -> bool: """Open a media file with the default system player (Linux). diff --git a/media_server/services/thumbnail_service.py b/media_server/services/thumbnail_service.py index 36742f2..5b40235 100644 --- a/media_server/services/thumbnail_service.py +++ b/media_server/services/thumbnail_service.py @@ -321,7 +321,7 @@ class ThumbnailService: if suffix in AUDIO_EXTENSIONS: # Audio files - run in executor (sync operation) - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() thumbnail_data = await loop.run_in_executor( None, ThumbnailService.generate_audio_thumbnail, diff --git a/media_server/services/websocket_manager.py b/media_server/services/websocket_manager.py index 5f2d57e..d70fac2 100644 --- a/media_server/services/websocket_manager.py +++ b/media_server/services/websocket_manager.py @@ -70,16 +70,27 @@ class ConnectionManager: ) async def broadcast(self, message: dict[str, Any]) -> None: - """Broadcast a message to all connected clients concurrently.""" + """Broadcast a message to all connected clients concurrently. + + The payload is serialized once and pushed via ``send_text`` to every + client, instead of having Starlette/Pydantic encode it N times via + ``send_json``. + """ async with self._lock: connections = list(self._active_connections) if not connections: return + try: + payload = json.dumps(message, default=str) + except (TypeError, ValueError) as e: + logger.error("Failed to encode broadcast message: %s", e) + return + async def _send(ws: WebSocket) -> WebSocket | None: try: - await ws.send_json(message) + await ws.send_text(payload) return None except Exception as e: logger.debug("Failed to send to client: %s", e) @@ -129,7 +140,7 @@ class ConnectionManager: async def _maybe_start_capture(self) -> None: """Start audio capture if not already running (called on first subscriber).""" if self._audio_analyzer and not self._audio_analyzer.running: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() started = await loop.run_in_executor(None, self._audio_analyzer.start) if started: logger.info("Audio capture started (first subscriber)") @@ -139,7 +150,7 @@ class ConnectionManager: async def _maybe_stop_capture(self) -> None: """Stop audio capture if running (called when last subscriber leaves).""" if self._audio_analyzer and self._audio_analyzer.running: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._audio_analyzer.stop) logger.info("Audio capture stopped (no subscribers)") @@ -171,7 +182,7 @@ class ConnectionManager: idle_interval = 1.0 / max(1, settings.visualizer_fps) # Bounded wait so we still notice subscribe/unsubscribe transitions. wake_timeout = max(0.05, idle_interval) - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() last_seq = -1 diff --git a/media_server/services/windows_media.py b/media_server/services/windows_media.py index 2441ffe..a073fc6 100644 --- a/media_server/services/windows_media.py +++ b/media_server/services/windows_media.py @@ -15,6 +15,22 @@ logger = logging.getLogger(__name__) # Thread pool for WinRT operations (they don't play well with asyncio) _executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt") +# Cache an asyncio event loop per worker thread so the 500ms status poll +# doesn't allocate + tear down a new loop on every tick. Creating a loop +# every 0.5s churns CPU and leaks finalized loop references that linger in +# WinRT callbacks. With this helper a thread reuses one loop forever and +# we only pay the setup cost once per worker. +_thread_local = threading.local() + + +def _thread_loop() -> asyncio.AbstractEventLoop: + loop = getattr(_thread_local, "loop", None) + if loop is None or loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + _thread_local.loop = loop + return loop + # Global storage for current album art (as bytes) _current_album_art_bytes: bytes | None = None @@ -161,8 +177,6 @@ WINDOWS_AVAILABLE = WINSDK_AVAILABLE def _sync_get_media_status() -> dict[str, Any]: """Synchronously get media status (runs in thread pool).""" - import asyncio - result = { "state": "idle", "title": None, @@ -174,9 +188,7 @@ def _sync_get_media_status() -> dict[str, Any]: } try: - # Create a new event loop for this thread - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) + loop = _thread_loop() try: # Get media session manager @@ -393,7 +405,8 @@ def _sync_get_media_status() -> dict[str, Any]: result["source"] = session.source_app_user_model_id finally: - loop.close() + # Reuse the loop across calls — see _thread_loop above. + pass except Exception as e: logger.error(f"Error getting media status: {e}") @@ -439,35 +452,28 @@ def _find_best_session(manager, loop): def _sync_media_command(command: str) -> bool: """Synchronously execute a media command (runs in thread pool).""" - import asyncio - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - manager = loop.run_until_complete(MediaManager.request_async()) - if manager is None: - return False - - session = _find_best_session(manager, loop) - if session is None: - return False - - if command == "play": - return loop.run_until_complete(session.try_play_async()) - elif command == "pause": - return loop.run_until_complete(session.try_pause_async()) - elif command == "stop": - return loop.run_until_complete(session.try_stop_async()) - elif command == "next": - return loop.run_until_complete(session.try_skip_next_async()) - elif command == "previous": - return loop.run_until_complete(session.try_skip_previous_async()) - + loop = _thread_loop() + manager = loop.run_until_complete(MediaManager.request_async()) + if manager is None: return False - finally: - loop.close() + + session = _find_best_session(manager, loop) + if session is None: + return False + + if command == "play": + return loop.run_until_complete(session.try_play_async()) + elif command == "pause": + return loop.run_until_complete(session.try_pause_async()) + elif command == "stop": + return loop.run_until_complete(session.try_stop_async()) + elif command == "next": + return loop.run_until_complete(session.try_skip_next_async()) + elif command == "previous": + return loop.run_until_complete(session.try_skip_previous_async()) + + return False except Exception as e: logger.error(f"Error executing media command {command}: {e}") @@ -476,27 +482,20 @@ def _sync_media_command(command: str) -> bool: def _sync_seek(position: float) -> bool: """Synchronously seek to position.""" - import asyncio - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) + loop = _thread_loop() + manager = loop.run_until_complete(MediaManager.request_async()) + if manager is None: + return False - try: - manager = loop.run_until_complete(MediaManager.request_async()) - if manager is None: - return False + session = _find_best_session(manager, loop) + if session is None: + return False - session = _find_best_session(manager, loop) - if session is None: - return False - - position_ticks = int(position * 10_000_000) - return loop.run_until_complete( - session.try_change_playback_position_async(position_ticks) - ) - finally: - loop.close() + position_ticks = int(position * 10_000_000) + return loop.run_until_complete( + session.try_change_playback_position_async(position_ticks) + ) except Exception as e: logger.error(f"Error seeking: {e}") @@ -559,7 +558,7 @@ class WindowsMediaController(MediaController): # Get media info in thread pool (avoids asyncio/WinRT issues) try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() media_info = await asyncio.wait_for( loop.run_in_executor(_executor, _sync_get_media_status), timeout=5.0 @@ -592,7 +591,7 @@ class WindowsMediaController(MediaController): async def _run_command(self, command: str) -> bool: """Run a media command in the thread pool.""" try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() return await asyncio.wait_for( loop.run_in_executor(_executor, _sync_media_command, command), timeout=5.0 @@ -616,16 +615,15 @@ class WindowsMediaController(MediaController): """Stop playback.""" return await self._run_command("stop") - async def next_track(self) -> bool: - """Skip to next track.""" - # Get current title before skipping - try: - status = await self.get_status() - old_title = status.title or "" - except Exception: - old_title = "" + async def _skip_track(self, command: str) -> bool: + # Read the current title from the position cache instead of doing a + # full WinRT round-trip (which can take up to 5s) just for one field. + with _position_lock: + track_id = _position_cache.get("track_id") or "" + # track_id is "title:artist:duration" — extract just the title. + old_title = track_id.split(":", 1)[0] if track_id else "" - result = await self._run_command("next") + result = await self._run_command(command) if result: with _position_lock: _track_skip_pending["active"] = True @@ -634,23 +632,13 @@ class WindowsMediaController(MediaController): logger.debug(f"Track skip initiated, old title: {old_title}") return result + async def next_track(self) -> bool: + """Skip to next track.""" + return await self._skip_track("next") + async def previous_track(self) -> bool: """Go to previous track.""" - # Get current title before skipping - try: - status = await self.get_status() - old_title = status.title or "" - except Exception: - old_title = "" - - result = await self._run_command("previous") - if result: - with _position_lock: - _track_skip_pending["active"] = True - _track_skip_pending["old_title"] = old_title - _track_skip_pending["skip_time"] = _time.time() - logger.debug(f"Track skip initiated, old title: {old_title}") - return result + return await self._skip_track("previous") async def set_volume(self, volume: int) -> bool: """Set system volume.""" @@ -680,7 +668,7 @@ class WindowsMediaController(MediaController): async def seek(self, position: float) -> bool: """Seek to position in seconds.""" try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() return await asyncio.wait_for( loop.run_in_executor(_executor, _sync_seek, position), timeout=5.0 @@ -705,7 +693,7 @@ class WindowsMediaController(MediaController): """ try: import os - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() await loop.run_in_executor(None, lambda: os.startfile(file_path)) logger.info(f"Opened file with default player: {file_path}") return True diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 7a2ebba..114cef6 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -329,7 +329,7 @@ body.translations-loaded { button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; - box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.2); + box-shadow: 0 0 0 4px rgba(var(--copper-rgb), 0.2); } input:focus-visible, @@ -337,7 +337,7 @@ select:focus-visible, textarea:focus-visible { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.15); + box-shadow: 0 0 0 3px rgba(var(--copper-rgb), 0.15); } .tab-btn:focus-visible { @@ -1004,7 +1004,7 @@ button:disabled { .controls button:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; - box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.25); + box-shadow: 0 0 0 4px rgba(var(--copper-rgb), 0.25); } .mute-btn:focus-visible, @@ -1012,7 +1012,7 @@ button:disabled { .vinyl-toggle-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; - box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.25); + box-shadow: 0 0 0 4px rgba(var(--copper-rgb), 0.25); } .controls button.primary { @@ -1060,7 +1060,7 @@ button:disabled { #volume-slider:hover::-webkit-slider-thumb { transform: scale(1.3); - box-shadow: 0 0 6px rgba(29, 185, 84, 0.4); + box-shadow: 0 0 6px rgba(var(--copper-rgb), 0.4); } #volume-slider::-moz-range-thumb { @@ -1075,7 +1075,7 @@ button:disabled { #volume-slider:hover::-moz-range-thumb { transform: scale(1.3); - box-shadow: 0 0 6px rgba(29, 185, 84, 0.4); + box-shadow: 0 0 6px rgba(var(--copper-rgb), 0.4); } .volume-display { @@ -1169,7 +1169,7 @@ button:disabled { .vinyl-toggle-btn.active { color: var(--accent); border-color: var(--accent); - background: rgba(29, 185, 84, 0.1); + background: rgba(var(--copper-rgb), 0.1); } .vinyl-toggle-btn svg { @@ -1393,7 +1393,7 @@ button:disabled { border-radius: 4px; background: var(--bg-tertiary); color: var(--text-primary); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-family: var(--sans, inherit); font-size: 1rem; cursor: pointer; transition: border-color 0.25s ease, box-shadow 0.25s ease; @@ -1402,7 +1402,7 @@ button:disabled { .audio-device-selector select:focus { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.15); + box-shadow: 0 0 0 3px rgba(var(--copper-rgb), 0.15); } .audio-device-status { @@ -1836,13 +1836,18 @@ button:disabled { dialog { background: var(--bg-secondary); color: var(--text-primary); + /* Editorial chrome to match the rest of the Studio Reference layout: + no rounded corners, hairline border, and a copper top accent that + lets the dialog read as a continuation of the magazine rather than + a generic Material modal. */ border: 1px solid var(--border); - border-radius: 12px; + border-top: 1px solid var(--copper); + border-radius: 0; padding: 0; - max-width: 500px; + max-width: 520px; width: 90%; margin: auto; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.65); animation: dialogIn 0.25s ease-out; overflow: visible; } @@ -2827,7 +2832,7 @@ button.primary svg { .breadcrumb-item:hover { color: var(--accent); - background: rgba(29, 185, 84, 0.08); + background: rgba(var(--copper-rgb), 0.08); text-decoration: none; } @@ -3235,12 +3240,14 @@ button.primary svg { } .browser-item { - background: var(--bg-tertiary); + /* Match the editorial card language used elsewhere on the page — + transparent background, hairline border, copper-on-hover. */ + background: transparent; border: 1px solid transparent; - border-radius: 10px; + border-radius: 0; padding: 0.6rem; cursor: pointer; - transition: all 0.2s ease; + transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease; display: flex; flex-direction: column; align-items: center; @@ -3250,6 +3257,11 @@ button.primary svg { animation-delay: calc(var(--item-index, 0) * 25ms); } +.browser-item:hover { + border-color: rgba(var(--copper-rgb), 0.45); + background: rgba(var(--copper-rgb), 0.04); +} + @keyframes itemFadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } @@ -3286,14 +3298,14 @@ button.primary svg { height: 56px; font-size: 1.75rem; border-radius: 14px; - background: rgba(29, 185, 84, 0.1); - border: 1px solid rgba(29, 185, 84, 0.15); + background: rgba(var(--copper-rgb), 0.1); + border: 1px solid rgba(var(--copper-rgb), 0.15); transition: all 0.25s; } .browser-item.browser-root-folder:hover .browser-icon { - background: rgba(29, 185, 84, 0.18); - border-color: rgba(29, 185, 84, 0.3); + background: rgba(var(--copper-rgb), 0.18); + border-color: rgba(var(--copper-rgb), 0.3); transform: scale(1.05); } @@ -3963,7 +3975,13 @@ html { } @media (max-width: 720px) { - .container { padding: 48px 18px 24px; } + .container { padding: 32px 18px 20px; } +} + +/* Phones: trim the editorial spread further so the first viewport isn't + 90% chrome. The 56px top pad eats a third of a 360x640 screen. */ +@media (max-width: 480px) { + .container { padding: 16px 12px 16px; } } /* ─── Folio marks (page corners, all tabs) ────────────────── */ diff --git a/media_server/static/index.html b/media_server/static/index.html index 3abb78b..f1b1732 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -522,7 +522,7 @@
-

No callbacks configured. Click "Add" to create one.

+

No callbacks configured. Click "Add" to create one.

@@ -608,7 +608,7 @@
-

Execute Script

+

Execute Script

diff --git a/media_server/static/js/browser.js b/media_server/static/js/browser.js index 5a26d67..f5ae045 100644 --- a/media_server/static/js/browser.js +++ b/media_server/static/js/browser.js @@ -66,12 +66,14 @@ function showRootFolders() { // Hide search at root level showBrowserSearch(false); - // Render breadcrumb with just "Home" (not clickable at root) + // Render breadcrumb with just "Home" (already at root — not interactive). const breadcrumb = document.getElementById('breadcrumb'); breadcrumb.innerHTML = ''; const root = document.createElement('span'); root.className = 'breadcrumb-item breadcrumb-home'; - root.innerHTML = ''; + root.setAttribute('aria-current', 'page'); + root.setAttribute('aria-label', t('browser.home') || 'Home'); + root.innerHTML = ''; breadcrumb.appendChild(root); // Hide play all button and pagination @@ -133,8 +135,10 @@ function showRootFolders() { } async function browsePath(folderId, path, offset = 0, nocache = false) { - // Clear search when navigating + // Clear search when navigating; bump browse generation so in-flight + // thumbnail fetches from the previous folder can be discarded. showBrowserSearch(false); + bumpBrowseGen(); try { if (!hasCredentials()) return; @@ -195,10 +199,13 @@ function renderBreadcrumbs(currentPathStr, parentPath) { const parts = (currentPathStr || '').split('/').filter(p => p); let path = '/'; - // Home link (back to folder list) - const home = document.createElement('span'); + // Home link (back to folder list) — use a real `; } const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 '); - const detailsHtml = details ? `${details}` : ''; + const detailsHtml = details ? `${escapeHtml(details)}` : ''; const primaryBadge = monitor.is_primary ? `
- ${monitor.name}${primaryBadge} + ${escapeHtml(monitor.name)}${primaryBadge} ${detailsHtml}
${powerBtn} @@ -303,6 +306,11 @@ export async function loadDisplayMonitors() { container.appendChild(card); }); + // Bind a single delegated click handler for the power buttons. + // Avoids inline onclick="..." with interpolated monitor data. + container.removeEventListener('click', _onPowerButtonClick); + container.addEventListener('click', _onPowerButtonClick); + // Enhance every tuning