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.""" """Media Server - REST API for controlling system media playback."""
import re
from importlib.metadata import PackageNotFoundError, version from importlib.metadata import PackageNotFoundError, version
from pathlib import Path from pathlib import Path
_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
def _detect_version() -> str: 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: try:
return version("media-server") return version("media-server")
except PackageNotFoundError: except PackageNotFoundError:
pass pass
# 2. VERSION file written by build scripts (production builds) # 3. VERSION file written by build scripts (production builds).
# Located at install root, two levels up from this package
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION" version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
if version_file.is_file(): if version_file.is_file():
return version_file.read_text().strip() return version_file.read_text().strip()
+69 -22
View File
@@ -1,6 +1,8 @@
"""Configuration management for the media server.""" """Configuration management for the media server."""
import logging
import os import os
import secrets
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -8,6 +10,8 @@ import yaml
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
class MediaFolderConfig(BaseModel): class MediaFolderConfig(BaseModel):
"""Configuration for a media folder.""" """Configuration for a media folder."""
@@ -81,8 +85,35 @@ class Settings(BaseSettings):
) )
# Server settings # 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") 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) # Authentication (empty = auth disabled, anyone can access the API)
api_tokens: dict[str, str] = Field( api_tokens: dict[str, str] = Field(
@@ -218,21 +249,25 @@ def get_config_dir() -> Path:
def generate_default_config(path: Optional[Path] = None) -> 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: if path is None:
path = get_config_dir() / "config.yaml" path = get_config_dir() / "config.yaml"
default_token = secrets.token_urlsafe(32)
config = { config = {
"host": "0.0.0.0", "host": "127.0.0.1",
"port": 8765, "port": 8765,
# "api_tokens": { "api_tokens": {
# "default": "your-secret-token-here", "default": default_token,
# }, },
"poll_interval": 1.0, "poll_interval": 1.0,
"log_level": "INFO", "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": { "scripts": {
"example_script": { "example_script": {
"command": "echo Hello from Media Server!", "command": "echo Hello from Media Server!",
@@ -240,26 +275,38 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
"timeout": 10, "timeout": 10,
"shell": True, "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) path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f: _write_yaml_atomic(path, config)
yaml.dump(config, f, default_flow_style=False, sort_keys=False) _restrict_config_perms(path)
logger.info("Generated default config at %s", path)
logger.info("API token (label=default): %s", default_token)
return path 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 # Global settings instance
settings = Settings.load_from_yaml() 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 logging
import os import os
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Optional
import yaml 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__) logger = logging.getLogger(__name__)
class ConfigManager: 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): 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._lock = threading.Lock()
self._config_path = config_path or self._find_config_path() self._config_path = config_path or self._find_config_path()
logger.info(f"ConfigManager initialized with path: {self._config_path}") logger.info(f"ConfigManager initialized with path: {self._config_path}")
def _find_config_path(self) -> Path: @staticmethod
"""Find the active config file path. 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: if os.name == "nt":
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
appdata = os.environ.get("APPDATA", "") appdata = os.environ.get("APPDATA", "")
if appdata: if appdata:
search_paths.append(Path(appdata) / "media-server" / "config.yaml") 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.home() / ".config" / "media-server" / "config.yaml")
search_paths.append(Path("/etc/media-server/config.yaml")) search_paths.append(Path("/etc/media-server/config.yaml"))
@@ -54,7 +52,6 @@ class ConfigManager:
if search_path.exists(): if search_path.exists():
return search_path return search_path
# If not found, use the default location
if os.name == "nt": if os.name == "nt":
default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml" default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml"
else: else:
@@ -63,422 +60,170 @@ class ConfigManager:
logger.warning(f"No config file found, using default path: {default_path}") logger.warning(f"No config file found, using default path: {default_path}")
return default_path return default_path
def add_script(self, name: str, config: ScriptConfig) -> None: def _load(self) -> dict[str, Any]:
"""Add a new script to config. """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: def _save(self, data: dict[str, Any]) -> None:
name: Script name (must be unique). """Atomically write the config YAML and lock down its permissions."""
config: Script configuration. self._config_path.parent.mkdir(parents=True, exist_ok=True)
_write_yaml_atomic(self._config_path, data)
_restrict_config_perms(self._config_path)
Raises: # --- Generic per-section CRUD --------------------------------------
ValueError: If script already exists.
IOError: If config file cannot be written. 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: with self._lock:
# Read YAML data = self._load()
if not self._config_path.exists(): existing = data.get(section, {})
data = {} if require_absent and key in existing:
else: raise ValueError(f"{section[:-1].title()} '{key}' already exists")
with open(self._config_path, "r", encoding="utf-8") as f: if require_present and (not isinstance(existing, dict) or key not in existing):
data = yaml.safe_load(f) or {} raise ValueError(f"{section[:-1].title()} '{key}' does not exist")
# Check if script already exists if not isinstance(existing, dict):
if "scripts" in data and name in data["scripts"]: existing = {}
raise ValueError(f"Script '{name}' already exists") existing[key] = value.model_dump(exclude_none=True)
data[section] = existing
# Add script self._save(data)
if "scripts" not in data:
data["scripts"] = {}
data["scripts"][name] = config.model_dump(exclude_none=True)
# Write YAML if in_memory_target is not None:
self._config_path.parent.mkdir(parents=True, exist_ok=True) in_memory_target[key] = value
with open(self._config_path, "w", encoding="utf-8") as f: logger.info(f"{section[:-1].title()} '{key}' {verb} in config")
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings def _delete(
settings.scripts[name] = config 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: def update_script(self, name: str, config: ScriptConfig) -> None:
"""Update an existing script. self._upsert(
"scripts", name, config,
Args: require_present=True,
name: Script name. in_memory_target=settings.scripts,
config: New script configuration. verb="updated",
)
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")
def delete_script(self, name: str) -> None: def delete_script(self, name: str) -> None:
"""Delete a script from config. self._delete("scripts", name, in_memory_target=settings.scripts)
Args: # --- Callbacks -----------------------------------------------------
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")
def add_callback(self, name: str, config: CallbackConfig) -> None: def add_callback(self, name: str, config: CallbackConfig) -> None:
"""Add a new callback to config. self._upsert(
"callbacks", name, config,
Args: require_absent=True,
name: Callback name (must be unique). in_memory_target=settings.callbacks,
config: Callback configuration. verb="added",
)
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")
def update_callback(self, name: str, config: CallbackConfig) -> None: def update_callback(self, name: str, config: CallbackConfig) -> None:
"""Update an existing callback. self._upsert(
"callbacks", name, config,
Args: require_present=True,
name: Callback name. in_memory_target=settings.callbacks,
config: New callback configuration. verb="updated",
)
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")
def delete_callback(self, name: str) -> None: def delete_callback(self, name: str) -> None:
"""Delete a callback from config. self._delete("callbacks", name, in_memory_target=settings.callbacks)
Args: # --- Media folders -------------------------------------------------
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")
def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None: def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
"""Add a new media folder to config. self._upsert(
"media_folders", folder_id, config,
Args: require_absent=True,
folder_id: Folder ID (must be unique). in_memory_target=settings.media_folders,
config: Media folder configuration. verb="added",
)
Raises:
ValueError: If folder already exists.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
data = {}
else:
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if folder already exists
if "media_folders" in data and folder_id in data["media_folders"]:
raise ValueError(f"Media folder '{folder_id}' already exists")
# Add folder
if "media_folders" not in data:
data["media_folders"] = {}
data["media_folders"][folder_id] = config.model_dump(exclude_none=True)
# Write YAML
self._config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
settings.media_folders[folder_id] = config
logger.info(f"Media folder '{folder_id}' added to config")
def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None: def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
"""Update an existing media folder. self._upsert(
"media_folders", folder_id, config,
Args: require_present=True,
folder_id: Folder ID. in_memory_target=settings.media_folders,
config: New media folder configuration. verb="updated",
)
Raises:
ValueError: If folder does not exist.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if folder exists
if "media_folders" not in data or folder_id not in data["media_folders"]:
raise ValueError(f"Media folder '{folder_id}' does not exist")
# Update folder
data["media_folders"][folder_id] = config.model_dump(exclude_none=True)
# Write YAML
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
settings.media_folders[folder_id] = config
logger.info(f"Media folder '{folder_id}' updated in config")
def delete_media_folder(self, folder_id: str) -> None: 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: # --- Links ---------------------------------------------------------
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")
def add_link(self, name: str, config: LinkConfig) -> None: def add_link(self, name: str, config: LinkConfig) -> None:
"""Add a new link to config.""" self._upsert(
with self._lock: "links", name, config,
if not self._config_path.exists(): require_absent=True,
data = {} in_memory_target=settings.links,
else: verb="added",
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")
def update_link(self, name: str, config: LinkConfig) -> None: def update_link(self, name: str, config: LinkConfig) -> None:
"""Update an existing link.""" self._upsert(
with self._lock: "links", name, config,
if not self._config_path.exists(): require_present=True,
raise ValueError(f"Config file not found: {self._config_path}") in_memory_target=settings.links,
verb="updated",
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")
def delete_link(self, name: str) -> None: 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: with self._lock:
if not self._config_path.exists(): data = self._load()
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 {}
if value is None: if value is None:
data.pop(key, None) data.pop(key, None)
else: else:
data[key] = value data[key] = value
self._save(data)
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
if hasattr(settings, key): if hasattr(settings, key):
setattr(settings, key, value) setattr(settings, key, value)
logger.info("Setting '%s' updated to: %s", key, value) logger.info("Setting '%s' updated to: %s", key, value)
# Global config manager instance
config_manager = ConfigManager() config_manager = ConfigManager()
+95 -9
View File
@@ -63,10 +63,10 @@ async def lifespan(app: FastAPI):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info(f"Media Server starting on {settings.host}:{settings.port}") 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: if settings.api_tokens:
for label, token in settings.api_tokens.items(): labels = ", ".join(settings.api_tokens.keys())
logger.info(f"API Token [{label}]: {token[:8]}...") logger.info(f"Authentication enabled. Tokens configured: [{labels}]")
else: else:
logger.warning("No API tokens configured — authentication is DISABLED") 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 # Store globally so health endpoint can access cached result
app.state.update_checker = update_checker 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) # Register audio visualizer (capture starts on-demand when clients subscribe)
analyzer = None analyzer = None
if settings.visualizer_enabled: if settings.visualizer_enabled:
@@ -109,6 +127,13 @@ async def lifespan(app: FastAPI):
if update_checker is not None: if update_checker is not None:
await update_checker.stop() await update_checker.stop()
# Cancel periodic thumbnail cleanup
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
# Stop audio visualizer # Stop audio visualizer
await ws_manager.stop_audio_monitor() await ws_manager.stop_audio_monitor()
if analyzer and analyzer.running: if analyzer and analyzer.running:
@@ -117,6 +142,13 @@ async def lifespan(app: FastAPI):
# Stop WebSocket status monitor # Stop WebSocket status monitor
await ws_manager.stop_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 # Clean up platform-specific resources
import platform as _platform import platform as _platform
if _platform.system() == "Windows": if _platform.system() == "Windows":
@@ -138,16 +170,43 @@ def create_app() -> FastAPI:
# Compress responses > 1KB # Compress responses > 1KB
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
# Add CORS middleware for cross-origin requests # CORS — restrict to same-origin by default; users that integrate the API
# Token auth is via Authorization header, not cookies, so credentials are not needed # 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=cors_origins,
allow_credentials=False, allow_credentials=False,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"], 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 # Add token logging middleware
@app.middleware("http") @app.middleware("http")
async def token_logging_middleware(request: Request, call_next): async def token_logging_middleware(request: Request, call_next):
@@ -247,7 +306,8 @@ def main():
if args.generate_config: if args.generate_config:
config_path = generate_default_config() config_path = generate_default_config()
print(f"Configuration file generated at: {config_path}") 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 return
if args.show_token: if args.show_token:
@@ -260,6 +320,32 @@ def main():
print("\nAuthentication is DISABLED (no tokens configured)") print("\nAuthentication is DISABLED (no tokens configured)")
return 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 # Check if port is available before starting
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try: try:
+100 -80
View File
@@ -23,6 +23,17 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser", tags=["browser"]) 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: def _require_folder_management() -> None:
"""Raise 403 if media folder management is disabled in config.""" """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. Fires as a background task so the HTTP response returns immediately.
""" """
status = None
try: try:
interval = 0.3 interval = 0.3
elapsed = 0.0 elapsed = 0.0
while elapsed < max_wait: while elapsed < max_wait:
await asyncio.sleep(interval) await asyncio.sleep(interval)
elapsed += 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"): if status.state in ("playing", "paused"):
break break
if status is None:
return
status_dict = status.model_dump() status_dict = status.model_dump()
await ws_manager.broadcast({"type": "status", "data": status_dict}) await ws_manager.broadcast({"type": "status", "data": status_dict})
logger.info(f"Broadcasted status update after opening: {label}") logger.info(f"Broadcasted status update after opening: {label}")
@@ -74,9 +92,14 @@ class FolderUpdateRequest(BaseModel):
class PlayRequest(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): class PlayFolderRequest(BaseModel):
@@ -128,8 +151,10 @@ async def create_folder(
""" """
_require_folder_management() _require_folder_management()
try: try:
# Validate folder_id format (alphanumeric and underscore only) # Validate folder_id format (alphanumeric and underscore only).
if not request.folder_id.replace("_", "").isalnum(): # 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( raise HTTPException(
status_code=400, status_code=400,
detail="Folder ID must contain only alphanumeric characters and underscores", detail="Folder ID must contain only alphanumeric characters and underscores",
@@ -277,13 +302,15 @@ async def browse(
# URL decode the path # URL decode the path
decoded_path = unquote(path) decoded_path = unquote(path)
# Browse directory # Browse directory in a thread — iterdir() + stat() can block on
result = BrowserService.browse_directory( # network shares for many seconds; never run on the event loop.
folder_id=folder_id, result = await asyncio.to_thread(
path=decoded_path, BrowserService.browse_directory,
offset=offset, folder_id,
limit=limit, decoded_path,
nocache=nocache, offset,
limit,
nocache,
) )
return result return result
@@ -307,41 +334,40 @@ async def browse(
# Metadata Endpoint # Metadata Endpoint
@router.get("/metadata") @router.get("/metadata")
async def 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), _: str = Depends(verify_token),
): ):
"""Get metadata for a media file. """Get metadata for a media file inside a configured media folder.
Args: 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: Returns:
Media file metadata. Media file metadata.
Raises:
HTTPException: If file not found or metadata extraction fails.
""" """
try: try:
# URL decode the path
decoded_path = unquote(path) decoded_path = unquote(path)
file_path = Path(decoded_path) file_path = BrowserService.validate_path(folder_id, decoded_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if not file_path.is_file(): if not file_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a 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_running_loop()
loop = asyncio.get_event_loop()
metadata = await loop.run_in_executor( metadata = await loop.run_in_executor(
None, None,
MetadataService.extract_metadata, MetadataService.extract_metadata,
file_path, file_path,
) )
return metadata 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -352,59 +378,47 @@ async def get_metadata(
# Thumbnail Endpoint # Thumbnail Endpoint
@router.get("/thumbnail") @router.get("/thumbnail")
async def 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"'), size: str = Query(default="medium", description='Thumbnail size: "small" or "medium"'),
_: str = Depends(verify_token), _: str = Depends(verify_token),
): ):
"""Get thumbnail for a media file. """Get thumbnail for a media file inside a configured media folder."""
Args:
path: Full path to the media file (URL-encoded).
size: Thumbnail size ("small" or "medium").
Returns:
JPEG image bytes.
Raises:
HTTPException: If file not found or thumbnail generation fails.
"""
try: try:
# URL decode the path
decoded_path = unquote(path) decoded_path = unquote(path)
file_path = Path(decoded_path) file_path = BrowserService.validate_path(folder_id, decoded_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if not file_path.is_file(): if not file_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a 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"): if size not in ("small", "medium"):
size = "medium" size = "medium"
# Get thumbnail
thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size) thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size)
if thumbnail_data is None: if thumbnail_data is None:
return Response(status_code=204) return Response(status_code=204)
# Calculate ETag (hash of path + mtime)
import hashlib import hashlib
stat = file_path.stat() stat = file_path.stat()
etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode() etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode()
etag = hashlib.md5(etag_data).hexdigest() etag = hashlib.md5(etag_data).hexdigest()
# Return image with caching headers
return Response( return Response(
content=thumbnail_data, content=thumbnail_data,
media_type="image/jpeg", media_type="image/jpeg",
headers={ headers={
"ETag": f'"{etag}"', "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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -420,44 +434,37 @@ async def play_file(
): ):
"""Open a media file with the default system player. """Open a media file with the default system player.
Args: Requires both ``folder_id`` and a folder-relative ``path``; the resolved
request: Play request with file path. file must live inside the configured media folder and be a recognized
media file. This prevents arbitrary OS-handler invocation (e.g.,
Returns: ``os.startfile`` on Windows ``.lnk``/UNC paths).
Success message.
Raises:
HTTPException: If file not found or playback fails.
""" """
try: try:
file_path = Path(request.path) decoded_path = unquote(request.path)
file_path = BrowserService.validate_path(request.folder_id, decoded_path)
# Validate file exists
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if not file_path.is_file(): if not file_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a 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): if not BrowserService.is_media_file(file_path):
raise HTTPException(status_code=400, detail="File is not a media file") raise HTTPException(status_code=400, detail="File is not a media file")
# Get media controller and open file
controller = get_media_controller() controller = get_media_controller()
success = await controller.open_file(str(file_path)) success = await controller.open_file(str(file_path))
if not success: if not success:
raise HTTPException(status_code=500, detail="Failed to open file") raise HTTPException(status_code=500, detail="Failed to open file")
# Poll until player registers with media session API (up to 2s) _spawn_background(_broadcast_after_open(controller, file_path.name))
asyncio.create_task(_broadcast_after_open(controller, file_path.name))
return { return {
"success": True, "success": True,
"message": f"Playing {file_path.name}", "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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -489,26 +496,38 @@ async def play_folder(
if not full_path.is_dir(): if not full_path.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory") raise HTTPException(status_code=400, detail="Path is not a directory")
# Collect all media files sorted by name def _scan(directory: Path) -> list[Path]:
media_files = sorted( return sorted(
[f for f in full_path.iterdir() if f.is_file() and BrowserService.is_media_file(f)], (
key=lambda f: f.name.lower(), 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: if not media_files:
raise HTTPException(status_code=404, detail="No media files found in this folder") raise HTTPException(status_code=404, detail="No media files found in this folder")
# Generate M3U playlist with absolute paths and EXTINF entries # Generate M3U playlist with absolute paths and EXTINF entries.
# Written to local temp dir to avoid extra SMB file handle on network shares # Use NamedTemporaryFile to get a fresh per-call path — prevents
# Uses utf-8-sig (BOM) so players detect encoding properly # symlink-clobber races between concurrent /play-folder requests
# and any local user pre-creating a fixed temp filename.
lines = ["#EXTM3U"] lines = ["#EXTM3U"]
for f in media_files: for f in media_files:
lines.append(f"#EXTINF:-1,{f.stem}") lines.append(f"#EXTINF:-1,{f.stem}")
lines.append(str(f)) 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" with tempfile.NamedTemporaryFile(
playlist_path.write_text(m3u_content, encoding="utf-8-sig") 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 # Open playlist with default player
controller = get_media_controller() controller = get_media_controller()
@@ -517,8 +536,9 @@ async def play_folder(
if not success: if not success:
raise HTTPException(status_code=500, detail="Failed to open playlist") raise HTTPException(status_code=500, detail="Failed to open playlist")
# Poll until player registers with media session API (up to 2s) _spawn_background(
asyncio.create_task(_broadcast_after_open(controller, f"playlist ({len(media_files)} files)")) _broadcast_after_open(controller, f"playlist ({len(media_files)} files)")
)
return { return {
"success": True, "success": True,
+27 -4
View File
@@ -3,6 +3,7 @@
import asyncio import asyncio
import logging import logging
import subprocess import subprocess
import sys
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
@@ -21,6 +22,22 @@ logger = logging.getLogger(__name__)
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback") _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): class CallbackInfo(BaseModel):
"""Information about a configured callback.""" """Information about a configured callback."""
@@ -131,7 +148,7 @@ async def execute_callback(
try: try:
# Execute in dedicated thread pool to not block the default executor # 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( result = await loop.run_in_executor(
_callback_executor, _callback_executor,
lambda: _run_callback( lambda: _run_callback(
@@ -178,6 +195,11 @@ def _run_callback(
Dict with exit_code, stdout, stderr, execution_time Dict with exit_code, stdout, stderr, execution_time
""" """
start_time = time.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: try:
result = subprocess.run( result = subprocess.run(
command, command,
@@ -186,6 +208,7 @@ def _run_callback(
capture_output=True, capture_output=True,
text=True, text=True,
timeout=timeout, timeout=timeout,
**popen_kwargs,
) )
execution_time = time.time() - start_time execution_time = time.time() - start_time
return { return {
@@ -230,7 +253,7 @@ async def create_callback(
Raises: Raises:
HTTPException: If callback already exists or name is invalid. HTTPException: If callback already exists or name is invalid.
""" """
# Validate name _require_callbacks_management()
_validate_callback_name(callback_name) _validate_callback_name(callback_name)
# Check if callback already exists # Check if callback already exists
@@ -278,7 +301,7 @@ async def update_callback(
Raises: Raises:
HTTPException: If callback does not exist. HTTPException: If callback does not exist.
""" """
# Validate name _require_callbacks_management()
_validate_callback_name(callback_name) _validate_callback_name(callback_name)
# Check if callback exists # Check if callback exists
@@ -324,7 +347,7 @@ async def delete_callback(
Raises: Raises:
HTTPException: If callback does not exist. HTTPException: If callback does not exist.
""" """
# Validate name _require_callbacks_management()
_validate_callback_name(callback_name) _validate_callback_name(callback_name)
# Check if callback exists # Check if callback exists
+16 -13
View File
@@ -1,5 +1,6 @@
"""Display brightness, power, contrast, input-source, color-preset and picture-mode API.""" """Display brightness, power, contrast, input-source, color-preset and picture-mode API."""
import asyncio
import logging import logging
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -45,19 +46,21 @@ class PictureModeRequest(BaseModel):
code: int = Field(ge=0, le=255) 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") @router.get("/monitors")
async def get_monitors( async def get_monitors(
refresh: bool = False, refresh: bool = False,
rediscover: bool = False, rediscover: bool = False,
_: str = Depends(verify_token), _: str = Depends(verify_token),
) -> list[dict]: ) -> list[dict]:
"""List all connected monitors with their reported DDC/CI capabilities. """List all connected monitors with their reported DDC/CI capabilities."""
monitors = await asyncio.to_thread(
- `refresh=true` bypasses the response TTL cache (re-reads current state). list_monitors, force_refresh=refresh, rediscover=rediscover
- `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)
logger.debug("Found %d monitors", len(monitors)) logger.debug("Found %d monitors", len(monitors))
return [m.to_dict() for m in 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) monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token)
) -> dict: ) -> dict:
"""Set brightness for a specific monitor.""" """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: if success:
logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness) logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness)
return {"success": success} return {"success": success}
@@ -79,7 +82,7 @@ async def set_monitor_power(
) -> dict: ) -> dict:
"""Turn a monitor on or off.""" """Turn a monitor on or off."""
action = "on" if request.on else "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: if success:
logger.info("Set monitor %d power %s", monitor_id, action) logger.info("Set monitor %d power %s", monitor_id, action)
return {"success": success} return {"success": success}
@@ -90,7 +93,7 @@ async def set_monitor_contrast(
monitor_id: int, request: ContrastRequest, _: str = Depends(verify_token) monitor_id: int, request: ContrastRequest, _: str = Depends(verify_token)
) -> dict: ) -> dict:
"""Set DDC/CI contrast for a specific monitor.""" """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: if success:
logger.info("Set monitor %d contrast to %d", monitor_id, request.contrast) logger.info("Set monitor %d contrast to %d", monitor_id, request.contrast)
return {"success": success} return {"success": success}
@@ -101,7 +104,7 @@ async def set_monitor_input_source(
monitor_id: int, request: InputSourceRequest, _: str = Depends(verify_token) monitor_id: int, request: InputSourceRequest, _: str = Depends(verify_token)
) -> dict: ) -> dict:
"""Switch a monitor's DDC/CI input source (e.g. HDMI1, DP1).""" """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: if success:
logger.info("Set monitor %d input source to %s", monitor_id, request.source) logger.info("Set monitor %d input source to %s", monitor_id, request.source)
return {"success": success} return {"success": success}
@@ -112,7 +115,7 @@ async def set_monitor_color_preset(
monitor_id: int, request: ColorPresetRequest, _: str = Depends(verify_token) monitor_id: int, request: ColorPresetRequest, _: str = Depends(verify_token)
) -> dict: ) -> dict:
"""Apply a DDC/CI color preset (color temperature) to the monitor.""" """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: if success:
logger.info("Set monitor %d color preset to %s", monitor_id, request.preset) logger.info("Set monitor %d color preset to %s", monitor_id, request.preset)
return {"success": success} return {"success": success}
@@ -123,7 +126,7 @@ async def set_monitor_picture_mode(
monitor_id: int, request: PictureModeRequest, _: str = Depends(verify_token) monitor_id: int, request: PictureModeRequest, _: str = Depends(verify_token)
) -> dict: ) -> dict:
"""Apply a DDC/CI picture/scene mode (VCP 0xDC) by raw code.""" """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: if success:
logger.info("Set monitor %d picture mode to code %d", monitor_id, request.code) logger.info("Set monitor %d picture mode to code %d", monitor_id, request.code)
return {"success": success} return {"success": success}
+49 -13
View File
@@ -3,9 +3,10 @@
import logging import logging
import re import re
from typing import Any from typing import Any
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, status 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 ..auth import verify_token
from ..config import LinkConfig, settings from ..config import LinkConfig, settings
@@ -15,6 +16,35 @@ from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/links", tags=["links"]) router = APIRouter(prefix="/api/links", tags=["links"])
logger = logging.getLogger(__name__) 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): class LinkInfo(BaseModel):
"""Information about a configured link.""" """Information about a configured link."""
@@ -29,22 +59,25 @@ class LinkInfo(BaseModel):
class LinkCreateRequest(BaseModel): class LinkCreateRequest(BaseModel):
"""Request model for creating or updating a link.""" """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')") icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
label: str = Field(default="", description="Tooltip text") label: str = Field(default="", description="Tooltip text", max_length=128)
description: str = Field(default="", description="Optional description") 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: def _validate_link_name(name: str) -> None:
"""Validate link name. """Validate link name."""
if not re.match(r"^[a-zA-Z0-9_]+$", name):
Args:
name: Link name to validate.
Raises:
HTTPException: If name is invalid.
"""
if not re.match(r'^[a-zA-Z0-9_]+$', name):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Link name must contain only letters, numbers, and underscores", detail="Link name must contain only letters, numbers, and underscores",
@@ -90,6 +123,7 @@ async def create_link(
Returns: Returns:
Success response with link name. Success response with link name.
""" """
_require_links_management()
_validate_link_name(link_name) _validate_link_name(link_name)
if link_name in settings.links: if link_name in settings.links:
@@ -129,6 +163,7 @@ async def update_link(
Returns: Returns:
Success response with link name. Success response with link name.
""" """
_require_links_management()
_validate_link_name(link_name) _validate_link_name(link_name)
if link_name not in settings.links: if link_name not in settings.links:
@@ -166,6 +201,7 @@ async def delete_link(
Returns: Returns:
Success response with link name. Success response with link name.
""" """
_require_links_management()
_validate_link_name(link_name) _validate_link_name(link_name)
if link_name not in settings.links: if link_name not in settings.links:
+2 -2
View File
@@ -27,7 +27,7 @@ def _run_callback(callback_name: str) -> None:
try: try:
callback = settings.callbacks[callback_name] callback = settings.callbacks[callback_name]
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
None, None,
lambda: _run_script( 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.""" """List available loopback audio devices for the visualizer."""
from ..services.audio_analyzer import AudioAnalyzer 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) return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices)
+32 -5
View File
@@ -2,8 +2,10 @@
import asyncio import asyncio
import logging import logging
import os
import re import re
import subprocess import subprocess
import sys
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
@@ -23,6 +25,22 @@ _script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script"
logger = logging.getLogger(__name__) 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): class ScriptExecuteRequest(BaseModel):
"""Request model for script execution with optional parameters.""" """Request model for script execution with optional parameters."""
@@ -233,7 +251,7 @@ async def execute_script(
try: try:
# Execute in dedicated thread pool to not block the default executor # 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( result = await loop.run_in_executor(
_script_executor, _script_executor,
lambda: _run_script( lambda: _run_script(
@@ -285,8 +303,16 @@ def _run_script(
start_time = time.time() start_time = time.time()
env = None env = None
if extra_env: if extra_env:
import os
env = {**os.environ, **extra_env} 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: try:
result = subprocess.run( result = subprocess.run(
command, command,
@@ -296,6 +322,7 @@ def _run_script(
text=True, text=True,
timeout=timeout, timeout=timeout,
env=env, env=env,
**popen_kwargs,
) )
execution_time = time.time() - start_time execution_time = time.time() - start_time
return { return {
@@ -455,7 +482,7 @@ async def create_script(
Raises: Raises:
HTTPException: If script already exists or name is invalid. HTTPException: If script already exists or name is invalid.
""" """
# Validate name _require_scripts_management()
_validate_script_name(script_name) _validate_script_name(script_name)
# Check if script already exists # Check if script already exists
@@ -511,7 +538,7 @@ async def update_script(
Raises: Raises:
HTTPException: If script does not exist. HTTPException: If script does not exist.
""" """
# Validate name _require_scripts_management()
_validate_script_name(script_name) _validate_script_name(script_name)
# Check if script exists # Check if script exists
@@ -565,7 +592,7 @@ async def delete_script(
Raises: Raises:
HTTPException: If script does not exist. HTTPException: If script does not exist.
""" """
# Validate name _require_scripts_management()
_validate_script_name(script_name) _validate_script_name(script_name)
# Check if script exists # Check if script exists
+13
View File
@@ -72,6 +72,11 @@ class AudioAnalyzer:
self._lifecycle_lock = threading.Lock() self._lifecycle_lock = threading.Lock()
self._data: dict | None = None self._data: dict | None = None
self._current_device_name: str | 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. # Generation counter — bumped each time _data is refreshed.
# Lets the broadcast loop dedupe without comparing dict identity # Lets the broadcast loop dedupe without comparing dict identity
# (which is fragile because we always allocate a new dict). # (which is fragile because we always allocate a new dict).
@@ -123,6 +128,10 @@ class AudioAnalyzer:
return True return True
if not self.available: if not self.available:
return False 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 # Reset AGC envelope so a long silent gap between sessions
# doesn't make the first new transients clip at the ceiling. # doesn't make the first new transients clip at the ceiling.
@@ -235,6 +244,9 @@ class AudioAnalyzer:
self.device_name = device_name self.device_name = device_name
self._current_device_name = None 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: if was_running:
return self.start() return self.start()
@@ -269,6 +281,7 @@ class AudioAnalyzer:
if device is None: if device is None:
logger.warning("No loopback audio device found - visualizer disabled") logger.warning("No loopback audio device found - visualizer disabled")
self._running = False self._running = False
self._unavailable = True
return return
interval = 1.0 / self.target_fps interval = 1.0 / self.target_fps
+20 -6
View File
@@ -63,14 +63,28 @@ class BrowserService:
if not base_path.is_dir(): if not base_path.is_dir():
raise ValueError(f"Media folder path is not a directory: {base_path}") raise ValueError(f"Media folder path is not a directory: {base_path}")
# Handle relative vs absolute paths # Reject absolute paths, drive letters, UNC paths, and NUL bytes outright.
if requested_path.startswith("/") or requested_path.startswith("\\"): # Only true folder-relative paths are accepted.
# Relative to folder root (remove leading slash) if "\x00" in requested_path:
requested_path = requested_path.lstrip("/\\") 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 # Build and resolve full path
if requested_path: if cleaned:
full_path = (base_path / requested_path).resolve() full_path = (base_path / cleaned).resolve()
else: else:
full_path = base_path full_path = base_path
+24 -68
View File
@@ -151,22 +151,19 @@ class LinuxMediaController(MediaController):
logger.error(f"Failed to toggle mute: {e}") logger.error(f"Failed to toggle mute: {e}")
return False return False
async def get_status(self) -> MediaStatus: def _sync_get_status(self) -> MediaStatus:
"""Get current media playback status.""" """Synchronous status read (called from a worker thread)."""
status = MediaStatus() status = MediaStatus()
# Get system volume
volume, muted = self._get_volume_pulseaudio() volume, muted = self._get_volume_pulseaudio()
status.volume = volume status.volume = volume
status.muted = muted status.muted = muted
# Get active player
player_name = self._get_active_player() player_name = self._get_active_player()
if player_name is None: if player_name is None:
status.state = MediaState.IDLE status.state = MediaState.IDLE
return status return status
# Get playback status
playback_status = self._get_property(player_name, "PlaybackStatus") playback_status = self._get_property(player_name, "PlaybackStatus")
if playback_status == "Playing": if playback_status == "Playing":
status.state = MediaState.PLAYING status.state = MediaState.PLAYING
@@ -177,114 +174,70 @@ class LinuxMediaController(MediaController):
else: else:
status.state = MediaState.IDLE status.state = MediaState.IDLE
# Get metadata
metadata = self._get_property(player_name, "Metadata") metadata = self._get_property(player_name, "Metadata")
if metadata: if metadata:
status.title = str(metadata.get("xesam:title", "")) or None status.title = str(metadata.get("xesam:title", "")) or None
artists = metadata.get("xesam:artist", []) artists = metadata.get("xesam:artist", [])
if artists: if artists:
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists) status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
status.album = str(metadata.get("xesam:album", "")) or None status.album = str(metadata.get("xesam:album", "")) or None
status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None
# Duration in microseconds
length = metadata.get("mpris:length", 0) length = metadata.get("mpris:length", 0)
if length: if length:
status.duration = int(length) / 1_000_000 status.duration = int(length) / 1_000_000
# Get position (in microseconds)
position = self._get_property(player_name, "Position") position = self._get_property(player_name, "Position")
if position is not None: if position is not None:
status.position = int(position) / 1_000_000 status.position = int(position) / 1_000_000
# Get source name
status.source = player_name.replace(self.MPRIS_PREFIX, "") status.source = player_name.replace(self.MPRIS_PREFIX, "")
return status return status
async def play(self) -> bool: async def get_status(self) -> MediaStatus:
"""Resume playback.""" """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() player_name = self._get_active_player()
if player_name is None: if player_name is None:
return False return False
try: try:
player = self._get_player_interface(player_name) player = self._get_player_interface(player_name)
player.Play() getattr(player, method_name)()
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to play: {e}") logger.error(f"Failed to call player.{method_name}: {e}")
return False return False
async def play(self) -> bool:
return await asyncio.to_thread(self._call_player, "Play")
async def pause(self) -> bool: async def pause(self) -> bool:
"""Pause playback.""" return await asyncio.to_thread(self._call_player, "Pause")
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
async def stop(self) -> bool: async def stop(self) -> bool:
"""Stop playback.""" return await asyncio.to_thread(self._call_player, "Stop")
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
async def next_track(self) -> bool: async def next_track(self) -> bool:
"""Skip to next track.""" return await asyncio.to_thread(self._call_player, "Next")
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
async def previous_track(self) -> bool: async def previous_track(self) -> bool:
"""Go to previous track.""" return await asyncio.to_thread(self._call_player, "Previous")
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
async def set_volume(self, volume: int) -> bool: async def set_volume(self, volume: int) -> bool:
"""Set system volume.""" return await asyncio.to_thread(self._set_volume_pulseaudio, volume)
return self._set_volume_pulseaudio(volume)
async def toggle_mute(self) -> bool: async def toggle_mute(self) -> bool:
"""Toggle mute state.""" return await asyncio.to_thread(self._toggle_mute_pulseaudio)
return self._toggle_mute_pulseaudio()
async def seek(self, position: float) -> bool: def _sync_seek(self, position: float) -> bool:
"""Seek to position in seconds."""
player_name = self._get_active_player() player_name = self._get_active_player()
if player_name is None: if player_name is None:
return False return False
try: try:
player = self._get_player_interface(player_name) player = self._get_player_interface(player_name)
# MPRIS expects position in microseconds
player.SetPosition( player.SetPosition(
self._get_property(player_name, "Metadata").get("mpris:trackid", "/"), self._get_property(player_name, "Metadata").get("mpris:trackid", "/"),
int(position * 1_000_000), int(position * 1_000_000),
@@ -294,6 +247,9 @@ class LinuxMediaController(MediaController):
logger.error(f"Failed to seek: {e}") logger.error(f"Failed to seek: {e}")
return False 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: async def open_file(self, file_path: str) -> bool:
"""Open a media file with the default system player (Linux). """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: if suffix in AUDIO_EXTENSIONS:
# Audio files - run in executor (sync operation) # Audio files - run in executor (sync operation)
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
thumbnail_data = await loop.run_in_executor( thumbnail_data = await loop.run_in_executor(
None, None,
ThumbnailService.generate_audio_thumbnail, ThumbnailService.generate_audio_thumbnail,
+16 -5
View File
@@ -70,16 +70,27 @@ class ConnectionManager:
) )
async def broadcast(self, message: dict[str, Any]) -> None: 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: async with self._lock:
connections = list(self._active_connections) connections = list(self._active_connections)
if not connections: if not connections:
return 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: async def _send(ws: WebSocket) -> WebSocket | None:
try: try:
await ws.send_json(message) await ws.send_text(payload)
return None return None
except Exception as e: except Exception as e:
logger.debug("Failed to send to client: %s", e) logger.debug("Failed to send to client: %s", e)
@@ -129,7 +140,7 @@ class ConnectionManager:
async def _maybe_start_capture(self) -> None: async def _maybe_start_capture(self) -> None:
"""Start audio capture if not already running (called on first subscriber).""" """Start audio capture if not already running (called on first subscriber)."""
if self._audio_analyzer and not self._audio_analyzer.running: 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) started = await loop.run_in_executor(None, self._audio_analyzer.start)
if started: if started:
logger.info("Audio capture started (first subscriber)") logger.info("Audio capture started (first subscriber)")
@@ -139,7 +150,7 @@ class ConnectionManager:
async def _maybe_stop_capture(self) -> None: async def _maybe_stop_capture(self) -> None:
"""Stop audio capture if running (called when last subscriber leaves).""" """Stop audio capture if running (called when last subscriber leaves)."""
if self._audio_analyzer and self._audio_analyzer.running: 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) await loop.run_in_executor(None, self._audio_analyzer.stop)
logger.info("Audio capture stopped (no subscribers)") logger.info("Audio capture stopped (no subscribers)")
@@ -171,7 +182,7 @@ class ConnectionManager:
idle_interval = 1.0 / max(1, settings.visualizer_fps) idle_interval = 1.0 / max(1, settings.visualizer_fps)
# Bounded wait so we still notice subscribe/unsubscribe transitions. # Bounded wait so we still notice subscribe/unsubscribe transitions.
wake_timeout = max(0.05, idle_interval) wake_timeout = max(0.05, idle_interval)
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
last_seq = -1 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) # Thread pool for WinRT operations (they don't play well with asyncio)
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt") _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) # Global storage for current album art (as bytes)
_current_album_art_bytes: bytes | None = None _current_album_art_bytes: bytes | None = None
@@ -161,8 +177,6 @@ WINDOWS_AVAILABLE = WINSDK_AVAILABLE
def _sync_get_media_status() -> dict[str, Any]: def _sync_get_media_status() -> dict[str, Any]:
"""Synchronously get media status (runs in thread pool).""" """Synchronously get media status (runs in thread pool)."""
import asyncio
result = { result = {
"state": "idle", "state": "idle",
"title": None, "title": None,
@@ -174,9 +188,7 @@ def _sync_get_media_status() -> dict[str, Any]:
} }
try: try:
# Create a new event loop for this thread loop = _thread_loop()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try: try:
# Get media session manager # Get media session manager
@@ -393,7 +405,8 @@ def _sync_get_media_status() -> dict[str, Any]:
result["source"] = session.source_app_user_model_id result["source"] = session.source_app_user_model_id
finally: finally:
loop.close() # Reuse the loop across calls — see _thread_loop above.
pass
except Exception as e: except Exception as e:
logger.error(f"Error getting media status: {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: def _sync_media_command(command: str) -> bool:
"""Synchronously execute a media command (runs in thread pool).""" """Synchronously execute a media command (runs in thread pool)."""
import asyncio
try: try:
loop = asyncio.new_event_loop() loop = _thread_loop()
asyncio.set_event_loop(loop) manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
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())
return False 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: except Exception as e:
logger.error(f"Error executing media command {command}: {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: def _sync_seek(position: float) -> bool:
"""Synchronously seek to position.""" """Synchronously seek to position."""
import asyncio
try: try:
loop = asyncio.new_event_loop() loop = _thread_loop()
asyncio.set_event_loop(loop) manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
return False
try: session = _find_best_session(manager, loop)
manager = loop.run_until_complete(MediaManager.request_async()) if session is None:
if manager is None: return False
return False
session = _find_best_session(manager, loop) position_ticks = int(position * 10_000_000)
if session is None: return loop.run_until_complete(
return False session.try_change_playback_position_async(position_ticks)
)
position_ticks = int(position * 10_000_000)
return loop.run_until_complete(
session.try_change_playback_position_async(position_ticks)
)
finally:
loop.close()
except Exception as e: except Exception as e:
logger.error(f"Error seeking: {e}") logger.error(f"Error seeking: {e}")
@@ -559,7 +558,7 @@ class WindowsMediaController(MediaController):
# Get media info in thread pool (avoids asyncio/WinRT issues) # Get media info in thread pool (avoids asyncio/WinRT issues)
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
media_info = await asyncio.wait_for( media_info = await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_get_media_status), loop.run_in_executor(_executor, _sync_get_media_status),
timeout=5.0 timeout=5.0
@@ -592,7 +591,7 @@ class WindowsMediaController(MediaController):
async def _run_command(self, command: str) -> bool: async def _run_command(self, command: str) -> bool:
"""Run a media command in the thread pool.""" """Run a media command in the thread pool."""
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
return await asyncio.wait_for( return await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_media_command, command), loop.run_in_executor(_executor, _sync_media_command, command),
timeout=5.0 timeout=5.0
@@ -616,16 +615,15 @@ class WindowsMediaController(MediaController):
"""Stop playback.""" """Stop playback."""
return await self._run_command("stop") return await self._run_command("stop")
async def next_track(self) -> bool: async def _skip_track(self, command: str) -> bool:
"""Skip to next track.""" # Read the current title from the position cache instead of doing a
# Get current title before skipping # full WinRT round-trip (which can take up to 5s) just for one field.
try: with _position_lock:
status = await self.get_status() track_id = _position_cache.get("track_id") or ""
old_title = status.title or "" # track_id is "title:artist:duration" — extract just the title.
except Exception: old_title = track_id.split(":", 1)[0] if track_id else ""
old_title = ""
result = await self._run_command("next") result = await self._run_command(command)
if result: if result:
with _position_lock: with _position_lock:
_track_skip_pending["active"] = True _track_skip_pending["active"] = True
@@ -634,23 +632,13 @@ class WindowsMediaController(MediaController):
logger.debug(f"Track skip initiated, old title: {old_title}") logger.debug(f"Track skip initiated, old title: {old_title}")
return result return result
async def next_track(self) -> bool:
"""Skip to next track."""
return await self._skip_track("next")
async def previous_track(self) -> bool: async def previous_track(self) -> bool:
"""Go to previous track.""" """Go to previous track."""
# Get current title before skipping return await self._skip_track("previous")
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
async def set_volume(self, volume: int) -> bool: async def set_volume(self, volume: int) -> bool:
"""Set system volume.""" """Set system volume."""
@@ -680,7 +668,7 @@ class WindowsMediaController(MediaController):
async def seek(self, position: float) -> bool: async def seek(self, position: float) -> bool:
"""Seek to position in seconds.""" """Seek to position in seconds."""
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
return await asyncio.wait_for( return await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_seek, position), loop.run_in_executor(_executor, _sync_seek, position),
timeout=5.0 timeout=5.0
@@ -705,7 +693,7 @@ class WindowsMediaController(MediaController):
""" """
try: try:
import os import os
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: os.startfile(file_path)) await loop.run_in_executor(None, lambda: os.startfile(file_path))
logger.info(f"Opened file with default player: {file_path}") logger.info(f"Opened file with default player: {file_path}")
return True return True
+39 -21
View File
@@ -329,7 +329,7 @@ body.translations-loaded {
button:focus-visible { button:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 2px; 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, input:focus-visible,
@@ -337,7 +337,7 @@ select:focus-visible,
textarea:focus-visible { textarea:focus-visible {
outline: none; outline: none;
border-color: var(--accent); 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 { .tab-btn:focus-visible {
@@ -1004,7 +1004,7 @@ button:disabled {
.controls button:focus-visible { .controls button:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 3px; 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, .mute-btn:focus-visible,
@@ -1012,7 +1012,7 @@ button:disabled {
.vinyl-toggle-btn:focus-visible { .vinyl-toggle-btn:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 2px; 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 { .controls button.primary {
@@ -1060,7 +1060,7 @@ button:disabled {
#volume-slider:hover::-webkit-slider-thumb { #volume-slider:hover::-webkit-slider-thumb {
transform: scale(1.3); 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 { #volume-slider::-moz-range-thumb {
@@ -1075,7 +1075,7 @@ button:disabled {
#volume-slider:hover::-moz-range-thumb { #volume-slider:hover::-moz-range-thumb {
transform: scale(1.3); 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 { .volume-display {
@@ -1169,7 +1169,7 @@ button:disabled {
.vinyl-toggle-btn.active { .vinyl-toggle-btn.active {
color: var(--accent); color: var(--accent);
border-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 { .vinyl-toggle-btn svg {
@@ -1393,7 +1393,7 @@ button:disabled {
border-radius: 4px; border-radius: 4px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-primary); 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; font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: border-color 0.25s ease, box-shadow 0.25s ease; transition: border-color 0.25s ease, box-shadow 0.25s ease;
@@ -1402,7 +1402,7 @@ button:disabled {
.audio-device-selector select:focus { .audio-device-selector select:focus {
outline: none; outline: none;
border-color: var(--accent); 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 { .audio-device-status {
@@ -1836,13 +1836,18 @@ button:disabled {
dialog { dialog {
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-primary); 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: 1px solid var(--border);
border-radius: 12px; border-top: 1px solid var(--copper);
border-radius: 0;
padding: 0; padding: 0;
max-width: 500px; max-width: 520px;
width: 90%; width: 90%;
margin: auto; 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; animation: dialogIn 0.25s ease-out;
overflow: visible; overflow: visible;
} }
@@ -2827,7 +2832,7 @@ button.primary svg {
.breadcrumb-item:hover { .breadcrumb-item:hover {
color: var(--accent); color: var(--accent);
background: rgba(29, 185, 84, 0.08); background: rgba(var(--copper-rgb), 0.08);
text-decoration: none; text-decoration: none;
} }
@@ -3235,12 +3240,14 @@ button.primary svg {
} }
.browser-item { .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: 1px solid transparent;
border-radius: 10px; border-radius: 0;
padding: 0.6rem; padding: 0.6rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -3250,6 +3257,11 @@ button.primary svg {
animation-delay: calc(var(--item-index, 0) * 25ms); 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 { @keyframes itemFadeIn {
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
@@ -3286,14 +3298,14 @@ button.primary svg {
height: 56px; height: 56px;
font-size: 1.75rem; font-size: 1.75rem;
border-radius: 14px; border-radius: 14px;
background: rgba(29, 185, 84, 0.1); background: rgba(var(--copper-rgb), 0.1);
border: 1px solid rgba(29, 185, 84, 0.15); border: 1px solid rgba(var(--copper-rgb), 0.15);
transition: all 0.25s; transition: all 0.25s;
} }
.browser-item.browser-root-folder:hover .browser-icon { .browser-item.browser-root-folder:hover .browser-icon {
background: rgba(29, 185, 84, 0.18); background: rgba(var(--copper-rgb), 0.18);
border-color: rgba(29, 185, 84, 0.3); border-color: rgba(var(--copper-rgb), 0.3);
transform: scale(1.05); transform: scale(1.05);
} }
@@ -3963,7 +3975,13 @@ html {
} }
@media (max-width: 720px) { @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) ────────────────── */ /* ─── Folio marks (page corners, all tabs) ────────────────── */
+2 -2
View File
@@ -522,7 +522,7 @@
<td colspan="4" class="empty-state"> <td colspan="4" class="empty-state">
<div class="empty-state-illustration"> <div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg> <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> </div>
</td> </td>
</tr> </tr>
@@ -608,7 +608,7 @@
<!-- Script Parameters Input Dialog (shown before executing scripts with params) --> <!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
<dialog id="scriptParamsDialog"> <dialog id="scriptParamsDialog">
<div class="dialog-header"> <div class="dialog-header">
<h3 id="scriptParamsDialogTitle">Execute Script</h3> <h3 id="scriptParamsDialogTitle" data-i18n="scripts.params.execute">Execute Script</h3>
</div> </div>
<form id="scriptParamsForm" onsubmit="submitScriptWithParams(event)"> <form id="scriptParamsForm" onsubmit="submitScriptWithParams(event)">
<div class="dialog-body"> <div class="dialog-body">
+49 -20
View File
@@ -66,12 +66,14 @@ function showRootFolders() {
// Hide search at root level // Hide search at root level
showBrowserSearch(false); 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'); const breadcrumb = document.getElementById('breadcrumb');
breadcrumb.innerHTML = ''; breadcrumb.innerHTML = '';
const root = document.createElement('span'); const root = document.createElement('span');
root.className = 'breadcrumb-item breadcrumb-home'; 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); breadcrumb.appendChild(root);
// Hide play all button and pagination // Hide play all button and pagination
@@ -133,8 +135,10 @@ function showRootFolders() {
} }
async function browsePath(folderId, path, offset = 0, nocache = false) { 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); showBrowserSearch(false);
bumpBrowseGen();
try { try {
if (!hasCredentials()) return; if (!hasCredentials()) return;
@@ -195,10 +199,13 @@ function renderBreadcrumbs(currentPathStr, parentPath) {
const parts = (currentPathStr || '').split('/').filter(p => p); const parts = (currentPathStr || '').split('/').filter(p => p);
let path = '/'; let path = '/';
// Home link (back to folder list) // Home link (back to folder list) — use a real <button> so it's
const home = document.createElement('span'); // keyboard-focusable and reachable by screen readers.
const home = document.createElement('button');
home.type = 'button';
home.className = 'breadcrumb-item breadcrumb-home'; 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(); home.onclick = () => showRootFolders();
breadcrumb.appendChild(home); breadcrumb.appendChild(home);
@@ -512,16 +519,33 @@ function formatBitrate(bps) {
return Math.round(bps / 1000) + ' kbps'; 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) { async function loadThumbnail(imgElement, fileName) {
const myGen = currentBrowseGen();
const folderId = currentFolderId;
const relPath = buildRelativeFilePath(currentPath, fileName);
const cacheKey = `${folderId}|${relPath}`;
try { try {
if (!hasCredentials()) return; if (!hasCredentials()) return;
// If the user navigates away before this fetch resolves, the imgElement
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName); // may already be detached. Bail in that case.
if (!imgElement.isConnected) return;
// Check cache first // Check cache first
if (thumbnailCache.has(absolutePath)) { if (thumbnailCache.has(cacheKey)) {
const cachedUrl = thumbnailCache.get(absolutePath); const cachedUrl = thumbnailCache.get(cacheKey);
imgElement.onload = () => { imgElement.onload = () => {
if (!imgElement.isConnected) return;
imgElement.classList.remove('loading'); imgElement.classList.remove('loading');
imgElement.classList.add('loaded'); imgElement.classList.add('loaded');
}; };
@@ -529,17 +553,24 @@ async function loadThumbnail(imgElement, fileName) {
return; return;
} }
const encodedPath = encodeURIComponent(absolutePath); const params = new URLSearchParams({
folder_id: folderId,
path: relPath,
size: 'medium',
});
const response = await fetch( const response = await fetch(
`/api/browser/thumbnail?path=${encodedPath}&size=medium`, `/api/browser/thumbnail?${params.toString()}`,
{ headers: getAuthHeaders() } { headers: getAuthHeaders() }
); );
// Drop the response if the user has since navigated away.
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
if (response.status === 200) { if (response.status === 200) {
const blob = await response.blob(); const blob = await response.blob();
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
thumbnailCache.set(absolutePath, url); thumbnailCache.set(cacheKey, url);
// Evict oldest entries when cache exceeds limit // Evict oldest entries when cache exceeds limit
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) { if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
@@ -548,13 +579,11 @@ async function loadThumbnail(imgElement, fileName) {
thumbnailCache.delete(oldest); thumbnailCache.delete(oldest);
} }
// Wait for image to actually load before showing it
imgElement.onload = () => { imgElement.onload = () => {
imgElement.classList.remove('loading'); imgElement.classList.remove('loading');
imgElement.classList.add('loaded'); imgElement.classList.add('loaded');
}; };
// Revoke previous blob URL if not managed by cache
if (imgElement.src && imgElement.src.startsWith('blob:')) { if (imgElement.src && imgElement.src.startsWith('blob:')) {
let isCached = false; let isCached = false;
for (const cachedUrl of thumbnailCache.values()) { for (const cachedUrl of thumbnailCache.values()) {
@@ -564,8 +593,8 @@ async function loadThumbnail(imgElement, fileName) {
} }
imgElement.src = url; imgElement.src = url;
} else { } else {
// Fallback to icon (204 = no thumbnail available)
const parent = imgElement.parentElement; const parent = imgElement.parentElement;
if (!parent) return;
const isList = parent.classList.contains('browser-list-icon'); const isList = parent.classList.contains('browser-list-icon');
imgElement.remove(); imgElement.remove();
if (isList) { if (isList) {
@@ -579,7 +608,7 @@ async function loadThumbnail(imgElement, fileName) {
} }
} catch (error) { } catch (error) {
console.error('Error loading thumbnail:', 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 { try {
if (!hasCredentials()) return; if (!hasCredentials()) return;
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName); const relativePath = buildRelativeFilePath(currentPath, fileName);
const response = await fetch('/api/browser/play', { const response = await fetch('/api/browser/play', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, 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'); if (!response.ok) throw new Error('Failed to play file');
+13 -12
View File
@@ -81,7 +81,7 @@ async function _loadCallbacksTableImpl() {
`).join(''); `).join('');
} catch (error) { } catch (error) {
console.error('Error loading callbacks:', 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); const callback = callbacksList.find(c => c.name === callbackName);
if (!callback) { if (!callback) {
showToast('Callback not found', 'error'); showToast(t('callbacks.msg.not_found'), 'error');
return; return;
} }
@@ -142,7 +142,7 @@ export async function showEditCallbackDialog(callbackName) {
dialog.showModal(); dialog.showModal();
} catch (error) { } catch (error) {
console.error('Error loading callback for edit:', 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 shell: true
}; };
const encodedName = encodeURIComponent(callbackName);
const endpoint = isEdit ? const endpoint = isEdit ?
`/api/callbacks/update/${callbackName}` : `/api/callbacks/update/${encodedName}` :
`/api/callbacks/create/${callbackName}`; `/api/callbacks/create/${encodedName}`;
const method = isEdit ? 'PUT' : 'POST'; const method = isEdit ? 'PUT' : 'POST';
@@ -191,16 +192,16 @@ export async function saveCallback(event) {
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success'); showToast(t(isEdit ? 'callbacks.msg.updated' : 'callbacks.msg.created'), 'success');
callbackFormDirty = false; callbackFormDirty = false;
closeCallbackDialog(); closeCallbackDialog();
loadCallbacksTable(); loadCallbacksTable();
} else { } 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) { } catch (error) {
console.error('Error saving callback:', 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 { } finally {
if (submitBtn) submitBtn.disabled = false; if (submitBtn) submitBtn.disabled = false;
} }
@@ -212,7 +213,7 @@ export async function deleteCallbackConfirm(callbackName) {
} }
try { try {
const response = await fetch(`/api/callbacks/delete/${callbackName}`, { const response = await fetch(`/api/callbacks/delete/${encodeURIComponent(callbackName)}`, {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders() headers: getAuthHeaders()
}); });
@@ -220,13 +221,13 @@ export async function deleteCallbackConfirm(callbackName) {
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showToast('Callback deleted successfully', 'success'); showToast(t('callbacks.msg.deleted'), 'success');
loadCallbacksTable(); loadCallbacksTable();
} else { } else {
showToast(result.detail || 'Failed to delete callback', 'error'); showToast(result.detail || t('callbacks.msg.delete_failed'), 'error');
} }
} catch (error) { } catch (error) {
console.error('Error deleting callback:', 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) // Shared state (accessed across multiple modules)
export let ws = null; export let ws = null;
export function setWs(value) { ws = value; } export function setWs(value) { ws = value; }
export function getWs() { return ws; }
export let currentState = 'idle'; export let currentState = 'idle';
export function setCurrentState(value) { currentState = value; } export function setCurrentState(value) { currentState = value; }
export let currentDuration = 0; export let currentDuration = 0;
@@ -513,6 +514,12 @@ export function setVolume(volume) {
sendCommand('volume', { volume: 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() { export function toggleMute() {
sendCommand('mute'); sendCommand('mute');
@@ -536,23 +543,68 @@ function _persistMdiCache() {
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {} 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) { 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]; if (mdiIconCache[name]) return mdiIconCache[name];
try { 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) { if (response.ok) {
const svg = await response.text(); const raw = await response.text();
mdiIconCache[name] = svg; const safe = sanitizeSvg(raw);
_persistMdiCache(); if (safe) {
return svg; mdiIconCache[name] = safe;
_persistMdiCache();
return safe;
}
} }
} catch (e) { } catch (e) {
console.warn('Failed to fetch MDI icon:', name, 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) { export async function resolveMdiIcons(container) {
+28 -12
View File
@@ -148,16 +148,19 @@ export async function loadDisplayMonitors() {
let powerBtn = ''; let powerBtn = '';
if (monitor.power_supported) { 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 = ` powerBtn = `
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}" <button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
onclick="toggleDisplayPower(${monitor.id}, '${monitor.name.replace(/'/g, "\\'")}')" data-action="toggle-power" data-monitor-id="${monitor.id}"
title="${monitor.power_on ? t('display.power_off') : t('display.power_on')}"> 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> <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>`; </button>`;
} }
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 '); 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 const primaryBadge = monitor.is_primary
? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.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"> <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"/> <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> </svg>
<div class="display-monitor-info"> <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} ${detailsHtml}
</div> </div>
${powerBtn} ${powerBtn}
@@ -303,6 +306,11 @@ export async function loadDisplayMonitors() {
container.appendChild(card); 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 // Enhance every tuning <select> with an IconSelect now that the
// cards are in the DOM (IconSelect needs offsetParent + sibling). // cards are in the DOM (IconSelect needs offsetParent + sibling).
pendingIconSelects.forEach(({ kind, monitorId, items }) => { 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 btn = document.getElementById(`power-btn-${monitorId}`);
const isOn = btn && btn.classList.contains('on'); const isOn = btn && btn.classList.contains('on');
const newState = !isOn; const newState = !isOn;
@@ -459,13 +474,13 @@ export async function toggleDisplayPower(monitorId, monitorName) {
btn.classList.toggle('off', !newState); btn.classList.toggle('off', !newState);
btn.title = newState ? t('display.power_off') : t('display.power_on'); 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 { } else {
showToast('Failed to change monitor power', 'error'); showToast(t('display.msg.power_failed'), 'error');
} }
} catch (e) { } catch (e) {
console.error('Failed to set display power:', 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); resolveMdiIcons(tbody);
} catch (error) { } catch (error) {
console.error('Error loading links:', 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 || '' description: document.getElementById('linkDescription').value || ''
}; };
const encodedName = encodeURIComponent(linkName);
const endpoint = isEdit ? const endpoint = isEdit ?
`/api/links/update/${linkName}` : `/api/links/update/${encodedName}` :
`/api/links/create/${linkName}`; `/api/links/create/${encodedName}`;
const method = isEdit ? 'PUT' : 'POST'; const method = isEdit ? 'PUT' : 'POST';
@@ -701,7 +717,7 @@ export async function deleteLinkConfirm(linkName) {
} }
try { try {
const response = await fetch(`/api/links/delete/${linkName}`, { const response = await fetch(`/api/links/delete/${encodeURIComponent(linkName)}`, {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders() headers: getAuthHeaders()
}); });
+47 -42
View File
@@ -8,7 +8,7 @@ import {
ws, currentState, setCurrentState, currentDuration, setCurrentDuration, ws, currentState, setCurrentState, currentDuration, setCurrentDuration,
currentPosition, setCurrentPosition, isUserAdjustingVolume, currentPosition, setCurrentPosition, isUserAdjustingVolume,
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState, lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
POSITION_INTERPOLATION_MS, seek, POSITION_INTERPOLATION_MS, seek, notifyRemoteVolume,
getAuthHeaders, hasCredentials, getAuthHeaders, hasCredentials,
} from './core.js'; } from './core.js';
import { updateBackgroundColors } from './background.js'; import { updateBackgroundColors } from './background.js';
@@ -687,54 +687,49 @@ export async function onAudioDeviceChanged() {
let lastArtworkKey = null; let lastArtworkKey = null;
let currentArtworkBlobUrl = null; let currentArtworkBlobUrl = null;
let artworkFetchGen = 0;
let artworkAbort = null;
let lastPositionUpdate = 0; let lastPositionUpdate = 0;
let lastPositionValue = 0; let lastPositionValue = 0;
let interpolationInterval = null; let interpolationInterval = null;
export function setupProgressDrag(bar, fill) { 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) { function getPercent(clientX) {
const rect = bar.getBoundingClientRect(); const rect = bar.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
} }
function updatePreview(percent) { fill.style.width = (percent * 100) + '%'; }
function updatePreview(percent) { function pointerStart(getX, moveEvent, endEvent, getMoveX, getEndX) {
fill.style.width = (percent * 100) + '%';
}
function handleStart(clientX) {
if (currentDuration <= 0) return; if (currentDuration <= 0) return;
dragging = true;
bar.classList.add('dragging'); bar.classList.add('dragging');
updatePreview(getPercent(clientX)); updatePreview(getPercent(getX));
}
function handleMove(clientX) { function onMove(e) { updatePreview(getPercent(getMoveX(e))); }
if (!dragging) return; function onEnd(e) {
updatePreview(getPercent(clientX)); document.removeEventListener(moveEvent, onMove);
} document.removeEventListener(endEvent, onEnd);
bar.classList.remove('dragging');
function handleEnd(clientX) { const clientX = getEndX(e);
if (!dragging) return; if (clientX !== undefined) seek(getPercent(clientX) * currentDuration);
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);
} }
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) => { bar.addEventListener('click', (e) => {
if (currentDuration > 0) { if (currentDuration > 0) {
@@ -811,28 +806,35 @@ export function updateUI(status) {
lastArtworkKey = artworkKey; 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 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"; 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) { 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', { fetch('/api/media/artwork', {
headers: getAuthHeaders() headers: getAuthHeaders(),
signal: artworkAbort.signal,
}) })
.then(r => r.ok ? r.blob() : null) .then(r => r.ok ? r.blob() : null)
.then(blob => { .then(blob => {
if (!blob) return; if (!blob || myGen !== artworkFetchGen) return;
const oldBlobUrl = currentArtworkBlobUrl; const oldBlobUrl = currentArtworkBlobUrl;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
currentArtworkBlobUrl = url; currentArtworkBlobUrl = url;
swapArtworkSrc(dom.albumArt, url); swapArtworkSrc(dom.albumArt, url);
if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url; if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.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); syncFullscreenBloomArt(url);
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000); 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 { } else {
if (currentArtworkBlobUrl) { if (currentArtworkBlobUrl) {
URL.revokeObjectURL(currentArtworkBlobUrl); URL.revokeObjectURL(currentArtworkBlobUrl);
@@ -858,6 +860,9 @@ export function updateUI(status) {
} }
if (!isUserAdjustingVolume) { 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.volumeSlider.value = status.volume;
dom.volumeDisplay.textContent = `${status.volume}%`; dom.volumeDisplay.textContent = `${status.volume}%`;
dom.miniVolumeSlider.value = 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) { async function _doExecuteScript(scriptName, params) {
try { try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, { const response = await fetch(`/api/scripts/execute/${encodeURIComponent(scriptName)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ params }) body: JSON.stringify({ params })
@@ -393,7 +393,7 @@ async function _loadScriptsTableImpl() {
resolveMdiIcons(tbody); resolveMdiIcons(tbody);
} catch (error) { } catch (error) {
console.error('Error loading scripts:', 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); const script = scriptsList.find(s => s.name === scriptName);
if (!script) { if (!script) {
showToast('Script not found', 'error'); showToast(t('scripts.msg.not_found'), 'error');
return; return;
} }
@@ -470,7 +470,7 @@ export async function showEditScriptDialog(scriptName) {
dialog.showModal(); dialog.showModal();
} catch (error) { } catch (error) {
console.error('Error loading script for edit:', 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(), parameters: _collectParameterDefinitions(),
}; };
const encodedName = encodeURIComponent(scriptName);
const endpoint = isEdit ? const endpoint = isEdit ?
`/api/scripts/update/${scriptName}` : `/api/scripts/update/${encodedName}` :
`/api/scripts/create/${scriptName}`; `/api/scripts/create/${encodedName}`;
const method = isEdit ? 'PUT' : 'POST'; const method = isEdit ? 'PUT' : 'POST';
@@ -524,15 +525,15 @@ export async function saveScript(event) {
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success'); showToast(t(isEdit ? 'scripts.msg.updated' : 'scripts.msg.created'), 'success');
scriptFormDirty = false; scriptFormDirty = false;
closeScriptDialog(); closeScriptDialog();
} else { } 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) { } catch (error) {
console.error('Error saving script:', 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 { } finally {
if (submitBtn) submitBtn.disabled = false; if (submitBtn) submitBtn.disabled = false;
} }
@@ -544,7 +545,7 @@ export async function deleteScriptConfirm(scriptName) {
} }
try { try {
const response = await fetch(`/api/scripts/delete/${scriptName}`, { const response = await fetch(`/api/scripts/delete/${encodeURIComponent(scriptName)}`, {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders() headers: getAuthHeaders()
}); });
@@ -552,13 +553,13 @@ export async function deleteScriptConfirm(scriptName) {
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showToast('Script deleted successfully', 'success'); showToast(t('scripts.msg.deleted'), 'success');
} else { } else {
showToast(result.detail || 'Failed to delete script', 'error'); showToast(result.detail || t('scripts.msg.delete_failed'), 'error');
} }
} catch (error) { } catch (error) {
console.error('Error deleting script:', 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 outputPre = document.getElementById('executionOutput');
const errorPre = document.getElementById('executionError'); 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 success = result.success && result.exit_code === 0;
const statusClass = success ? 'success' : 'error'; const statusClass = success ? 'success' : 'error';
const statusText = success ? 'Success' : 'Failed'; const statusText = t(success ? 'execution.success' : 'execution.failed');
statusDiv.innerHTML = ` statusDiv.innerHTML = `
<div class="status-item ${statusClass}"> <div class="status-item ${statusClass}">
<label>Status</label> <label>${escapeHtml(t('execution.status'))}</label>
<value>${statusText}</value> <value>${escapeHtml(statusText)}</value>
</div> </div>
<div class="status-item"> <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> <value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
</div> </div>
<div class="status-item"> <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> <value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
</div> </div>
`; `;
@@ -606,7 +607,7 @@ function showExecutionResult(name, result, type = 'script') {
if (result.stdout && result.stdout.trim()) { if (result.stdout && result.stdout.trim()) {
outputPre.textContent = result.stdout; outputPre.textContent = result.stdout;
} else { } else {
outputPre.textContent = '(no output)'; outputPre.textContent = t('execution.no_output');
outputPre.style.fontStyle = 'italic'; outputPre.style.fontStyle = 'italic';
outputPre.style.color = 'var(--text-secondary)'; outputPre.style.color = 'var(--text-secondary)';
} }
@@ -642,11 +643,11 @@ async function _executeScriptDebugWithParams(scriptName, params) {
const title = document.getElementById('executionDialogTitle'); const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus'); const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${scriptName}`; title.textContent = `${t('execution.executing')}: ${scriptName}`;
statusDiv.innerHTML = ` statusDiv.innerHTML = `
<div class="status-item"> <div class="status-item">
<label>Status</label> <label>${escapeHtml(t('execution.status'))}</label>
<value><span class="loading-spinner"></span> Running...</value> <value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
</div> </div>
`; `;
document.getElementById('outputSection').style.display = 'none'; document.getElementById('outputSection').style.display = 'none';
@@ -813,11 +814,11 @@ export async function executeCallbackDebug(callbackName) {
const title = document.getElementById('executionDialogTitle'); const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus'); const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${callbackName}`; title.textContent = `${t('execution.executing')}: ${callbackName}`;
statusDiv.innerHTML = ` statusDiv.innerHTML = `
<div class="status-item"> <div class="status-item">
<label>Status</label> <label>${escapeHtml(t('execution.status'))}</label>
<value><span class="loading-spinner"></span> Running...</value> <value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
</div> </div>
`; `;
document.getElementById('outputSection').style.display = 'none'; document.getElementById('outputSection').style.display = 'none';
@@ -826,7 +827,7 @@ export async function executeCallbackDebug(callbackName) {
dialog.showModal(); dialog.showModal();
try { try {
const response = await fetch(`/api/callbacks/execute/${callbackName}`, { const response = await fetch(`/api/callbacks/execute/${encodeURIComponent(callbackName)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() } headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
}); });
+75 -15
View File
@@ -3,7 +3,7 @@
// ============================================================ // ============================================================
import { import {
dom, t, showToast, setWs, dom, t, setWs, getWs,
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS, WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS, WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
authRequired, showUpdateBanner, authRequired, showUpdateBanner,
@@ -14,8 +14,26 @@ import { loadCallbacksTable } from './callbacks.js';
import { loadHeaderLinks, loadLinksTable } from './links.js'; import { loadHeaderLinks, loadLinksTable } from './links.js';
let reconnectTimeout = null; let reconnectTimeout = null;
let pingInterval = null;
let wsReconnectAttempts = 0; 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 = '') { export function showAuthForm(errorMessage = '') {
const overlay = document.getElementById('auth-overlay'); const overlay = document.getElementById('auth-overlay');
@@ -47,19 +65,24 @@ export function authenticate() {
export function clearToken() { export function clearToken() {
localStorage.removeItem('media_server_token'); localStorage.removeItem('media_server_token');
// Access ws via import const current = getWs();
import('./core.js').then(core => { if (current) {
if (core.ws) { try { current.close(1000, 'token cleared'); } catch { /* ignore */ }
core.ws.close(); }
}
});
showAuthForm(t('auth.cleared')); showAuthForm(t('auth.cleared'));
} }
export function connectWebSocket(token) { export function connectWebSocket(token) {
if (pingInterval) { // Always cancel a pending reconnect first — otherwise a user-triggered
clearInterval(pingInterval); // reconnect can race a scheduled one and create two live sockets.
pingInterval = null; 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:'; 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 wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
const newWs = new WebSocket(wsUrl); const newWs = new WebSocket(wsUrl);
activeSocket = newWs;
setWs(newWs); setWs(newWs);
newWs.onopen = () => { newWs.onopen = () => {
@@ -84,7 +108,13 @@ export function connectWebSocket(token) {
}; };
newWs.onmessage = (event) => { 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') { if (msg.type === 'status' || msg.type === 'status_update') {
updateUI(msg.data); updateUI(msg.data);
@@ -116,6 +146,13 @@ export function connectWebSocket(token) {
updateConnectionStatus(false); updateConnectionStatus(false);
stopPositionInterpolation(); 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) { if (event.code === 4001) {
localStorage.removeItem('media_server_token'); localStorage.removeItem('media_server_token');
showAuthForm(t('auth.invalid')); showAuthForm(t('auth.invalid'));
@@ -133,7 +170,9 @@ export function connectWebSocket(token) {
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false); showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
} }
clearReconnect();
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
reconnectTimeout = null;
const savedToken = localStorage.getItem('media_server_token'); const savedToken = localStorage.getItem('media_server_token');
if (savedToken || !authRequired) { if (savedToken || !authRequired) {
connectWebSocket(savedToken || ''); connectWebSocket(savedToken || '');
@@ -145,9 +184,10 @@ export function connectWebSocket(token) {
} }
}; };
pingInterval = setInterval(() => { clearPing();
if (newWs && newWs.readyState === WebSocket.OPEN) { activePingInterval = setInterval(() => {
newWs.send(JSON.stringify({ type: 'ping' })); if (newWs.readyState === WebSocket.OPEN) {
try { newWs.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
} }
}, WS_PING_INTERVAL_MS); }, WS_PING_INTERVAL_MS);
} }
@@ -182,3 +222,23 @@ export function manualReconnect() {
connectWebSocket(savedToken || ''); 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.color_failed": "Failed to apply color preset",
"display.msg.mode_changed": "Picture mode applied", "display.msg.mode_changed": "Picture mode applied",
"display.msg.mode_failed": "Failed to apply picture mode", "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.title": "Media Browser",
"browser.home": "Home", "browser.home": "Home",
"browser.manage_folders": "Manage Folders", "browser.manage_folders": "Manage Folders",
+12
View File
@@ -174,6 +174,18 @@
"display.msg.color_failed": "Не удалось применить цветовую температуру", "display.msg.color_failed": "Не удалось применить цветовую температуру",
"display.msg.mode_changed": "Режим изображения применён", "display.msg.mode_changed": "Режим изображения применён",
"display.msg.mode_failed": "Не удалось применить режим изображения", "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.title": "Медиа Браузер",
"browser.home": "Главная", "browser.home": "Главная",
"browser.manage_folders": "Управление папками", "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. // 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.addEventListener('install', () => {
self.skipWaiting(); self.skipWaiting();
@@ -9,7 +11,3 @@ self.addEventListener('install', () => {
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim()); event.waitUntil(self.clients.claim());
}); });
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});