fix: comprehensive security, bug, performance, and UI/UX audit
Lint & Test / test (push) Successful in 20s
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) ────────────────── */
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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() }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Управление папками",
|
||||||
|
|||||||
@@ -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));
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user