bcc6d40ed7
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
230 lines
7.7 KiB
Python
230 lines
7.7 KiB
Python
"""Thread-safe configuration file manager for runtime updates."""
|
|
|
|
import logging
|
|
import os
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
import yaml
|
|
|
|
from .config import (
|
|
CallbackConfig,
|
|
LinkConfig,
|
|
MediaFolderConfig,
|
|
ScriptConfig,
|
|
_restrict_config_perms,
|
|
_write_yaml_atomic,
|
|
settings,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ConfigManager:
|
|
"""Thread-safe configuration file manager.
|
|
|
|
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):
|
|
self._lock = threading.Lock()
|
|
self._config_path = config_path or self._find_config_path()
|
|
logger.info(f"ConfigManager initialized with path: {self._config_path}")
|
|
|
|
@staticmethod
|
|
def _find_config_path() -> Path:
|
|
"""Find the active config file path (or the default if none exists yet)."""
|
|
search_paths = [Path("config.yaml"), Path("config.yml")]
|
|
|
|
if os.name == "nt":
|
|
appdata = os.environ.get("APPDATA", "")
|
|
if appdata:
|
|
search_paths.append(Path(appdata) / "media-server" / "config.yaml")
|
|
else:
|
|
search_paths.append(Path.home() / ".config" / "media-server" / "config.yaml")
|
|
search_paths.append(Path("/etc/media-server/config.yaml"))
|
|
|
|
for search_path in search_paths:
|
|
if search_path.exists():
|
|
return search_path
|
|
|
|
if os.name == "nt":
|
|
default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml"
|
|
else:
|
|
default_path = Path.home() / ".config" / "media-server" / "config.yaml"
|
|
|
|
logger.warning(f"No config file found, using default path: {default_path}")
|
|
return default_path
|
|
|
|
def _load(self) -> dict[str, Any]:
|
|
"""Read the config YAML, returning an empty dict if the file is missing."""
|
|
if not self._config_path.exists():
|
|
return {}
|
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
|
return yaml.safe_load(f) or {}
|
|
|
|
def _save(self, data: dict[str, Any]) -> None:
|
|
"""Atomically write the config YAML and lock down its permissions."""
|
|
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
_write_yaml_atomic(self._config_path, data)
|
|
_restrict_config_perms(self._config_path)
|
|
|
|
# --- Generic per-section CRUD --------------------------------------
|
|
|
|
def _upsert(
|
|
self,
|
|
section: str,
|
|
key: str,
|
|
value: Any,
|
|
*,
|
|
require_absent: bool = False,
|
|
require_present: bool = False,
|
|
in_memory_target: dict[str, Any] | None = None,
|
|
verb: str = "set",
|
|
) -> None:
|
|
with self._lock:
|
|
data = self._load()
|
|
existing = data.get(section, {})
|
|
if require_absent and key in existing:
|
|
raise ValueError(f"{section[:-1].title()} '{key}' already exists")
|
|
if require_present and (not isinstance(existing, dict) or key not in existing):
|
|
raise ValueError(f"{section[:-1].title()} '{key}' does not exist")
|
|
|
|
if not isinstance(existing, dict):
|
|
existing = {}
|
|
existing[key] = value.model_dump(exclude_none=True)
|
|
data[section] = existing
|
|
|
|
self._save(data)
|
|
|
|
if in_memory_target is not None:
|
|
in_memory_target[key] = value
|
|
logger.info(f"{section[:-1].title()} '{key}' {verb} in config")
|
|
|
|
def _delete(
|
|
self,
|
|
section: str,
|
|
key: str,
|
|
*,
|
|
in_memory_target: dict[str, Any] | None = None,
|
|
) -> None:
|
|
with self._lock:
|
|
data = self._load()
|
|
existing = data.get(section, {})
|
|
if not isinstance(existing, dict) or key not in existing:
|
|
raise ValueError(f"{section[:-1].title()} '{key}' does not exist")
|
|
del existing[key]
|
|
data[section] = existing
|
|
|
|
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:
|
|
self._upsert(
|
|
"scripts", name, config,
|
|
require_present=True,
|
|
in_memory_target=settings.scripts,
|
|
verb="updated",
|
|
)
|
|
|
|
def delete_script(self, name: str) -> None:
|
|
self._delete("scripts", name, in_memory_target=settings.scripts)
|
|
|
|
# --- Callbacks -----------------------------------------------------
|
|
|
|
def add_callback(self, name: str, config: CallbackConfig) -> None:
|
|
self._upsert(
|
|
"callbacks", name, config,
|
|
require_absent=True,
|
|
in_memory_target=settings.callbacks,
|
|
verb="added",
|
|
)
|
|
|
|
def update_callback(self, name: str, config: CallbackConfig) -> None:
|
|
self._upsert(
|
|
"callbacks", name, config,
|
|
require_present=True,
|
|
in_memory_target=settings.callbacks,
|
|
verb="updated",
|
|
)
|
|
|
|
def delete_callback(self, name: str) -> None:
|
|
self._delete("callbacks", name, in_memory_target=settings.callbacks)
|
|
|
|
# --- Media folders -------------------------------------------------
|
|
|
|
def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
|
self._upsert(
|
|
"media_folders", folder_id, config,
|
|
require_absent=True,
|
|
in_memory_target=settings.media_folders,
|
|
verb="added",
|
|
)
|
|
|
|
def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
|
self._upsert(
|
|
"media_folders", folder_id, config,
|
|
require_present=True,
|
|
in_memory_target=settings.media_folders,
|
|
verb="updated",
|
|
)
|
|
|
|
def delete_media_folder(self, folder_id: str) -> None:
|
|
self._delete("media_folders", folder_id, in_memory_target=settings.media_folders)
|
|
|
|
# --- Links ---------------------------------------------------------
|
|
|
|
def add_link(self, name: str, config: LinkConfig) -> None:
|
|
self._upsert(
|
|
"links", name, config,
|
|
require_absent=True,
|
|
in_memory_target=settings.links,
|
|
verb="added",
|
|
)
|
|
|
|
def update_link(self, name: str, config: LinkConfig) -> None:
|
|
self._upsert(
|
|
"links", name, config,
|
|
require_present=True,
|
|
in_memory_target=settings.links,
|
|
verb="updated",
|
|
)
|
|
|
|
def delete_link(self, name: str) -> None:
|
|
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:
|
|
data = self._load()
|
|
if value is None:
|
|
data.pop(key, None)
|
|
else:
|
|
data[key] = value
|
|
self._save(data)
|
|
if hasattr(settings, key):
|
|
setattr(settings, key, value)
|
|
logger.info("Setting '%s' updated to: %s", key, value)
|
|
|
|
|
|
config_manager = ConfigManager()
|