fix: comprehensive security, bug, performance, and UI/UX audit
Lint & Test / test (push) Successful in 20s

Security
- Default bind 127.0.0.1; first-run bootstrap generates random api_token
  and refuses to bind non-loopback without auth unless explicitly opted in
- Path-traversal hardened: BrowserService.validate_path rejects absolute
  paths, drive letters, UNC, NUL bytes. /api/browser/{play,metadata,
  thumbnail} now require folder_id and a folder-relative path
- Pydantic validators on links: http(s) URLs only, mdi:<slug> icons only
- Scripts/callbacks/links create/update/delete gated by *_management flags
- Strict CSP, X-Frame-Options DENY, Referrer-Policy no-referrer,
  X-Content-Type-Options nosniff
- CORS locked to localhost:<port> + 127.0.0.1:<port> by default; configurable
- config.yaml writes atomic (tmp + os.replace) and 0o600 on POSIX
- Subprocesses spawned in their own process group / new session so timeout
  kills the whole tree (Windows CREATE_NEW_PROCESS_GROUP, POSIX
  start_new_session=True)
- Frontend XSS: monitor name + details escapeHtml'd; power button moved to
  delegated data-action handler; remote MDI SVGs parsed and sanitized
  (strip script/foreignObject/on*/javascript: hrefs) before innerHTML
- All dynamic URL segments now wrapped in encodeURIComponent

Bugs
- WebSocket reconnect: close previous socket before opening new, clear
  ping interval per-socket, clear reconnectTimeout up-front, retry on
  online/visibilitychange, try/catch JSON.parse
- Artwork fetch race: AbortController + generation guard
- _broadcast_after_open: initialize status, swallow per-poll errors,
  background tasks tracked in a strong-ref set with done-callback cleanup
- Audio analyzer: sticky _unavailable flag prevents infinite start/stop
  spin when no loopback device exists; cleared by set_device()
- Volume short-circuit cache invalidated when server reports remote volume
- Browser thumbnail race: per-folder generation counter + isConnected
  checks; aborts in-flight fetches on navigation
- Track-skip uses cached title instead of full WinRT status round-trip

Performance
- Linux MPRIS/pactl and /api/display DDC-CI handlers wrapped in
  asyncio.to_thread so blocking IO never stalls the event loop
- browse_directory moved off the event loop (SMB shares could freeze it)
- Windows status poll caches one asyncio loop per worker thread via
  threading.local instead of new_event_loop/close on every 0.5s tick
- broadcast() serializes JSON once and uses send_text to all clients
- Hourly thumbnail cache cleanup scheduled in lifespan (was never invoked
  — cache grew unbounded)
- Progress drag listeners attached only while dragging

Quality
- All asyncio.get_event_loop() in coroutines → get_running_loop()
- ThreadPoolExecutors shut down cleanly during lifespan teardown
- config_manager dedup: 12 near-identical methods collapsed onto generic
  _upsert/_delete helpers (~290 lines removed)
- Service worker no longer pass-throughs every fetch
- M3U playlist written via NamedTemporaryFile (no fixed-path symlink
  clobber race)
- __version__ now prefers live pyproject.toml in dev checkouts so
  pip install -e . users see the source-of-truth version, not the stale
  package-metadata version baked in at install time

UI/UX (Studio Reference)
- Green leftover focus rings (rgba(29,185,84,...)) all replaced with
  copper accent (rgba(var(--copper-rgb),...))
- Dialogs: square corners, copper top hairline, unified with editorial
  chrome
- .browser-item: transparent with copper hover border (was filled card)
- Audio device select uses var(--sans) instead of generic system font
- Mobile container padding tuned for ≤480px screens
- Breadcrumb home is a real <button> with aria-label; aria-current on root
- i18n: filled display.msg.power_*, execution.*, scripts.params.execute,
  callbacks.empty in both en + ru
This commit is contained in:
2026-05-16 13:22:46 +03:00
parent 770bba7e60
commit bcc6d40ed7
28 changed files with 1063 additions and 876 deletions
+17 -3
View File
@@ -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()
+69 -22
View File
@@ -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:<port> and http://127.0.0.1:<port>."
),
)
# 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()
+147 -402
View File
@@ -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()
+95 -9
View File
@@ -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:
+100 -80
View File
@@ -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,
+27 -4
View File
@@ -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
+16 -13
View File
@@ -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}
+49 -13
View File
@@ -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:<lowercase-slug>'")
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:
+2 -2
View File
@@ -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)
+32 -5
View File
@@ -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
+13
View File
@@ -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
+20 -6
View File
@@ -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
+24 -68
View File
@@ -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).
+1 -1
View File
@@ -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,
+16 -5
View File
@@ -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
+67 -79
View File
@@ -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
+39 -21
View File
@@ -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) ────────────────── */
+2 -2
View File
@@ -522,7 +522,7 @@
<td colspan="4" class="empty-state">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg>
<p>No callbacks configured. Click "Add" to create one.</p>
<p data-i18n="callbacks.empty">No callbacks configured. Click "Add" to create one.</p>
</div>
</td>
</tr>
@@ -608,7 +608,7 @@
<!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
<dialog id="scriptParamsDialog">
<div class="dialog-header">
<h3 id="scriptParamsDialogTitle">Execute Script</h3>
<h3 id="scriptParamsDialogTitle" data-i18n="scripts.params.execute">Execute Script</h3>
</div>
<form id="scriptParamsForm" onsubmit="submitScriptWithParams(event)">
<div class="dialog-body">
+49 -20
View File
@@ -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 = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
root.setAttribute('aria-current', 'page');
root.setAttribute('aria-label', t('browser.home') || 'Home');
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
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 <button> so it's
// keyboard-focusable and reachable by screen readers.
const home = document.createElement('button');
home.type = 'button';
home.className = 'breadcrumb-item breadcrumb-home';
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
home.setAttribute('aria-label', t('browser.home') || 'Home');
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
home.onclick = () => showRootFolders();
breadcrumb.appendChild(home);
@@ -512,16 +519,33 @@ function formatBitrate(bps) {
return Math.round(bps / 1000) + ' kbps';
}
// Bump this whenever the user changes folder/path so in-flight fetches from
// the previous view can be ignored when they finally resolve.
let _browseGen = 0;
function bumpBrowseGen() { return ++_browseGen; }
function currentBrowseGen() { return _browseGen; }
function buildRelativeFilePath(relativePath, fileName) {
const base = (relativePath === '/' || relativePath === '') ? '' : relativePath.replace(/\/$/, '');
return base + '/' + fileName;
}
async function loadThumbnail(imgElement, fileName) {
const myGen = currentBrowseGen();
const folderId = currentFolderId;
const relPath = buildRelativeFilePath(currentPath, fileName);
const cacheKey = `${folderId}|${relPath}`;
try {
if (!hasCredentials()) return;
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
// If the user navigates away before this fetch resolves, the imgElement
// may already be detached. Bail in that case.
if (!imgElement.isConnected) return;
// Check cache first
if (thumbnailCache.has(absolutePath)) {
const cachedUrl = thumbnailCache.get(absolutePath);
if (thumbnailCache.has(cacheKey)) {
const cachedUrl = thumbnailCache.get(cacheKey);
imgElement.onload = () => {
if (!imgElement.isConnected) return;
imgElement.classList.remove('loading');
imgElement.classList.add('loaded');
};
@@ -529,17 +553,24 @@ async function loadThumbnail(imgElement, fileName) {
return;
}
const encodedPath = encodeURIComponent(absolutePath);
const params = new URLSearchParams({
folder_id: folderId,
path: relPath,
size: 'medium',
});
const response = await fetch(
`/api/browser/thumbnail?path=${encodedPath}&size=medium`,
`/api/browser/thumbnail?${params.toString()}`,
{ headers: getAuthHeaders() }
);
// Drop the response if the user has since navigated away.
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
if (response.status === 200) {
const blob = await response.blob();
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
const url = URL.createObjectURL(blob);
thumbnailCache.set(absolutePath, url);
thumbnailCache.set(cacheKey, url);
// Evict oldest entries when cache exceeds limit
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
@@ -548,13 +579,11 @@ async function loadThumbnail(imgElement, fileName) {
thumbnailCache.delete(oldest);
}
// Wait for image to actually load before showing it
imgElement.onload = () => {
imgElement.classList.remove('loading');
imgElement.classList.add('loaded');
};
// Revoke previous blob URL if not managed by cache
if (imgElement.src && imgElement.src.startsWith('blob:')) {
let isCached = false;
for (const cachedUrl of thumbnailCache.values()) {
@@ -564,8 +593,8 @@ async function loadThumbnail(imgElement, fileName) {
}
imgElement.src = url;
} else {
// Fallback to icon (204 = no thumbnail available)
const parent = imgElement.parentElement;
if (!parent) return;
const isList = parent.classList.contains('browser-list-icon');
imgElement.remove();
if (isList) {
@@ -579,7 +608,7 @@ async function loadThumbnail(imgElement, fileName) {
}
} catch (error) {
console.error('Error loading thumbnail:', error);
imgElement.classList.remove('loading');
if (imgElement.isConnected) imgElement.classList.remove('loading');
}
}
@@ -601,12 +630,12 @@ async function playMediaFile(fileName) {
try {
if (!hasCredentials()) return;
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
const relativePath = buildRelativeFilePath(currentPath, fileName);
const response = await fetch('/api/browser/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ path: absolutePath })
body: JSON.stringify({ folder_id: currentFolderId, path: relativePath })
});
if (!response.ok) throw new Error('Failed to play file');
+13 -12
View File
@@ -81,7 +81,7 @@ async function _loadCallbacksTableImpl() {
`).join('');
} catch (error) {
console.error('Error loading callbacks:', error);
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load callbacks</td></tr>';
tbody.innerHTML = `<tr><td colspan="4" class="empty-state" style="color: var(--error);">${escapeHtml(t('callbacks.msg.load_failed'))}</td></tr>`;
}
}
@@ -121,7 +121,7 @@ export async function showEditCallbackDialog(callbackName) {
const callback = callbacksList.find(c => c.name === callbackName);
if (!callback) {
showToast('Callback not found', 'error');
showToast(t('callbacks.msg.not_found'), 'error');
return;
}
@@ -142,7 +142,7 @@ export async function showEditCallbackDialog(callbackName) {
dialog.showModal();
} catch (error) {
console.error('Error loading callback for edit:', error);
showToast('Failed to load callback details', 'error');
showToast(t('callbacks.msg.load_failed'), 'error');
}
}
@@ -175,9 +175,10 @@ export async function saveCallback(event) {
shell: true
};
const encodedName = encodeURIComponent(callbackName);
const endpoint = isEdit ?
`/api/callbacks/update/${callbackName}` :
`/api/callbacks/create/${callbackName}`;
`/api/callbacks/update/${encodedName}` :
`/api/callbacks/create/${encodedName}`;
const method = isEdit ? 'PUT' : 'POST';
@@ -191,16 +192,16 @@ export async function saveCallback(event) {
const result = await response.json();
if (response.ok && result.success) {
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
showToast(t(isEdit ? 'callbacks.msg.updated' : 'callbacks.msg.created'), 'success');
callbackFormDirty = false;
closeCallbackDialog();
loadCallbacksTable();
} else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error');
showToast(result.detail || t(isEdit ? 'callbacks.msg.update_failed' : 'callbacks.msg.create_failed'), 'error');
}
} catch (error) {
console.error('Error saving callback:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error');
showToast(t(isEdit ? 'callbacks.msg.update_failed' : 'callbacks.msg.create_failed'), 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
@@ -212,7 +213,7 @@ export async function deleteCallbackConfirm(callbackName) {
}
try {
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
const response = await fetch(`/api/callbacks/delete/${encodeURIComponent(callbackName)}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
@@ -220,13 +221,13 @@ export async function deleteCallbackConfirm(callbackName) {
const result = await response.json();
if (response.ok && result.success) {
showToast('Callback deleted successfully', 'success');
showToast(t('callbacks.msg.deleted'), 'success');
loadCallbacksTable();
} else {
showToast(result.detail || 'Failed to delete callback', 'error');
showToast(result.detail || t('callbacks.msg.delete_failed'), 'error');
}
} catch (error) {
console.error('Error deleting callback:', error);
showToast('Error deleting callback', 'error');
showToast(t('callbacks.msg.delete_failed'), 'error');
}
}
+59 -7
View File
@@ -155,6 +155,7 @@ export const VOLUME_RELEASE_DELAY_MS = 500;
// Shared state (accessed across multiple modules)
export let ws = null;
export function setWs(value) { ws = value; }
export function getWs() { return ws; }
export let currentState = 'idle';
export function setCurrentState(value) { currentState = value; }
export let currentDuration = 0;
@@ -513,6 +514,12 @@ export function setVolume(volume) {
sendCommand('volume', { volume: volume });
}
}
// Reset the de-dupe cache whenever the server reports a fresh volume value
// (e.g., another client moved the slider). Otherwise the user can end up
// unable to "set volume back to the value we last sent" after a remote change.
export function notifyRemoteVolume(volume) {
lastSentVolume = volume;
}
export function toggleMute() {
sendCommand('mute');
@@ -536,23 +543,68 @@ function _persistMdiCache() {
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
}
// Strict iconify MDI slug — used to reject anything that could be path-traversal
// or query injection before we even hit the network.
const MDI_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
function sanitizeSvg(rawSvg) {
// Parse the SVG and strip anything that could execute script. Anything
// unparseable returns null so callers fall back to the placeholder.
try {
const doc = new DOMParser().parseFromString(rawSvg, 'image/svg+xml');
const root = doc.documentElement;
if (!root || root.tagName.toLowerCase() !== 'svg' || root.querySelector('parsererror')) {
return null;
}
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
const toRemove = [];
let node = walker.currentNode;
while (node) {
const tag = node.tagName?.toLowerCase();
if (tag === 'script' || tag === 'foreignobject') {
toRemove.push(node);
} else if (node.attributes) {
for (const attr of Array.from(node.attributes)) {
const name = attr.name.toLowerCase();
if (name.startsWith('on') ||
((name === 'href' || name === 'xlink:href') &&
/^\s*(javascript|data):/i.test(attr.value))) {
node.removeAttribute(attr.name);
}
}
}
node = walker.nextNode();
}
toRemove.forEach((el) => el.remove());
return root.outerHTML;
} catch {
return null;
}
}
const PLACEHOLDER_SVG = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
export async function fetchMdiIcon(iconName) {
const name = iconName.replace(/^mdi:/, '');
const name = String(iconName || '').replace(/^mdi:/, '');
if (!MDI_SLUG_RE.test(name)) return PLACEHOLDER_SVG;
if (mdiIconCache[name]) return mdiIconCache[name];
try {
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
const response = await fetch(`https://api.iconify.design/mdi/${encodeURIComponent(name)}.svg?width=16&height=16`);
if (response.ok) {
const svg = await response.text();
mdiIconCache[name] = svg;
_persistMdiCache();
return svg;
const raw = await response.text();
const safe = sanitizeSvg(raw);
if (safe) {
mdiIconCache[name] = safe;
_persistMdiCache();
return safe;
}
}
} catch (e) {
console.warn('Failed to fetch MDI icon:', name, e);
}
return '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
return PLACEHOLDER_SVG;
}
export async function resolveMdiIcons(container) {
+28 -12
View File
@@ -148,16 +148,19 @@ export async function loadDisplayMonitors() {
let powerBtn = '';
if (monitor.power_supported) {
// Inline onclick with string-interpolated monitor.name is a DOM-XSS
// foot-gun if the OS ever reports a name containing quotes / angle
// brackets. Use a delegated click handler bound to data-* attrs.
powerBtn = `
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
onclick="toggleDisplayPower(${monitor.id}, '${monitor.name.replace(/'/g, "\\'")}')"
title="${monitor.power_on ? t('display.power_off') : t('display.power_on')}">
data-action="toggle-power" data-monitor-id="${monitor.id}"
title="${escapeHtml(monitor.power_on ? t('display.power_off') : t('display.power_on'))}">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0119 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.95 8.95 0 003 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.95 8.95 0 00-3.17-6.83z"/></svg>
</button>`;
}
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
const detailsHtml = details ? `<span class="display-monitor-details">${escapeHtml(details)}</span>` : '';
const primaryBadge = monitor.is_primary
? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.primary')}">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@@ -282,7 +285,7 @@ export async function loadDisplayMonitors() {
<path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/>
</svg>
<div class="display-monitor-info">
<span class="display-monitor-name"><span class="display-monitor-name-text">${monitor.name}</span>${primaryBadge}</span>
<span class="display-monitor-name"><span class="display-monitor-name-text">${escapeHtml(monitor.name)}</span>${primaryBadge}</span>
${detailsHtml}
</div>
${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 <select> with an IconSelect now that the
// cards are in the DOM (IconSelect needs offsetParent + sibling).
pendingIconSelects.forEach(({ kind, monitorId, items }) => {
@@ -441,7 +449,14 @@ export async function onDisplayPictureModeChange(monitorId, codeRaw) {
}
}
export async function toggleDisplayPower(monitorId, monitorName) {
function _onPowerButtonClick(event) {
const btn = event.target.closest('[data-action="toggle-power"]');
if (!btn) return;
const id = Number(btn.dataset.monitorId);
if (Number.isFinite(id)) toggleDisplayPower(id);
}
export async function toggleDisplayPower(monitorId) {
const btn = document.getElementById(`power-btn-${monitorId}`);
const isOn = btn && btn.classList.contains('on');
const newState = !isOn;
@@ -459,13 +474,13 @@ export async function toggleDisplayPower(monitorId, monitorName) {
btn.classList.toggle('off', !newState);
btn.title = newState ? t('display.power_off') : t('display.power_on');
}
showToast(newState ? 'Monitor turned on' : 'Monitor turned off', 'success');
showToast(t(newState ? 'display.msg.power_on' : 'display.msg.power_off'), 'success');
} else {
showToast('Failed to change monitor power', 'error');
showToast(t('display.msg.power_failed'), 'error');
}
} catch (e) {
console.error('Failed to set display power:', e);
showToast('Failed to change monitor power', 'error');
showToast(t('display.msg.power_failed'), 'error');
}
}
@@ -562,7 +577,7 @@ async function _loadLinksTableImpl() {
resolveMdiIcons(tbody);
} catch (error) {
console.error('Error loading links:', error);
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load links</td></tr>';
tbody.innerHTML = `<tr><td colspan="4" class="empty-state" style="color: var(--error);">${escapeHtml(t('links.msg.load_failed'))}</td></tr>`;
}
}
@@ -665,9 +680,10 @@ export async function saveLink(event) {
description: document.getElementById('linkDescription').value || ''
};
const encodedName = encodeURIComponent(linkName);
const endpoint = isEdit ?
`/api/links/update/${linkName}` :
`/api/links/create/${linkName}`;
`/api/links/update/${encodedName}` :
`/api/links/create/${encodedName}`;
const method = isEdit ? 'PUT' : 'POST';
@@ -701,7 +717,7 @@ export async function deleteLinkConfirm(linkName) {
}
try {
const response = await fetch(`/api/links/delete/${linkName}`, {
const response = await fetch(`/api/links/delete/${encodeURIComponent(linkName)}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
+47 -42
View File
@@ -8,7 +8,7 @@ import {
ws, currentState, setCurrentState, currentDuration, setCurrentDuration,
currentPosition, setCurrentPosition, isUserAdjustingVolume,
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
POSITION_INTERPOLATION_MS, seek,
POSITION_INTERPOLATION_MS, seek, notifyRemoteVolume,
getAuthHeaders, hasCredentials,
} from './core.js';
import { updateBackgroundColors } from './background.js';
@@ -687,54 +687,49 @@ export async function onAudioDeviceChanged() {
let lastArtworkKey = null;
let currentArtworkBlobUrl = null;
let artworkFetchGen = 0;
let artworkAbort = null;
let lastPositionUpdate = 0;
let lastPositionValue = 0;
let interpolationInterval = null;
export function setupProgressDrag(bar, fill) {
let dragging = false;
// Listeners are attached on mousedown and removed on mouseup so the
// document doesn't carry per-progress-bar move handlers for the entire
// session (especially expensive on mobile).
function getPercent(clientX) {
const rect = bar.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
}
function updatePreview(percent) { fill.style.width = (percent * 100) + '%'; }
function updatePreview(percent) {
fill.style.width = (percent * 100) + '%';
}
function handleStart(clientX) {
function pointerStart(getX, moveEvent, endEvent, getMoveX, getEndX) {
if (currentDuration <= 0) return;
dragging = true;
bar.classList.add('dragging');
updatePreview(getPercent(clientX));
}
updatePreview(getPercent(getX));
function handleMove(clientX) {
if (!dragging) return;
updatePreview(getPercent(clientX));
}
function handleEnd(clientX) {
if (!dragging) return;
dragging = false;
bar.classList.remove('dragging');
const percent = getPercent(clientX);
seek(percent * currentDuration);
}
bar.addEventListener('mousedown', (e) => { e.preventDefault(); handleStart(e.clientX); });
document.addEventListener('mousemove', (e) => { handleMove(e.clientX); });
document.addEventListener('mouseup', (e) => { handleEnd(e.clientX); });
bar.addEventListener('touchstart', (e) => { handleStart(e.touches[0].clientX); }, { passive: true });
document.addEventListener('touchmove', (e) => { if (dragging) handleMove(e.touches[0].clientX); });
document.addEventListener('touchend', (e) => {
if (dragging) {
const touch = e.changedTouches[0];
handleEnd(touch.clientX);
function onMove(e) { updatePreview(getPercent(getMoveX(e))); }
function onEnd(e) {
document.removeEventListener(moveEvent, onMove);
document.removeEventListener(endEvent, onEnd);
bar.classList.remove('dragging');
const clientX = getEndX(e);
if (clientX !== undefined) seek(getPercent(clientX) * currentDuration);
}
document.addEventListener(moveEvent, onMove);
document.addEventListener(endEvent, onEnd);
}
bar.addEventListener('mousedown', (e) => {
e.preventDefault();
pointerStart(e.clientX, 'mousemove', 'mouseup',
(ev) => ev.clientX, (ev) => ev.clientX);
});
bar.addEventListener('touchstart', (e) => {
pointerStart(e.touches[0].clientX, 'touchmove', 'touchend',
(ev) => ev.touches[0].clientX,
(ev) => ev.changedTouches?.[0]?.clientX);
}, { passive: true });
bar.addEventListener('click', (e) => {
if (currentDuration > 0) {
@@ -811,28 +806,35 @@ export function updateUI(status) {
lastArtworkKey = artworkKey;
const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' 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";
const placeholderGlow = "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%3C/svg%3E";
// Cancel any in-flight artwork fetch and bump the generation so a
// late response from a previous track cannot overwrite the new one.
if (artworkAbort) {
try { artworkAbort.abort(); } catch { /* ignore */ }
}
const myGen = ++artworkFetchGen;
artworkAbort = new AbortController();
if (artworkSource) {
// No cache-buster: when album_art_url is unchanged the
// browser can reuse the decoded bitmap. The artworkKey gate
// already skips fetches when the user hasn't switched tracks.
fetch('/api/media/artwork', {
headers: getAuthHeaders()
headers: getAuthHeaders(),
signal: artworkAbort.signal,
})
.then(r => r.ok ? r.blob() : null)
.then(blob => {
if (!blob) return;
if (!blob || myGen !== artworkFetchGen) return;
const oldBlobUrl = currentArtworkBlobUrl;
const url = URL.createObjectURL(blob);
currentArtworkBlobUrl = url;
swapArtworkSrc(dom.albumArt, url);
if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url;
// Mirror to fullscreen bloom directly — drops the
// MutationObserver fan-out path.
syncFullscreenBloomArt(url);
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
})
.catch(err => console.error('Artwork fetch failed:', err));
.catch(err => {
if (err && err.name === 'AbortError') return;
console.error('Artwork fetch failed:', err);
});
} else {
if (currentArtworkBlobUrl) {
URL.revokeObjectURL(currentArtworkBlobUrl);
@@ -858,6 +860,9 @@ export function updateUI(status) {
}
if (!isUserAdjustingVolume) {
// Re-seed the throttling cache so a future call to setVolume() with
// the previously-sent value still propagates after an external change.
notifyRemoteVolume(status.volume);
dom.volumeSlider.value = status.volume;
dom.volumeDisplay.textContent = `${status.volume}%`;
dom.miniVolumeSlider.value = status.volume;
+28 -27
View File
@@ -150,7 +150,7 @@ async function executeScript(scriptName, buttonElement) {
async function _doExecuteScript(scriptName, params) {
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
const response = await fetch(`/api/scripts/execute/${encodeURIComponent(scriptName)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ params })
@@ -393,7 +393,7 @@ async function _loadScriptsTableImpl() {
resolveMdiIcons(tbody);
} catch (error) {
console.error('Error loading scripts:', error);
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>';
tbody.innerHTML = `<tr><td colspan="5" class="empty-state" style="color: var(--error);">${escapeHtml(t('scripts.msg.load_failed'))}</td></tr>`;
}
}
@@ -433,7 +433,7 @@ export async function showEditScriptDialog(scriptName) {
const script = scriptsList.find(s => s.name === scriptName);
if (!script) {
showToast('Script not found', 'error');
showToast(t('scripts.msg.not_found'), 'error');
return;
}
@@ -470,7 +470,7 @@ export async function showEditScriptDialog(scriptName) {
dialog.showModal();
} catch (error) {
console.error('Error loading script for edit:', error);
showToast('Failed to load script details', 'error');
showToast(t('scripts.msg.load_failed'), 'error');
}
}
@@ -508,9 +508,10 @@ export async function saveScript(event) {
parameters: _collectParameterDefinitions(),
};
const encodedName = encodeURIComponent(scriptName);
const endpoint = isEdit ?
`/api/scripts/update/${scriptName}` :
`/api/scripts/create/${scriptName}`;
`/api/scripts/update/${encodedName}` :
`/api/scripts/create/${encodedName}`;
const method = isEdit ? 'PUT' : 'POST';
@@ -524,15 +525,15 @@ export async function saveScript(event) {
const result = await response.json();
if (response.ok && result.success) {
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
showToast(t(isEdit ? 'scripts.msg.updated' : 'scripts.msg.created'), 'success');
scriptFormDirty = false;
closeScriptDialog();
} else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error');
showToast(result.detail || t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
}
} catch (error) {
console.error('Error saving script:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error');
showToast(t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
@@ -544,7 +545,7 @@ export async function deleteScriptConfirm(scriptName) {
}
try {
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
const response = await fetch(`/api/scripts/delete/${encodeURIComponent(scriptName)}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
@@ -552,13 +553,13 @@ export async function deleteScriptConfirm(scriptName) {
const result = await response.json();
if (response.ok && result.success) {
showToast('Script deleted successfully', 'success');
showToast(t('scripts.msg.deleted'), 'success');
} else {
showToast(result.detail || 'Failed to delete script', 'error');
showToast(result.detail || t('scripts.msg.delete_failed'), 'error');
}
} catch (error) {
console.error('Error deleting script:', error);
showToast('Error deleting script', 'error');
showToast(t('scripts.msg.delete_failed'), 'error');
}
}
@@ -581,23 +582,23 @@ function showExecutionResult(name, result, type = 'script') {
const outputPre = document.getElementById('executionOutput');
const errorPre = document.getElementById('executionError');
title.textContent = `Execution Result: ${name}`;
title.textContent = `${t('execution.result')}: ${name}`;
const success = result.success && result.exit_code === 0;
const statusClass = success ? 'success' : 'error';
const statusText = success ? 'Success' : 'Failed';
const statusText = t(success ? 'execution.success' : 'execution.failed');
statusDiv.innerHTML = `
<div class="status-item ${statusClass}">
<label>Status</label>
<value>${statusText}</value>
<label>${escapeHtml(t('execution.status'))}</label>
<value>${escapeHtml(statusText)}</value>
</div>
<div class="status-item">
<label>Exit Code</label>
<label>${escapeHtml(t('execution.exit_code'))}</label>
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
</div>
<div class="status-item">
<label>Duration</label>
<label>${escapeHtml(t('execution.duration'))}</label>
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
</div>
`;
@@ -606,7 +607,7 @@ function showExecutionResult(name, result, type = 'script') {
if (result.stdout && result.stdout.trim()) {
outputPre.textContent = result.stdout;
} else {
outputPre.textContent = '(no output)';
outputPre.textContent = t('execution.no_output');
outputPre.style.fontStyle = 'italic';
outputPre.style.color = 'var(--text-secondary)';
}
@@ -642,11 +643,11 @@ async function _executeScriptDebugWithParams(scriptName, params) {
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${scriptName}`;
title.textContent = `${t('execution.executing')}: ${scriptName}`;
statusDiv.innerHTML = `
<div class="status-item">
<label>Status</label>
<value><span class="loading-spinner"></span> Running...</value>
<label>${escapeHtml(t('execution.status'))}</label>
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
</div>
`;
document.getElementById('outputSection').style.display = 'none';
@@ -813,11 +814,11 @@ export async function executeCallbackDebug(callbackName) {
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${callbackName}`;
title.textContent = `${t('execution.executing')}: ${callbackName}`;
statusDiv.innerHTML = `
<div class="status-item">
<label>Status</label>
<value><span class="loading-spinner"></span> Running...</value>
<label>${escapeHtml(t('execution.status'))}</label>
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
</div>
`;
document.getElementById('outputSection').style.display = 'none';
@@ -826,7 +827,7 @@ export async function executeCallbackDebug(callbackName) {
dialog.showModal();
try {
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
const response = await fetch(`/api/callbacks/execute/${encodeURIComponent(callbackName)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
});
+75 -15
View File
@@ -3,7 +3,7 @@
// ============================================================
import {
dom, t, showToast, setWs,
dom, t, setWs, getWs,
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
authRequired, showUpdateBanner,
@@ -14,8 +14,26 @@ import { loadCallbacksTable } from './callbacks.js';
import { loadHeaderLinks, loadLinksTable } from './links.js';
let reconnectTimeout = null;
let pingInterval = null;
let wsReconnectAttempts = 0;
// Track the ping interval against the socket that owns it so we never leak
// a timer if connectWebSocket() is called while a previous socket is still
// alive. The pair is wiped on close to avoid double-clear races.
let activeSocket = null;
let activePingInterval = null;
function clearReconnect() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
}
function clearPing() {
if (activePingInterval) {
clearInterval(activePingInterval);
activePingInterval = null;
}
}
export function showAuthForm(errorMessage = '') {
const overlay = document.getElementById('auth-overlay');
@@ -47,19 +65,24 @@ export function authenticate() {
export function clearToken() {
localStorage.removeItem('media_server_token');
// Access ws via import
import('./core.js').then(core => {
if (core.ws) {
core.ws.close();
}
});
const current = getWs();
if (current) {
try { current.close(1000, 'token cleared'); } catch { /* ignore */ }
}
showAuthForm(t('auth.cleared'));
}
export function connectWebSocket(token) {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
// Always cancel a pending reconnect first — otherwise a user-triggered
// reconnect can race a scheduled one and create two live sockets.
clearReconnect();
clearPing();
// Close any previous socket cleanly before opening a new one.
const previous = activeSocket;
activeSocket = null;
if (previous && previous.readyState <= WebSocket.OPEN) {
try { previous.close(1000, 'reconnecting'); } catch { /* ignore */ }
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -67,6 +90,7 @@ export function connectWebSocket(token) {
const wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
const newWs = new WebSocket(wsUrl);
activeSocket = newWs;
setWs(newWs);
newWs.onopen = () => {
@@ -84,7 +108,13 @@ export function connectWebSocket(token) {
};
newWs.onmessage = (event) => {
const msg = JSON.parse(event.data);
let msg;
try {
msg = JSON.parse(event.data);
} catch (err) {
console.warn('Ignoring malformed WebSocket frame:', err);
return;
}
if (msg.type === 'status' || msg.type === 'status_update') {
updateUI(msg.data);
@@ -116,6 +146,13 @@ export function connectWebSocket(token) {
updateConnectionStatus(false);
stopPositionInterpolation();
// Drop this socket's ping interval. Guard so we don't kill a newer
// socket's interval if reconnect already started.
if (activeSocket === newWs) {
clearPing();
activeSocket = null;
}
if (event.code === 4001) {
localStorage.removeItem('media_server_token');
showAuthForm(t('auth.invalid'));
@@ -133,7 +170,9 @@ export function connectWebSocket(token) {
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
}
clearReconnect();
reconnectTimeout = setTimeout(() => {
reconnectTimeout = null;
const savedToken = localStorage.getItem('media_server_token');
if (savedToken || !authRequired) {
connectWebSocket(savedToken || '');
@@ -145,9 +184,10 @@ export function connectWebSocket(token) {
}
};
pingInterval = setInterval(() => {
if (newWs && newWs.readyState === WebSocket.OPEN) {
newWs.send(JSON.stringify({ type: 'ping' }));
clearPing();
activePingInterval = setInterval(() => {
if (newWs.readyState === WebSocket.OPEN) {
try { newWs.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
}
}, WS_PING_INTERVAL_MS);
}
@@ -182,3 +222,23 @@ export function manualReconnect() {
connectWebSocket(savedToken || '');
}
}
// When the browser regains connectivity or the tab becomes visible again,
// drop the backoff and reconnect immediately rather than waiting out the
// current timer.
function reconnectIfNeeded() {
const current = activeSocket;
if (current && (current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) {
return;
}
const savedToken = localStorage.getItem('media_server_token');
if (savedToken || !authRequired) {
wsReconnectAttempts = 0;
connectWebSocket(savedToken || '');
}
}
window.addEventListener('online', reconnectIfNeeded);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) reconnectIfNeeded();
});
+12
View File
@@ -174,6 +174,18 @@
"display.msg.color_failed": "Failed to apply color preset",
"display.msg.mode_changed": "Picture mode applied",
"display.msg.mode_failed": "Failed to apply picture mode",
"display.msg.power_on": "Monitor turned on",
"display.msg.power_off": "Monitor turned off",
"display.msg.power_failed": "Failed to change monitor power",
"execution.result": "Execution Result",
"execution.executing": "Executing",
"execution.status": "Status",
"execution.exit_code": "Exit Code",
"execution.duration": "Duration",
"execution.success": "Success",
"execution.failed": "Failed",
"execution.running": "Running...",
"execution.no_output": "(no output)",
"browser.title": "Media Browser",
"browser.home": "Home",
"browser.manage_folders": "Manage Folders",
+12
View File
@@ -174,6 +174,18 @@
"display.msg.color_failed": "Не удалось применить цветовую температуру",
"display.msg.mode_changed": "Режим изображения применён",
"display.msg.mode_failed": "Не удалось применить режим изображения",
"display.msg.power_on": "Монитор включён",
"display.msg.power_off": "Монитор выключен",
"display.msg.power_failed": "Не удалось переключить питание монитора",
"execution.result": "Результат выполнения",
"execution.executing": "Выполняется",
"execution.status": "Статус",
"execution.exit_code": "Код выхода",
"execution.duration": "Длительность",
"execution.success": "Успешно",
"execution.failed": "Ошибка",
"execution.running": "Выполняется...",
"execution.no_output": "(нет вывода)",
"browser.title": "Медиа Браузер",
"browser.home": "Главная",
"browser.manage_folders": "Управление папками",
+4 -6
View File
@@ -1,6 +1,8 @@
// Minimal service worker for PWA installability.
// Minimal service worker for PWA installability only.
// This app requires a live WebSocket connection, so offline caching is not useful.
// All fetch requests are passed through to the network.
// We intentionally do NOT register a `fetch` handler — a pass-through handler
// forces every navigation through the SW for no benefit and breaks the
// browser's normal HTTP cache + error semantics.
self.addEventListener('install', () => {
self.skipWaiting();
@@ -9,7 +11,3 @@ self.addEventListener('install', () => {
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});