feat: asset-based image/video sources, notification sounds, UI improvements
Lint & Test / test (push) Has been cancelled

- Replace URL-based image_source/url fields with image_asset_id/video_asset_id
  on StaticImagePictureSource and VideoCaptureSource (clean break, no migration)
- Resolve asset IDs to file paths at runtime via AssetStore.get_file_path()
- Add EntitySelect asset pickers for image/video in stream editor modal
- Add notification sound configuration (global sound + per-app overrides)
- Unify per-app color and sound overrides into single "Per-App Overrides" section
- Persist notification history between server restarts
- Add asset management system (upload, edit, delete, soft-delete)
- Replace emoji buttons with SVG icons throughout UI
- Various backend improvements: SQLite stores, auth, backup, MQTT, webhooks
This commit is contained in:
2026-03-26 20:40:25 +03:00
parent c0853ce184
commit e2e1107df7
100 changed files with 2935 additions and 992 deletions
+5 -1
View File
@@ -1,10 +1,13 @@
"""Atomic file write utilities."""
import json
import logging
import os
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
def atomic_write_json(file_path: Path, data: dict, indent: int = 2) -> None:
"""Write JSON data to file atomically via temp file + rename.
@@ -29,6 +32,7 @@ def atomic_write_json(file_path: Path, data: dict, indent: int = 2) -> None:
# Clean up temp file on any error
try:
os.unlink(tmp_path)
except OSError:
except OSError as e:
logger.debug("Failed to clean up temp file %s: %s", tmp_path, e)
pass
raise
@@ -0,0 +1,54 @@
"""Validation utilities for image sources (URLs and local file paths).
Prevents SSRF via dangerous URL schemes and restricts file path access
to prevent arbitrary file reads through API query parameters.
"""
from pathlib import Path
from urllib.parse import urlparse
from fastapi import HTTPException
# Image file extensions considered safe to serve
_IMAGE_EXTENSIONS = frozenset({
".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp", ".tiff", ".tif", ".ico",
})
def validate_image_url(url: str) -> None:
"""Validate that *url* uses a safe scheme (http/https only).
Blocks ``file://``, ``ftp://``, ``gopher://``, and other dangerous schemes
that could be used for SSRF.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise HTTPException(
status_code=400,
detail=f"Unsupported URL scheme: {parsed.scheme!r}. Only http and https are allowed.",
)
if not parsed.hostname:
raise HTTPException(status_code=400, detail="URL has no hostname")
def validate_image_path(file_path: str | Path) -> Path:
"""Validate a local file path points to a real image file.
Checks:
- The extension is a known image format
- The resolved path does not escape via symlinks to unexpected locations
Returns the resolved Path on success, raises HTTPException on violation.
"""
resolved = Path(file_path).resolve()
suffix = resolved.suffix.lower()
if suffix not in _IMAGE_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Unsupported file type: {suffix!r}. Only image files are allowed.",
)
return resolved
@@ -0,0 +1,126 @@
"""Cross-platform asynchronous sound playback for notification alerts.
Windows: uses winsound.PlaySound (stdlib, no dependencies).
Linux: uses paplay (PulseAudio) or aplay (ALSA) via subprocess.
All playback is fire-and-forget on a background thread. A new notification
sound cancels any currently playing sound to prevent overlap.
"""
import subprocess
import sys
import threading
from pathlib import Path
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Lock + handle for cancelling previous sound
_play_lock = threading.Lock()
_current_process: subprocess.Popen | None = None
def _play_windows(file_path: Path, volume: float) -> None:
"""Play a WAV file on Windows using winsound."""
import winsound
# winsound doesn't support volume control natively,
# but SND_ASYNC plays non-blocking within this thread
try:
winsound.PlaySound(str(file_path), winsound.SND_FILENAME | winsound.SND_ASYNC)
except Exception as e:
logger.error(f"winsound playback failed: {e}")
def _play_linux(file_path: Path, volume: float) -> None:
"""Play a sound file on Linux using paplay or aplay."""
global _current_process
# Cancel previous sound
with _play_lock:
if _current_process is not None:
try:
_current_process.terminate()
except OSError as e:
logger.debug("Failed to terminate previous sound process: %s", e)
pass
_current_process = None
try:
# Try paplay first (PulseAudio/PipeWire) — supports volume
pa_volume = max(0, min(65536, int(volume * 65536)))
proc = subprocess.Popen(
["paplay", f"--volume={pa_volume}", str(file_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
try:
# Fallback to aplay (ALSA) — no volume control
proc = subprocess.Popen(
["aplay", "-q", str(file_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
logger.warning("Neither paplay nor aplay found — cannot play notification sound")
return
with _play_lock:
_current_process = proc
# Wait for completion
proc.wait()
with _play_lock:
if _current_process is proc:
_current_process = None
def play_sound_async(file_path: Path, volume: float = 1.0) -> None:
"""Play a sound file asynchronously (fire-and-forget).
Args:
file_path: Path to the sound file (.wav).
volume: Volume level 0.0-1.0 (best-effort, not all backends support it).
"""
if not file_path.exists():
logger.warning(f"Sound file not found: {file_path}")
return
volume = max(0.0, min(1.0, volume))
if sys.platform == "win32":
player = _play_windows
else:
player = _play_linux
thread = threading.Thread(
target=player,
args=(file_path, volume),
name="sound-player",
daemon=True,
)
thread.start()
def stop_current_sound() -> None:
"""Stop any currently playing notification sound."""
if sys.platform == "win32":
try:
import winsound
winsound.PlaySound(None, winsound.SND_PURGE)
except Exception as e:
logger.debug("Failed to stop winsound playback: %s", e)
pass
else:
with _play_lock:
global _current_process
if _current_process is not None:
try:
_current_process.terminate()
except OSError as e:
logger.debug("Failed to terminate sound process: %s", e)
pass
_current_process = None