feat: asset-based image/video sources, notification sounds, UI improvements
Lint & Test / test (push) Has been cancelled
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:
@@ -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
|
||||
Reference in New Issue
Block a user