feat: asset-based image/video sources, notification sounds, UI improvements
Some checks failed
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

View File

@@ -1,118 +0,0 @@
# Feature Brainstorm — LED Grab
## New Automation Conditions (Profiles)
Right now profiles only trigger on **app detection**. High-value additions:
- **Time-of-day / Schedule** — "warm tones after sunset, off at midnight." Schedule-based value sources pattern already exists
- **Display state** — detect monitor on/off/sleep, auto-stop targets when display is off
- **System idle** — dim or switch to ambient effect after N minutes of no input
- **Sunrise/sunset** — fetch local solar times, drive circadian color temperature shifts
- **Webhook/MQTT trigger** — let external systems activate profiles without HA integration
## New Output Targets
Currently: WLED, Adalight, AmbileD, DDP. Potential:
- **MQTT publish** — generic IoT output, any MQTT subscriber becomes a target
- **Art-Net / sACN (E1.31)** — stage/theatrical lighting protocols, DMX controllers
- **OpenRGB** — control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
- **HTTP webhook** — POST color data to arbitrary endpoints
- **Recording target** — save color streams to file for playback later
## New Color Strip Sources
- **Spotify / media player** — album art color extraction or tempo-synced effects
- **Weather** — pull conditions from API, map to palettes (blue=rain, orange=sun, white=snow)
- **Camera / webcam** — border-sampling from camera feed for video calls or room-reactive lighting
- **Script source** — user-written JS/Python snippets producing color arrays per frame
- **Notification reactive** — flash/pulse on OS notifications (optional app filter)
## Processing Pipeline Extensions
- **Palette quantization** — force output to match a user-defined palette
- **Zone grouping** — merge adjacent LEDs into logical groups sharing one averaged color
- **Color temperature filter** — warm/cool shift separate from hue shift (circadian/mood)
- **Noise gate** — suppress small color changes below threshold, preventing shimmer on static content
## Multi-Instance & Sync
- **Multi-room sync** — multiple instances with shared clock for synchronized effects
- **Multi-display unification** — treat 2-3 monitors as single virtual display for seamless ambilight
- **Leader/follower mode** — one target's output drives others with optional delay (cascade)
## UX & Dashboard
- **PWA / mobile layout** — mobile-first layout + "Add to Home Screen" manifest
- **Scene presets** — bundled source + filters + brightness as one-click presets ("Movie night", "Gaming")
- **Live preview on dashboard** — miniature screen with LED colors rendered around its border
- **Undo/redo for calibration** — reduce frustration in the fiddly calibration editor
- **Drag-and-drop filter ordering** — reorder postprocessing filter chains visually
## API & Integration
- **WebSocket event bus** — broadcast all state changes over a single WS channel
- **OBS integration** — detect active scene, switch profiles; or use OBS virtual camera as source
- **Plugin system** — formalize extension points into documented plugin API with hot-reload
## Creative / Fun
- **Effect sequencer** — timeline-based choreography of effects, colors, and transitions
- **Music BPM sync** — lock effect speed to detected BPM (beat detection already exists)
- **Color extraction from image** — upload photo, extract palette, use as gradient/cycle source
- **Transition effects** — crossfade, wipe, or dissolve between sources/profiles instead of instant cut
---
## Deep Dive: Notification Reactive Source
**Type:** New `ColorStripSource` (`source_type: "notification"`) — normally outputs transparent RGBA, flashes on notification events. Designed to be used as a layer in a **composite source** so it overlays on top of a persistent base (gradient, effect, screen capture, etc.).
### Trigger modes (both active simultaneously)
1. **OS listener (Windows)**`pywinrt` + `Windows.UI.Notifications.Management.UserNotificationListener`. Runs in background thread, pushes events to source via queue. Windows-only for now; macOS (`pyobjc` + `NSUserNotificationCenter`) and Linux (`dbus` + `org.freedesktop.Notifications`) deferred to future.
2. **Webhook**`POST /api/v1/notifications/{source_id}/fire` with optional body `{ "app": "MyApp", "color": "#FF0000" }`. Always available, cross-platform by nature.
### Source config
```yaml
os_listener: true # enable Windows notification listener
app_filter:
mode: whitelist|blacklist # which apps to react to
apps: [Discord, Slack, Telegram]
app_colors: # user-configured app → color mapping
Discord: "#5865F2"
Slack: "#4A154B"
Telegram: "#26A5E4"
default_color: "#FFFFFF" # fallback when app has no mapping
effect: flash|pulse|sweep # visual effect type
duration_ms: 1500 # effect duration
```
### Effect rendering
Source outputs RGBA color array per frame:
- **Idle**: all pixels `(0,0,0,0)` — composite passes through base layer
- **Flash**: instant full-color, linear fade to transparent over `duration_ms`
- **Pulse**: sine fade in/out over `duration_ms`
- **Sweep**: color travels across the strip like a wave
Each notification starts its own mini-timeline from trigger timestamp (not sync clock).
### Overlap handling
New notification while previous effect is active → restart timer with new color. No queuing.
### App color resolution
1. Webhook body `color` field (explicit override) → highest priority
2. `app_colors` mapping by app name
3. `default_color` fallback
---
## Top Picks (impact vs effort)
1. **Time-of-day + idle profile conditions** — builds on existing profile/condition architecture
2. **MQTT output target** — opens the door to an enormous IoT ecosystem
3. **Scene presets** — purely frontend, bundles existing features into one-click UX

View File

@@ -121,6 +121,26 @@ Open **http://localhost:8080** to access the dashboard.
See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and Home Assistant setup. See [INSTALLATION.md](INSTALLATION.md) for the full installation guide, including configuration, Docker manual builds, and Home Assistant setup.
## Demo Mode
Demo mode runs the server with virtual devices, sample data, and isolated storage — useful for exploring the UI without real hardware.
Set the `WLED_DEMO` environment variable to `true`, `1`, or `yes`:
```bash
# Docker
docker compose run -e WLED_DEMO=true server
# Python
WLED_DEMO=true uvicorn wled_controller.main:app --host 0.0.0.0 --port 8081
# Windows (installed app)
set WLED_DEMO=true
LedGrab.bat
```
Demo mode uses port **8081**, config file `config/demo_config.yaml`, and stores data in `data/demo/` (separate from production data). It can run alongside the main server.
## Architecture ## Architecture
```text ```text

37
TODO.md
View File

@@ -1,37 +0,0 @@
# Build Size Reduction
## Phase 1: Quick Wins (build scripts)
- [x] Strip unused NumPy submodules (polynomial, linalg, ma, lib, distutils)
- [x] Strip debug symbols from .pyd/.dll/.so files
- [x] Remove zeroconf service database
- [x] Remove .py source from site-packages after compiling to .pyc
- [x] Strip unused PIL image plugins (keep JPEG/PNG/ICO/BMP for tray)
## Phase 2: Replace Pillow with cv2
- [x] Create `utils/image_codec.py` with cv2-based image helpers
- [x] Replace PIL in `_preview_helpers.py`
- [x] Replace PIL in `picture_sources.py`
- [x] Replace PIL in `color_strip_sources.py`
- [x] Replace PIL in `templates.py`
- [x] Replace PIL in `postprocessing.py`
- [x] Replace PIL in `output_targets_keycolors.py`
- [x] Replace PIL in `kc_target_processor.py`
- [x] Replace PIL in `pixelate.py` filter
- [x] Replace PIL in `downscaler.py` filter
- [x] Replace PIL in `scrcpy_engine.py`
- [x] Replace PIL in `live_stream_manager.py`
- [x] Move Pillow from core deps to [tray] optional in pyproject.toml
- [x] Make PIL import conditional in `tray.py`
- [x] Move opencv-python-headless to core dependencies
## Phase 4: OpenCV stripping (build scripts)
- [x] Strip ffmpeg DLL, Haar cascades, dev files (already existed)
- [x] Strip typing stubs (already existed)
## Verification
- [x] Lint: `ruff check src/ tests/ --fix`
- [x] Tests: 341 passed

View File

@@ -1,143 +0,0 @@
# Auto-Update Plan — Phase 1: Check & Notify
> Created: 2026-03-25. Status: **planned, not started.**
## Backend Architecture
### Release Provider Abstraction
```
core/update/
release_provider.py — ABC: get_releases(), get_releases_page_url()
gitea_provider.py — Gitea REST API implementation
version_check.py — normalize_version(), is_newer() using packaging.version
update_service.py — Background asyncio task + state machine
```
**`ReleaseProvider` interface** — two methods:
- `get_releases(limit) → list[ReleaseInfo]` — fetch releases (newest first)
- `get_releases_page_url() → str` — link for "view on web"
**`GiteaReleaseProvider`** calls `GET {base_url}/api/v1/repos/{repo}/releases`. Swapping to GitHub later means implementing the same interface against `api.github.com`.
**Data models:**
```python
@dataclass(frozen=True)
class AssetInfo:
name: str # "LedGrab-v0.3.0-win-x64.zip"
size: int # bytes
download_url: str
@dataclass(frozen=True)
class ReleaseInfo:
tag: str # "v0.3.0"
version: str # "0.3.0"
name: str # "LedGrab v0.3.0"
body: str # release notes markdown
prerelease: bool
published_at: str # ISO 8601
assets: tuple[AssetInfo, ...]
```
### Version Comparison
`version_check.py` — normalize Gitea tags to PEP 440:
- `v0.3.0-alpha.1``0.3.0a1`
- `v0.3.0-beta.2``0.3.0b2`
- `v0.3.0-rc.3``0.3.0rc3`
Uses `packaging.version.Version` for comparison.
### Update Service
Follows the **AutoBackupEngine pattern**:
- Settings in `Database.get_setting("auto_update")`
- asyncio.Task for periodic checks
- 30s startup delay (avoid slowing boot)
- 60s debounce on manual checks
**State machine (Phase 1):** `IDLE → CHECKING → UPDATE_AVAILABLE`
No download/apply in Phase 1 — just detection and notification.
**Settings:** `enabled` (bool), `check_interval_hours` (float), `channel` ("stable" | "pre-release")
**Persisted state:** `dismissed_version`, `last_check` (survives restarts)
### API Endpoints
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/v1/system/update/status` | Current state + available version |
| `POST` | `/api/v1/system/update/check` | Trigger immediate check |
| `POST` | `/api/v1/system/update/dismiss` | Dismiss notification for current version |
| `GET` | `/api/v1/system/update/settings` | Get settings |
| `PUT` | `/api/v1/system/update/settings` | Update settings |
### Wiring
- New `get_update_service()` in `dependencies.py`
- `UpdateService` created in `main.py` lifespan, `start()`/`stop()` alongside other engines
- Router registered in `api/__init__.py`
- WebSocket event: `update_available` fired via `processor_manager.fire_event()`
## Frontend
### Version badge highlight
The existing `#server-version` pill in the header gets a CSS class `has-update` when a newer version exists — changes the background to `var(--warning-color)` with a subtle pulse, making it clickable to open the update panel in settings.
### Notification popup
On `server:update_available` WebSocket event (and on page load if status says `has_update` and not dismissed):
- A **persistent dismissible banner** slides in below the header (not the ephemeral 3s toast)
- Shows: "Version {x.y.z} is available" + [View Release Notes] + [Dismiss]
- Dismiss calls `POST /dismiss` and hides the bar for that version
- Stored in `localStorage` so it doesn't re-show after page refresh for dismissed versions
### Settings tab: "Updates"
New 5th tab in the settings modal:
- Current version display
- "Check for updates" button + spinner
- Channel selector (stable / pre-release) via IconSelect
- Auto-check toggle + interval selector
- When update available: release name, notes preview, link to releases page
### i18n keys
New `update.*` keys in `en.json`, `ru.json`, `zh.json` for all labels.
## Files to Create
| File | Purpose |
|------|---------|
| `core/update/__init__.py` | Package init |
| `core/update/release_provider.py` | Abstract provider interface + data models |
| `core/update/gitea_provider.py` | Gitea API implementation |
| `core/update/version_check.py` | Semver normalization + comparison |
| `core/update/update_service.py` | Background service + state machine |
| `api/routes/update.py` | REST endpoints |
| `api/schemas/update.py` | Pydantic request/response models |
## Files to Modify
| File | Change |
|------|--------|
| `api/__init__.py` | Register update router |
| `api/dependencies.py` | Add `get_update_service()` |
| `main.py` | Create & start/stop UpdateService in lifespan |
| `templates/modals/settings.html` | Add Updates tab |
| `static/js/features/settings.ts` | Update check/settings UI logic |
| `static/js/core/api.ts` | Version badge highlight on health check |
| `static/css/layout.css` | `.has-update` styles for version badge |
| `static/locales/en.json` | i18n keys |
| `static/locales/ru.json` | i18n keys |
| `static/locales/zh.json` | i18n keys |
## Future Phases (not in scope)
- **Phase 2**: Download & stage artifacts
- **Phase 3**: Apply update & restart (external updater script, NSIS silent mode)
- **Phase 4**: Checksums, "What's new" dialog, update history

View File

@@ -175,7 +175,7 @@ When adding **new tabs, sections, or major UI elements**, update the correspondi
When you need a new icon: When you need a new icon:
1. Find the Lucide icon at https://lucide.dev 1. Find the Lucide icon at https://lucide.dev
2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.js` as a new export 2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.ts` as a new export
3. Add a corresponding `ICON_*` constant in `icons.ts` using `_svg(P.myIcon)` 3. Add a corresponding `ICON_*` constant in `icons.ts` using `_svg(P.myIcon)`
4. Import and use the constant in your feature module 4. Import and use the constant in your feature module
@@ -213,7 +213,19 @@ Static HTML using `data-i18n` attributes is handled automatically by the i18n sy
- `fetchWithAuth('/devices/dev_123', { method: 'DELETE' })` → `DELETE /api/v1/devices/dev_123` - `fetchWithAuth('/devices/dev_123', { method: 'DELETE' })` → `DELETE /api/v1/devices/dev_123`
- Passing `/api/v1/gradients` results in **double prefix**: `/api/v1/api/v1/gradients` (404) - Passing `/api/v1/gradients` results in **double prefix**: `/api/v1/api/v1/gradients` (404)
For raw `fetch()` without auth (rare), use the full path manually. **NEVER use raw `fetch()` or `new Audio(url)` / `new Image()` for authenticated endpoints.** These bypass the auth token and will fail with 401. Always use `fetchWithAuth()` and convert to blob URLs when needed (e.g. for `<audio>`, `<img>`, or download links):
```typescript
// WRONG: no auth header — 401
const audio = new Audio(`${API_BASE}/assets/${id}/file`);
// CORRECT: fetch with auth, then create blob URL
const res = await fetchWithAuth(`/assets/${id}/file`);
const blob = await res.blob();
const audio = new Audio(URL.createObjectURL(blob));
```
The only exception is raw `fetch()` for multipart uploads where you must set `Content-Type` to let the browser handle the boundary — but still use `getHeaders()` for the auth token.
## Bundling & Development Workflow ## Bundling & Development Workflow
@@ -266,11 +278,11 @@ See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, bro
### Uptime / duration values ### Uptime / duration values
Use `formatUptime(seconds)` from `core/ui.js`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`. Use `formatUptime(seconds)` from `core/ui.ts`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.
### Large numbers ### Large numbers
Use `formatCompact(n)` from `core/ui.js`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail. Use `formatCompact(n)` from `core/ui.ts`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
### Preventing layout shift ### Preventing layout shift

View File

@@ -11,7 +11,9 @@ Two independent server modes with separate configs, ports, and data directories:
| **Real** | `python -m wled_controller` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` | | **Real** | `python -m wled_controller` | `config/default_config.yaml` | 8080 | `development-key-change-in-production` | `data/` |
| **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` | | **Demo** | `python -m wled_controller.demo` | `config/demo_config.yaml` | 8081 | `demo` | `data/demo/` |
Both can run simultaneously on different ports. Demo mode can also be triggered via the `WLED_DEMO` environment variable (`true`, `1`, or `yes`). This works with any launch method — Python, Docker (`-e WLED_DEMO=true`), or the installed app (`set WLED_DEMO=true` before `LedGrab.bat`).
Both modes can run simultaneously on different ports.
## Restart Procedure ## Restart Procedure

View File

@@ -7,7 +7,7 @@
- `src/wled_controller/api/schemas/` — Pydantic request/response models (one file per entity) - `src/wled_controller/api/schemas/` — Pydantic request/response models (one file per entity)
- `src/wled_controller/core/` — Core business logic (capture, devices, audio, processing, automations) - `src/wled_controller/core/` — Core business logic (capture, devices, audio, processing, automations)
- `src/wled_controller/storage/` — Data models (dataclasses) and JSON persistence stores - `src/wled_controller/storage/` — Data models (dataclasses) and JSON persistence stores
- `src/wled_controller/utils/` — Utility functions (logging, monitor detection) - `src/wled_controller/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback)
- `src/wled_controller/static/` — Frontend files (TypeScript, CSS, locales) - `src/wled_controller/static/` — Frontend files (TypeScript, CSS, locales)
- `src/wled_controller/templates/` — Jinja2 HTML templates - `src/wled_controller/templates/` — Jinja2 HTML templates
- `config/` — Configuration files (YAML) - `config/` — Configuration files (YAML)

View File

@@ -8,7 +8,7 @@ The server component provides:
- 🎯 **Real-time Screen Capture** - Multi-monitor support with configurable FPS - 🎯 **Real-time Screen Capture** - Multi-monitor support with configurable FPS
- 🎨 **Advanced Processing** - Border pixel extraction with color correction - 🎨 **Advanced Processing** - Border pixel extraction with color correction
- 🔧 **Flexible Calibration** - Map screen edges to any LED layout - 🔧 **Flexible Calibration** - Map screen edges to any LED layout
- 🌐 **REST API** - Complete control via 17 REST endpoints - 🌐 **REST API** - Complete control via 25+ REST endpoints
- 💾 **Persistent Storage** - JSON-based device and configuration management - 💾 **Persistent Storage** - JSON-based device and configuration management
- 📊 **Metrics & Monitoring** - Real-time FPS, status, and performance data - 📊 **Metrics & Monitoring** - Real-time FPS, status, and performance data

View File

@@ -26,6 +26,7 @@ from .routes.color_strip_processing import router as cspt_router
from .routes.gradients import router as gradients_router from .routes.gradients import router as gradients_router
from .routes.weather_sources import router as weather_sources_router from .routes.weather_sources import router as weather_sources_router
from .routes.update import router as update_router from .routes.update import router as update_router
from .routes.assets import router as assets_router
router = APIRouter() router = APIRouter()
router.include_router(system_router) router.include_router(system_router)
@@ -52,5 +53,6 @@ router.include_router(cspt_router)
router.include_router(gradients_router) router.include_router(gradients_router)
router.include_router(weather_sources_router) router.include_router(weather_sources_router)
router.include_router(update_router) router.include_router(update_router)
router.include_router(assets_router)
__all__ = ["router"] __all__ = ["router"]

View File

@@ -24,6 +24,7 @@ from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.storage.gradient_store import GradientStore from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.weather_source_store import WeatherSourceStore from wled_controller.storage.weather_source_store import WeatherSourceStore
from wled_controller.storage.asset_store import AssetStore
from wled_controller.core.automations.automation_engine import AutomationEngine from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.core.weather.weather_manager import WeatherManager from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.core.backup.auto_backup import AutoBackupEngine
@@ -131,6 +132,10 @@ def get_weather_manager() -> WeatherManager:
return _get("weather_manager", "Weather manager") return _get("weather_manager", "Weather manager")
def get_asset_store() -> AssetStore:
return _get("asset_store", "Asset store")
def get_database() -> Database: def get_database() -> Database:
return _get("database", "Database") return _get("database", "Database")
@@ -187,6 +192,7 @@ def init_dependencies(
weather_source_store: WeatherSourceStore | None = None, weather_source_store: WeatherSourceStore | None = None,
weather_manager: WeatherManager | None = None, weather_manager: WeatherManager | None = None,
update_service: UpdateService | None = None, update_service: UpdateService | None = None,
asset_store: AssetStore | None = None,
): ):
"""Initialize global dependencies.""" """Initialize global dependencies."""
_deps.update({ _deps.update({
@@ -213,4 +219,5 @@ def init_dependencies(
"weather_source_store": weather_source_store, "weather_source_store": weather_source_store,
"weather_manager": weather_manager, "weather_manager": weather_manager,
"update_service": update_service, "update_service": update_service,
"asset_store": asset_store,
}) })

View File

@@ -123,7 +123,8 @@ async def stream_capture_test(
if stream: if stream:
try: try:
stream.cleanup() stream.cleanup()
except Exception: except Exception as e:
logger.debug("Capture stream cleanup error: %s", e)
pass pass
done_event.set() done_event.set()
@@ -210,8 +211,9 @@ async def stream_capture_test(
"avg_capture_ms": round(avg_ms, 1), "avg_capture_ms": round(avg_ms, 1),
}) })
except Exception: except Exception as e:
# WebSocket disconnect or send error — signal capture thread to stop # WebSocket disconnect or send error — signal capture thread to stop
logger.debug("Capture preview WS error, stopping capture thread: %s", e)
stop_event.set() stop_event.set()
await capture_future await capture_future
raise raise

View File

@@ -0,0 +1,226 @@
"""Asset routes: CRUD, file upload/download, prebuilt restore."""
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import fire_entity_event, get_asset_store
from wled_controller.api.schemas.assets import (
AssetListResponse,
AssetResponse,
AssetUpdate,
)
from wled_controller.config import get_config
from wled_controller.storage.asset_store import AssetStore
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# Prebuilt sounds directory (shipped with the app)
_PREBUILT_SOUNDS_DIR = Path(__file__).resolve().parents[2] / "data" / "prebuilt_sounds"
def _asset_to_response(asset) -> AssetResponse:
d = asset.to_dict()
return AssetResponse(**d)
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/assets",
response_model=AssetListResponse,
tags=["Assets"],
)
async def list_assets(
_: AuthRequired,
asset_type: str | None = Query(None, description="Filter by type: sound, image, video, other"),
store: AssetStore = Depends(get_asset_store),
):
"""List all assets, optionally filtered by type."""
if asset_type:
assets = store.get_assets_by_type(asset_type)
else:
assets = store.get_visible_assets()
return AssetListResponse(
assets=[_asset_to_response(a) for a in assets],
count=len(assets),
)
@router.get(
"/api/v1/assets/{asset_id}",
response_model=AssetResponse,
tags=["Assets"],
)
async def get_asset(
asset_id: str,
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Get asset metadata by ID."""
try:
asset = store.get_asset(asset_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
if asset.deleted:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
return _asset_to_response(asset)
@router.post(
"/api/v1/assets",
response_model=AssetResponse,
status_code=201,
tags=["Assets"],
)
async def upload_asset(
_: AuthRequired,
file: UploadFile = File(...),
name: str | None = Query(None, description="Display name (defaults to filename)"),
description: str | None = Query(None, description="Optional description"),
store: AssetStore = Depends(get_asset_store),
):
"""Upload a new asset file."""
config = get_config()
max_size = getattr(getattr(config, "assets", None), "max_file_size_mb", 50) * 1024 * 1024
data = await file.read()
if len(data) > max_size:
raise HTTPException(
status_code=400,
detail=f"File too large (max {max_size // (1024 * 1024)} MB)",
)
if not data:
raise HTTPException(status_code=400, detail="Empty file")
display_name = name or Path(file.filename or "unnamed").stem.replace("_", " ").replace("-", " ").title()
try:
asset = store.create_asset(
name=display_name,
filename=file.filename or "unnamed",
file_data=data,
mime_type=file.content_type,
description=description,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("asset", "created", asset.id)
return _asset_to_response(asset)
@router.put(
"/api/v1/assets/{asset_id}",
response_model=AssetResponse,
tags=["Assets"],
)
async def update_asset(
asset_id: str,
body: AssetUpdate,
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Update asset metadata."""
try:
asset = store.update_asset(
asset_id,
name=body.name,
description=body.description,
tags=body.tags,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("asset", "updated", asset.id)
return _asset_to_response(asset)
@router.delete(
"/api/v1/assets/{asset_id}",
tags=["Assets"],
)
async def delete_asset(
asset_id: str,
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Delete an asset. Prebuilt assets are soft-deleted and can be restored."""
try:
asset = store.get_asset(asset_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
store.delete_asset(asset_id)
fire_entity_event("asset", "deleted", asset_id)
return {
"status": "deleted",
"id": asset_id,
"restorable": asset.prebuilt,
}
# ---------------------------------------------------------------------------
# File serving
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/assets/{asset_id}/file",
tags=["Assets"],
)
async def serve_asset_file(
asset_id: str,
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Serve the actual asset file for playback/display."""
file_path = store.get_file_path(asset_id)
if file_path is None:
raise HTTPException(status_code=404, detail=f"Asset file not found: {asset_id}")
try:
asset = store.get_asset(asset_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
return FileResponse(
path=str(file_path),
media_type=asset.mime_type,
filename=asset.filename,
)
# ---------------------------------------------------------------------------
# Prebuilt restore
# ---------------------------------------------------------------------------
@router.post(
"/api/v1/assets/restore-prebuilt",
tags=["Assets"],
)
async def restore_prebuilt_assets(
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Re-import any deleted prebuilt assets."""
restored = store.restore_prebuilt(_PREBUILT_SOUNDS_DIR)
return {
"status": "ok",
"restored_count": len(restored),
"restored": [_asset_to_response(a) for a in restored],
}

View File

@@ -221,7 +221,8 @@ async def test_audio_source_ws(
template = template_store.get_template(audio_template_id) template = template_store.get_template(audio_template_id)
engine_type = template.engine_type engine_type = template.engine_type
engine_config = template.engine_config engine_config = template.engine_config
except ValueError: except ValueError as e:
logger.debug("Audio template not found, falling back to best available engine: %s", e)
pass # Fall back to best available engine pass # Fall back to best available engine
# Acquire shared audio stream # Acquire shared audio stream
@@ -268,6 +269,7 @@ async def test_audio_source_ws(
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
except WebSocketDisconnect: except WebSocketDisconnect:
logger.debug("Audio test WebSocket disconnected for source %s", source_id)
pass pass
except Exception as e: except Exception as e:
logger.error(f"Audio test WebSocket error for {source_id}: {e}") logger.error(f"Audio test WebSocket error for {source_id}: {e}")

View File

@@ -46,8 +46,8 @@ async def list_audio_templates(
] ]
return AudioTemplateListResponse(templates=responses, count=len(responses)) return AudioTemplateListResponse(templates=responses, count=len(responses))
except Exception as e: except Exception as e:
logger.error(f"Failed to list audio templates: {e}") logger.error("Failed to list audio templates: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/audio-templates", response_model=AudioTemplateResponse, tags=["Audio Templates"], status_code=201) @router.post("/api/v1/audio-templates", response_model=AudioTemplateResponse, tags=["Audio Templates"], status_code=201)
@@ -76,8 +76,8 @@ async def create_audio_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to create audio template: {e}") logger.error("Failed to create audio template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"]) @router.get("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
@@ -126,8 +126,8 @@ async def update_audio_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to update audio template: {e}") logger.error("Failed to update audio template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/audio-templates/{template_id}", status_code=204, tags=["Audio Templates"]) @router.delete("/api/v1/audio-templates/{template_id}", status_code=204, tags=["Audio Templates"])
@@ -149,8 +149,8 @@ async def delete_audio_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to delete audio template: {e}") logger.error("Failed to delete audio template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
# ===== AUDIO ENGINE ENDPOINTS ===== # ===== AUDIO ENGINE ENDPOINTS =====
@@ -175,8 +175,8 @@ async def list_audio_engines(_auth: AuthRequired):
return AudioEngineListResponse(engines=engines, count=len(engines)) return AudioEngineListResponse(engines=engines, count=len(engines))
except Exception as e: except Exception as e:
logger.error(f"Failed to list audio engines: {e}") logger.error("Failed to list audio engines: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
# ===== REAL-TIME AUDIO TEMPLATE TEST WEBSOCKET ===== # ===== REAL-TIME AUDIO TEMPLATE TEST WEBSOCKET =====
@@ -237,6 +237,7 @@ async def test_audio_template_ws(
}) })
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
except WebSocketDisconnect: except WebSocketDisconnect:
logger.debug("Audio template test WebSocket disconnected")
pass pass
except Exception as e: except Exception as e:
logger.error(f"Audio template test WS error: {e}") logger.error(f"Audio template test WS error: {e}")

View File

@@ -1,6 +1,7 @@
"""System routes: backup, restore, auto-backup. """System routes: backup, restore, auto-backup.
All backups are SQLite database snapshots (.db files). Backups are ZIP files containing a SQLite database snapshot (.db)
and any uploaded asset files from data/assets/.
""" """
import asyncio import asyncio
@@ -8,13 +9,14 @@ import io
import subprocess import subprocess
import sys import sys
import threading import threading
import zipfile
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_auto_backup_engine, get_database from wled_controller.api.dependencies import get_asset_store, get_auto_backup_engine, get_database
from wled_controller.api.schemas.system import ( from wled_controller.api.schemas.system import (
AutoBackupSettings, AutoBackupSettings,
AutoBackupStatusResponse, AutoBackupStatusResponse,
@@ -22,7 +24,9 @@ from wled_controller.api.schemas.system import (
BackupListResponse, BackupListResponse,
RestoreResponse, RestoreResponse,
) )
from wled_controller.config import get_config
from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.storage.asset_store import AssetStore
from wled_controller.storage.database import Database, freeze_writes from wled_controller.storage.database import Database, freeze_writes
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -60,25 +64,43 @@ def _schedule_restart() -> None:
@router.get("/api/v1/system/backup", tags=["System"]) @router.get("/api/v1/system/backup", tags=["System"])
def backup_config(_: AuthRequired, db: Database = Depends(get_database)): def backup_config(
"""Download a full database backup as a .db file.""" _: AuthRequired,
db: Database = Depends(get_database),
asset_store: AssetStore = Depends(get_asset_store),
):
"""Download a full backup as a .zip containing the database and asset files."""
import tempfile import tempfile
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp_path = Path(tmp.name) tmp_path = Path(tmp.name)
try: try:
db.backup_to(tmp_path) db.backup_to(tmp_path)
content = tmp_path.read_bytes() db_content = tmp_path.read_bytes()
finally: finally:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
# Build ZIP: database + asset files
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("ledgrab.db", db_content)
# Include all asset files
assets_dir = Path(get_config().assets.assets_dir)
if assets_dir.exists():
for asset_file in assets_dir.iterdir():
if asset_file.is_file():
zf.write(asset_file, f"assets/{asset_file.name}")
zip_buffer.seek(0)
from datetime import datetime, timezone from datetime import datetime, timezone
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S") timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.db" filename = f"ledgrab-backup-{timestamp}.zip"
return StreamingResponse( return StreamingResponse(
io.BytesIO(content), zip_buffer,
media_type="application/octet-stream", media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}, headers={"Content-Disposition": f'attachment; filename="{filename}"'},
) )
@@ -89,21 +111,52 @@ async def restore_config(
file: UploadFile = File(...), file: UploadFile = File(...),
db: Database = Depends(get_database), db: Database = Depends(get_database),
): ):
"""Upload a .db backup file to restore all configuration. Triggers server restart.""" """Upload a .db or .zip backup file to restore all configuration. Triggers server restart.
ZIP backups contain the database and asset files. Plain .db backups are
also supported for backward compatibility (assets are not restored).
"""
raw = await file.read() raw = await file.read()
if len(raw) > 50 * 1024 * 1024: # 50 MB limit if len(raw) > 200 * 1024 * 1024: # 200 MB limit (ZIP may contain assets)
raise HTTPException(status_code=400, detail="Backup file too large (max 50 MB)") raise HTTPException(status_code=400, detail="Backup file too large (max 200 MB)")
if len(raw) < 100: if len(raw) < 100:
raise HTTPException(status_code=400, detail="File too small to be a valid SQLite database") raise HTTPException(status_code=400, detail="File too small to be a valid backup")
# SQLite files start with "SQLite format 3\000"
if not raw[:16].startswith(b"SQLite format 3"):
raise HTTPException(status_code=400, detail="Not a valid SQLite database file")
import tempfile import tempfile
is_zip = raw[:4] == b"PK\x03\x04"
is_sqlite = raw[:16].startswith(b"SQLite format 3")
if not is_zip and not is_sqlite:
raise HTTPException(status_code=400, detail="Not a valid backup file (expected .zip or .db)")
if is_zip:
# Extract DB and assets from ZIP
try:
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
names = zf.namelist()
if "ledgrab.db" not in names:
raise HTTPException(status_code=400, detail="ZIP backup missing ledgrab.db")
db_bytes = zf.read("ledgrab.db")
# Restore asset files
assets_dir = Path(get_config().assets.assets_dir)
assets_dir.mkdir(parents=True, exist_ok=True)
for name in names:
if name.startswith("assets/") and not name.endswith("/"):
asset_filename = name.split("/", 1)[1]
dest = assets_dir / asset_filename
dest.write_bytes(zf.read(name))
logger.info(f"Restored asset file: {asset_filename}")
except zipfile.BadZipFile:
raise HTTPException(status_code=400, detail="Invalid ZIP file")
else:
db_bytes = raw
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp.write(raw) tmp.write(db_bytes)
tmp_path = Path(tmp.name) tmp_path = Path(tmp.name)
try: try:

View File

@@ -57,8 +57,8 @@ async def list_cspt(
responses = [_cspt_to_response(t) for t in templates] responses = [_cspt_to_response(t) for t in templates]
return ColorStripProcessingTemplateListResponse(templates=responses, count=len(responses)) return ColorStripProcessingTemplateListResponse(templates=responses, count=len(responses))
except Exception as e: except Exception as e:
logger.error(f"Failed to list color strip processing templates: {e}") logger.error("Failed to list color strip processing templates: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"], status_code=201) @router.post("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"], status_code=201)
@@ -84,8 +84,8 @@ async def create_cspt(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to create color strip processing template: {e}") logger.error("Failed to create color strip processing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"]) @router.get("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
@@ -127,8 +127,8 @@ async def update_cspt(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to update color strip processing template: {e}") logger.error("Failed to update color strip processing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/color-strip-processing-templates/{template_id}", status_code=204, tags=["Color Strip Processing"]) @router.delete("/api/v1/color-strip-processing-templates/{template_id}", status_code=204, tags=["Color Strip Processing"])
@@ -159,8 +159,8 @@ async def delete_cspt(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to delete color strip processing template: {e}") logger.error("Failed to delete color strip processing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
# ── Test / Preview WebSocket ────────────────────────────────────────── # ── Test / Preview WebSocket ──────────────────────────────────────────
@@ -259,12 +259,14 @@ async def test_cspt_ws(
result = flt.process_strip(colors) result = flt.process_strip(colors)
if result is not None: if result is not None:
colors = result colors = result
except Exception: except Exception as e:
logger.debug("Strip filter processing error: %s", e)
pass pass
await websocket.send_bytes(colors.tobytes()) await websocket.send_bytes(colors.tobytes())
await asyncio.sleep(frame_interval) await asyncio.sleep(frame_interval)
except WebSocketDisconnect: except WebSocketDisconnect:
logger.debug("Color strip processing test WebSocket disconnected")
pass pass
except Exception as e: except Exception as e:
logger.error(f"CSPT test WS error: {e}") logger.error(f"CSPT test WS error: {e}")

View File

@@ -96,7 +96,8 @@ def _resolve_display_index(picture_source_id: str, picture_source_store: Picture
return 0 return 0
try: try:
ps = picture_source_store.get_stream(picture_source_id) ps = picture_source_store.get_stream(picture_source_id)
except Exception: except Exception as e:
logger.debug("Failed to resolve display index for picture source %s: %s", picture_source_id, e)
return 0 return 0
if isinstance(ps, ScreenCapturePictureSource): if isinstance(ps, ScreenCapturePictureSource):
return ps.display_index return ps.display_index
@@ -160,8 +161,8 @@ async def create_color_strip_source(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to create color strip source: {e}") logger.error("Failed to create color strip source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"]) @router.get("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"])
@@ -204,8 +205,8 @@ async def update_color_strip_source(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to update color strip source: {e}") logger.error("Failed to update color strip source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"]) @router.delete("/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"])
@@ -256,8 +257,8 @@ async def delete_color_strip_source(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to delete color strip source: {e}") logger.error("Failed to delete color strip source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
# ===== CALIBRATION TEST ===== # ===== CALIBRATION TEST =====
@@ -332,8 +333,8 @@ async def test_css_calibration(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to set CSS calibration test mode: {e}") logger.error("Failed to set CSS calibration test mode: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
# ===== OVERLAY VISUALIZATION ===== # ===== OVERLAY VISUALIZATION =====
@@ -372,8 +373,8 @@ async def start_css_overlay(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to start CSS overlay: {e}", exc_info=True) logger.error("Failed to start CSS overlay: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/color-strip-sources/{source_id}/overlay/stop", tags=["Color Strip Sources"]) @router.post("/api/v1/color-strip-sources/{source_id}/overlay/stop", tags=["Color Strip Sources"])
@@ -387,8 +388,8 @@ async def stop_css_overlay(
await manager.stop_css_overlay(source_id) await manager.stop_css_overlay(source_id)
return {"status": "stopped", "source_id": source_id} return {"status": "stopped", "source_id": source_id}
except Exception as e: except Exception as e:
logger.error(f"Failed to stop CSS overlay: {e}", exc_info=True) logger.error("Failed to stop CSS overlay: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/color-strip-sources/{source_id}/overlay/status", tags=["Color Strip Sources"]) @router.get("/api/v1/color-strip-sources/{source_id}/overlay/status", tags=["Color Strip Sources"])
@@ -549,7 +550,8 @@ async def preview_color_strip_ws(
try: try:
mgr = get_processor_manager() mgr = get_processor_manager()
return getattr(mgr, "_sync_clock_manager", None) return getattr(mgr, "_sync_clock_manager", None)
except Exception: except Exception as e:
logger.debug("SyncClockManager not available: %s", e)
return None return None
def _build_source(config: dict): def _build_source(config: dict):

View File

@@ -177,8 +177,8 @@ async def create_device(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to create device: {e}") logger.error("Failed to create device: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"]) @router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"])
@@ -360,7 +360,8 @@ async def update_device(
led_count=update_data.led_count, led_count=update_data.led_count,
baud_rate=update_data.baud_rate, baud_rate=update_data.baud_rate,
) )
except ValueError: except ValueError as e:
logger.debug("Processor manager device update skipped for %s: %s", device_id, e)
pass pass
# Sync auto_shutdown and zone_mode in runtime state # Sync auto_shutdown and zone_mode in runtime state
@@ -377,8 +378,8 @@ async def update_device(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to update device: {e}") logger.error("Failed to update device: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/devices/{device_id}", status_code=204, tags=["Devices"]) @router.delete("/api/v1/devices/{device_id}", status_code=204, tags=["Devices"])
@@ -417,8 +418,8 @@ async def delete_device(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to delete device: {e}") logger.error("Failed to delete device: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
# ===== DEVICE STATE (health only) ===== # ===== DEVICE STATE (health only) =====
@@ -654,6 +655,7 @@ async def device_ws_stream(
while True: while True:
await websocket.receive_text() await websocket.receive_text()
except WebSocketDisconnect: except WebSocketDisconnect:
logger.debug("Device event WebSocket disconnected for %s", device_id)
pass pass
finally: finally:
broadcaster.remove_client(device_id, websocket) broadcaster.remove_client(device_id, websocket)

View File

@@ -163,8 +163,8 @@ async def create_target(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to create target: {e}") logger.error("Failed to create target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/output-targets", response_model=OutputTargetListResponse, tags=["Targets"]) @router.get("/api/v1/output-targets", response_model=OutputTargetListResponse, tags=["Targets"])
@@ -291,14 +291,16 @@ async def update_target(
css_changed=data.color_strip_source_id is not None, css_changed=data.color_strip_source_id is not None,
brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed), brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed),
) )
except ValueError: except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
pass pass
# Device change requires async stop -> swap -> start cycle # Device change requires async stop -> swap -> start cycle
if data.device_id is not None: if data.device_id is not None:
try: try:
await manager.update_target_device(target_id, target.device_id) await manager.update_target_device(target_id, target.device_id)
except ValueError: except ValueError as e:
logger.debug("Device update skipped for target %s: %s", target_id, e)
pass pass
fire_entity_event("output_target", "updated", target_id) fire_entity_event("output_target", "updated", target_id)
@@ -309,8 +311,8 @@ async def update_target(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to update target: {e}") logger.error("Failed to update target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/output-targets/{target_id}", status_code=204, tags=["Targets"]) @router.delete("/api/v1/output-targets/{target_id}", status_code=204, tags=["Targets"])
@@ -325,13 +327,15 @@ async def delete_target(
# Stop processing if running # Stop processing if running
try: try:
await manager.stop_processing(target_id) await manager.stop_processing(target_id)
except ValueError: except ValueError as e:
logger.debug("Stop processing skipped for target %s (not running): %s", target_id, e)
pass pass
# Remove from manager # Remove from manager
try: try:
manager.remove_target(target_id) manager.remove_target(target_id)
except (ValueError, RuntimeError): except (ValueError, RuntimeError) as e:
logger.debug("Remove target from manager skipped for %s: %s", target_id, e)
pass pass
# Delete from store # Delete from store
@@ -343,5 +347,5 @@ async def delete_target(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to delete target: {e}") logger.error("Failed to delete target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -120,8 +120,8 @@ async def start_processing(
msg = msg.replace(t.id, f"'{t.name}'") msg = msg.replace(t.id, f"'{t.name}'")
raise HTTPException(status_code=409, detail=msg) raise HTTPException(status_code=409, detail=msg)
except Exception as e: except Exception as e:
logger.error(f"Failed to start processing: {e}") logger.error("Failed to start processing: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"]) @router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"])
@@ -140,8 +140,8 @@ async def stop_processing(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to stop processing: {e}") logger.error("Failed to stop processing: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
# ===== STATE & METRICS ENDPOINTS ===== # ===== STATE & METRICS ENDPOINTS =====
@@ -160,8 +160,8 @@ async def get_target_state(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to get target state: {e}") logger.error("Failed to get target state: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"]) @router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
@@ -178,8 +178,8 @@ async def get_target_metrics(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to get target metrics: {e}") logger.error("Failed to get target metrics: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
# ===== STATE CHANGE EVENT STREAM ===== # ===== STATE CHANGE EVENT STREAM =====
@@ -268,8 +268,8 @@ async def start_target_overlay(
except RuntimeError as e: except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e)) raise HTTPException(status_code=409, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to start overlay: {e}", exc_info=True) logger.error("Failed to start overlay: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/output-targets/{target_id}/overlay/stop", tags=["Visualization"]) @router.post("/api/v1/output-targets/{target_id}/overlay/stop", tags=["Visualization"])
@@ -286,8 +286,8 @@ async def stop_target_overlay(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to stop overlay: {e}", exc_info=True) logger.error("Failed to stop overlay: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/output-targets/{target_id}/overlay/status", tags=["Visualization"]) @router.get("/api/v1/output-targets/{target_id}/overlay/status", tags=["Visualization"])

View File

@@ -88,7 +88,6 @@ async def test_kc_target(
pp_template_store=Depends(get_pp_template_store), pp_template_store=Depends(get_pp_template_store),
): ):
"""Test a key-colors target: capture a frame, extract colors from each rectangle.""" """Test a key-colors target: capture a frame, extract colors from each rectangle."""
import httpx
stream = None stream = None
try: try:
@@ -130,21 +129,16 @@ async def test_kc_target(
raw_stream = chain["raw_stream"] raw_stream = chain["raw_stream"]
from wled_controller.utils.image_codec import load_image_bytes, load_image_file from wled_controller.utils.image_codec import load_image_file
if isinstance(raw_stream, StaticImagePictureSource): if isinstance(raw_stream, StaticImagePictureSource):
source = raw_stream.image_source from wled_controller.api.dependencies import get_asset_store as _get_asset_store
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: asset_store = _get_asset_store()
resp = await client.get(source) image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
resp.raise_for_status() if not image_path:
image = load_image_bytes(resp.content) raise HTTPException(status_code=400, detail="Image asset not found or missing file")
else: image = load_image_file(image_path)
from pathlib import Path
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
image = load_image_file(path)
elif isinstance(raw_stream, ScreenCapturePictureSource): elif isinstance(raw_stream, ScreenCapturePictureSource):
try: try:
@@ -264,10 +258,11 @@ async def test_kc_target(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e: except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}") logger.error("Capture error during KC target test: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
except Exception as e: except Exception as e:
logger.error(f"Failed to test KC target: {e}", exc_info=True) logger.error("Failed to test KC target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
finally: finally:
if stream: if stream:
try: try:
@@ -420,7 +415,8 @@ async def test_kc_target_ws(
for pp_id in pp_template_ids: for pp_id in pp_template_ids:
try: try:
pp_template = pp_template_store_inst.get_template(pp_id) pp_template = pp_template_store_inst.get_template(pp_id)
except ValueError: except ValueError as e:
logger.debug("PP template %s not found during KC test: %s", pp_id, e)
continue continue
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters) flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
for fi in flat_filters: for fi in flat_filters:
@@ -429,7 +425,8 @@ async def test_kc_target_ws(
result = f.process_image(cur_image, image_pool) result = f.process_image(cur_image, image_pool)
if result is not None: if result is not None:
cur_image = result cur_image = result
except ValueError: except ValueError as e:
logger.debug("Filter processing error during KC test: %s", e)
pass pass
# Extract colors # Extract colors
@@ -492,7 +489,8 @@ async def test_kc_target_ws(
await asyncio.to_thread( await asyncio.to_thread(
live_stream_mgr.release, target.picture_source_id live_stream_mgr.release, target.picture_source_id
) )
except Exception: except Exception as e:
logger.debug("Live stream release during KC test cleanup: %s", e)
pass pass
logger.info(f"KC test WS closed for {target_id}") logger.info(f"KC test WS closed for {target_id}")
@@ -524,6 +522,7 @@ async def target_colors_ws(
# Keep alive — wait for client messages (or disconnect) # Keep alive — wait for client messages (or disconnect)
await websocket.receive_text() await websocket.receive_text()
except WebSocketDisconnect: except WebSocketDisconnect:
logger.debug("KC live WebSocket disconnected for target %s", target_id)
pass pass
finally: finally:
manager.remove_kc_ws_client(target_id, websocket) manager.remove_kc_ws_client(target_id, websocket)

View File

@@ -53,8 +53,8 @@ async def list_pattern_templates(
responses = [_pat_template_to_response(t) for t in templates] responses = [_pat_template_to_response(t) for t in templates]
return PatternTemplateListResponse(templates=responses, count=len(responses)) return PatternTemplateListResponse(templates=responses, count=len(responses))
except Exception as e: except Exception as e:
logger.error(f"Failed to list pattern templates: {e}") logger.error("Failed to list pattern templates: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/pattern-templates", response_model=PatternTemplateResponse, tags=["Pattern Templates"], status_code=201) @router.post("/api/v1/pattern-templates", response_model=PatternTemplateResponse, tags=["Pattern Templates"], status_code=201)
@@ -83,8 +83,8 @@ async def create_pattern_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to create pattern template: {e}") logger.error("Failed to create pattern template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/pattern-templates/{template_id}", response_model=PatternTemplateResponse, tags=["Pattern Templates"]) @router.get("/api/v1/pattern-templates/{template_id}", response_model=PatternTemplateResponse, tags=["Pattern Templates"])
@@ -131,8 +131,8 @@ async def update_pattern_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to update pattern template: {e}") logger.error("Failed to update pattern template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/pattern-templates/{template_id}", status_code=204, tags=["Pattern Templates"]) @router.delete("/api/v1/pattern-templates/{template_id}", status_code=204, tags=["Pattern Templates"])
@@ -162,5 +162,5 @@ async def delete_pattern_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to delete pattern template: {e}") logger.error("Failed to delete pattern template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -56,13 +56,13 @@ def _stream_to_response(s) -> PictureSourceResponse:
target_fps=getattr(s, "target_fps", None), target_fps=getattr(s, "target_fps", None),
source_stream_id=getattr(s, "source_stream_id", None), source_stream_id=getattr(s, "source_stream_id", None),
postprocessing_template_id=getattr(s, "postprocessing_template_id", None), postprocessing_template_id=getattr(s, "postprocessing_template_id", None),
image_source=getattr(s, "image_source", None), image_asset_id=getattr(s, "image_asset_id", None),
created_at=s.created_at, created_at=s.created_at,
updated_at=s.updated_at, updated_at=s.updated_at,
description=s.description, description=s.description,
tags=s.tags, tags=s.tags,
# Video fields # Video fields
url=getattr(s, "url", None), video_asset_id=getattr(s, "video_asset_id", None),
loop=getattr(s, "loop", None), loop=getattr(s, "loop", None),
playback_speed=getattr(s, "playback_speed", None), playback_speed=getattr(s, "playback_speed", None),
start_time=getattr(s, "start_time", None), start_time=getattr(s, "start_time", None),
@@ -83,8 +83,8 @@ async def list_picture_sources(
responses = [_stream_to_response(s) for s in streams] responses = [_stream_to_response(s) for s in streams]
return PictureSourceListResponse(streams=responses, count=len(responses)) return PictureSourceListResponse(streams=responses, count=len(responses))
except Exception as e: except Exception as e:
logger.error(f"Failed to list picture sources: {e}") logger.error("Failed to list picture sources: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/picture-sources/validate-image", response_model=ImageValidateResponse, tags=["Picture Sources"]) @router.post("/api/v1/picture-sources/validate-image", response_model=ImageValidateResponse, tags=["Picture Sources"])
@@ -94,19 +94,21 @@ async def validate_image(
): ):
"""Validate an image source (URL or file path) and return a preview thumbnail.""" """Validate an image source (URL or file path) and return a preview thumbnail."""
try: try:
from pathlib import Path
from wled_controller.utils.safe_source import validate_image_path, validate_image_url
source = data.image_source.strip() source = data.image_source.strip()
if not source: if not source:
return ImageValidateResponse(valid=False, error="Image source is empty") return ImageValidateResponse(valid=False, error="Image source is empty")
if source.startswith(("http://", "https://")): if source.startswith(("http://", "https://")):
validate_image_url(source)
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
response = await client.get(source) response = await client.get(source)
response.raise_for_status() response.raise_for_status()
img_bytes = response.content img_bytes = response.content
else: else:
path = Path(source) path = validate_image_path(source)
if not path.exists(): if not path.exists():
return ImageValidateResponse(valid=False, error=f"File not found: {source}") return ImageValidateResponse(valid=False, error=f"File not found: {source}")
img_bytes = path img_bytes = path
@@ -147,16 +149,18 @@ async def get_full_image(
source: str = Query(..., description="Image URL or local file path"), source: str = Query(..., description="Image URL or local file path"),
): ):
"""Serve the full-resolution image for lightbox preview.""" """Serve the full-resolution image for lightbox preview."""
from pathlib import Path
from wled_controller.utils.safe_source import validate_image_path, validate_image_url
try: try:
if source.startswith(("http://", "https://")): if source.startswith(("http://", "https://")):
validate_image_url(source)
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
response = await client.get(source) response = await client.get(source)
response.raise_for_status() response.raise_for_status()
img_bytes = response.content img_bytes = response.content
else: else:
path = Path(source) path = validate_image_path(source)
if not path.exists(): if not path.exists():
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
img_bytes = path img_bytes = path
@@ -215,11 +219,11 @@ async def create_picture_source(
target_fps=data.target_fps, target_fps=data.target_fps,
source_stream_id=data.source_stream_id, source_stream_id=data.source_stream_id,
postprocessing_template_id=data.postprocessing_template_id, postprocessing_template_id=data.postprocessing_template_id,
image_source=data.image_source, image_asset_id=data.image_asset_id,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
# Video fields # Video fields
url=data.url, video_asset_id=data.video_asset_id,
loop=data.loop, loop=data.loop,
playback_speed=data.playback_speed, playback_speed=data.playback_speed,
start_time=data.start_time, start_time=data.start_time,
@@ -237,8 +241,8 @@ async def create_picture_source(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to create picture source: {e}") logger.error("Failed to create picture source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"]) @router.get("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
@@ -272,11 +276,11 @@ async def update_picture_source(
target_fps=data.target_fps, target_fps=data.target_fps,
source_stream_id=data.source_stream_id, source_stream_id=data.source_stream_id,
postprocessing_template_id=data.postprocessing_template_id, postprocessing_template_id=data.postprocessing_template_id,
image_source=data.image_source, image_asset_id=data.image_asset_id,
description=data.description, description=data.description,
tags=data.tags, tags=data.tags,
# Video fields # Video fields
url=data.url, video_asset_id=data.video_asset_id,
loop=data.loop, loop=data.loop,
playback_speed=data.playback_speed, playback_speed=data.playback_speed,
start_time=data.start_time, start_time=data.start_time,
@@ -292,8 +296,8 @@ async def update_picture_source(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to update picture source: {e}") logger.error("Failed to update picture source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/picture-sources/{stream_id}", status_code=204, tags=["Picture Sources"]) @router.delete("/api/v1/picture-sources/{stream_id}", status_code=204, tags=["Picture Sources"])
@@ -324,8 +328,8 @@ async def delete_picture_source(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to delete picture source: {e}") logger.error("Failed to delete picture source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/picture-sources/{stream_id}/thumbnail", tags=["Picture Sources"]) @router.get("/api/v1/picture-sources/{stream_id}/thumbnail", tags=["Picture Sources"])
@@ -344,8 +348,15 @@ async def get_video_thumbnail(
if not isinstance(source, VideoCaptureSource): if not isinstance(source, VideoCaptureSource):
raise HTTPException(status_code=400, detail="Not a video source") raise HTTPException(status_code=400, detail="Not a video source")
# Resolve video asset to file path
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
video_path = asset_store.get_file_path(source.video_asset_id) if source.video_asset_id else None
if not video_path:
raise HTTPException(status_code=400, detail="Video asset not found or missing file")
frame = await asyncio.get_event_loop().run_in_executor( frame = await asyncio.get_event_loop().run_in_executor(
None, extract_thumbnail, source.url, source.resolution_limit None, extract_thumbnail, str(video_path), source.resolution_limit
) )
if frame is None: if frame is None:
raise HTTPException(status_code=404, detail="Could not extract thumbnail") raise HTTPException(status_code=404, detail="Could not extract thumbnail")
@@ -360,8 +371,8 @@ async def get_video_thumbnail(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to extract video thumbnail: {e}") logger.error("Failed to extract video thumbnail: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"]) @router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"])
@@ -394,24 +405,17 @@ async def test_picture_source(
raw_stream = chain["raw_stream"] raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource): if isinstance(raw_stream, StaticImagePictureSource):
# Static image stream: load image directly, no engine needed # Static image stream: load image from asset
from pathlib import Path from wled_controller.api.dependencies import get_asset_store as _get_asset_store
from wled_controller.utils.image_codec import load_image_file
asset_store = _get_asset_store()
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
if not image_path:
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
source = raw_stream.image_source
start_time = time.perf_counter() start_time = time.perf_counter()
image = await asyncio.to_thread(load_image_file, image_path)
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
image = load_image_bytes(resp.content)
else:
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
image = await asyncio.to_thread(load_image_file, path)
actual_duration = time.perf_counter() - start_time actual_duration = time.perf_counter() - start_time
frame_count = 1 frame_count = 1
@@ -543,10 +547,11 @@ async def test_picture_source(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e: except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Engine error: {str(e)}") logger.error("Engine error during picture source test: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
except Exception as e: except Exception as e:
logger.error(f"Failed to test picture source: {e}", exc_info=True) logger.error("Failed to test picture source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
finally: finally:
if stream: if stream:
try: try:
@@ -602,12 +607,19 @@ async def test_picture_source_ws(
# Video sources: use VideoCaptureLiveStream for test preview # Video sources: use VideoCaptureLiveStream for test preview
if isinstance(raw_stream, VideoCaptureSource): if isinstance(raw_stream, VideoCaptureSource):
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
from wled_controller.api.dependencies import get_asset_store as _get_asset_store2
asset_store = _get_asset_store2()
video_path = asset_store.get_file_path(raw_stream.video_asset_id) if raw_stream.video_asset_id else None
if not video_path:
await websocket.close(code=4004, reason="Video asset not found or missing file")
return
await websocket.accept() await websocket.accept()
logger.info(f"Video source test WS connected for {stream_id} ({duration}s)") logger.info(f"Video source test WS connected for {stream_id} ({duration}s)")
video_stream = VideoCaptureLiveStream( video_stream = VideoCaptureLiveStream(
url=raw_stream.url, url=str(video_path),
loop=raw_stream.loop, loop=raw_stream.loop,
playback_speed=raw_stream.playback_speed, playback_speed=raw_stream.playback_speed,
start_time=raw_stream.start_time, start_time=raw_stream.start_time,
@@ -663,12 +675,14 @@ async def test_picture_source_ws(
"avg_fps": round(frame_count / max(duration, 0.001), 1), "avg_fps": round(frame_count / max(duration, 0.001), 1),
}) })
except WebSocketDisconnect: except WebSocketDisconnect:
logger.debug("Video source test WebSocket disconnected for %s", stream_id)
pass pass
except Exception as e: except Exception as e:
logger.error(f"Video source test WS error for {stream_id}: {e}") logger.error(f"Video source test WS error for {stream_id}: {e}")
try: try:
await websocket.send_json({"type": "error", "detail": str(e)}) await websocket.send_json({"type": "error", "detail": str(e)})
except Exception: except Exception as e2:
logger.debug("Failed to send error to video test WS: %s", e2)
pass pass
finally: finally:
video_stream.stop() video_stream.stop()
@@ -697,7 +711,8 @@ async def test_picture_source_ws(
try: try:
pp_template = pp_store.get_template(pp_template_ids[0]) pp_template = pp_store.get_template(pp_template_ids[0])
pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
except ValueError: except ValueError as e:
logger.debug("PP template not found for picture source test: %s", e)
pass pass
# Engine factory — creates + initializes engine inside the capture thread # Engine factory — creates + initializes engine inside the capture thread
@@ -721,6 +736,7 @@ async def test_picture_source_ws(
preview_width=preview_width or None, preview_width=preview_width or None,
) )
except WebSocketDisconnect: except WebSocketDisconnect:
logger.debug("Picture source test WebSocket disconnected for %s", stream_id)
pass pass
except Exception as e: except Exception as e:
logger.error(f"Picture source test WS error for {stream_id}: {e}") logger.error(f"Picture source test WS error for {stream_id}: {e}")

View File

@@ -2,7 +2,6 @@
import time import time
import httpx
import numpy as np import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
@@ -87,8 +86,8 @@ async def create_pp_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to create postprocessing template: {e}") logger.error("Failed to create postprocessing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"]) @router.get("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"])
@@ -130,8 +129,8 @@ async def update_pp_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to update postprocessing template: {e}") logger.error("Failed to update postprocessing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/postprocessing-templates/{template_id}", status_code=204, tags=["Postprocessing Templates"]) @router.delete("/api/v1/postprocessing-templates/{template_id}", status_code=204, tags=["Postprocessing Templates"])
@@ -162,8 +161,8 @@ async def delete_pp_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to delete postprocessing template: {e}") logger.error("Failed to delete postprocessing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/postprocessing-templates/{template_id}/test", response_model=TemplateTestResponse, tags=["Postprocessing Templates"]) @router.post("/api/v1/postprocessing-templates/{template_id}/test", response_model=TemplateTestResponse, tags=["Postprocessing Templates"])
@@ -197,28 +196,21 @@ async def test_pp_template(
from wled_controller.utils.image_codec import ( from wled_controller.utils.image_codec import (
encode_jpeg_data_uri, encode_jpeg_data_uri,
load_image_bytes,
load_image_file, load_image_file,
thumbnail as make_thumbnail, thumbnail as make_thumbnail,
) )
if isinstance(raw_stream, StaticImagePictureSource): if isinstance(raw_stream, StaticImagePictureSource):
# Static image: load directly # Static image: load from asset
from pathlib import Path from wled_controller.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
if not image_path:
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
source = raw_stream.image_source
start_time = time.perf_counter() start_time = time.perf_counter()
image = load_image_file(image_path)
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
image = load_image_bytes(resp.content)
else:
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
image = load_image_file(path)
actual_duration = time.perf_counter() - start_time actual_duration = time.perf_counter() - start_time
frame_count = 1 frame_count = 1
@@ -330,13 +322,14 @@ async def test_pp_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Postprocessing template test failed: {e}") logger.error("Postprocessing template test failed: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
finally: finally:
if stream: if stream:
try: try:
stream.cleanup() stream.cleanup()
except Exception: except Exception as e:
logger.debug("PP test capture stream cleanup: %s", e)
pass pass
@@ -434,6 +427,7 @@ async def test_pp_template_ws(
preview_width=preview_width or None, preview_width=preview_width or None,
) )
except WebSocketDisconnect: except WebSocketDisconnect:
logger.debug("PP template test WebSocket disconnected for %s", template_id)
pass pass
except Exception as e: except Exception as e:
logger.error(f"PP template test WS error for {template_id}: {e}") logger.error(f"PP template test WS error for {template_id}: {e}")

View File

@@ -130,17 +130,19 @@ async def get_version():
async def list_all_tags(_: AuthRequired): async def list_all_tags(_: AuthRequired):
"""Get all tags used across all entities.""" """Get all tags used across all entities."""
all_tags: set[str] = set() all_tags: set[str] = set()
from wled_controller.api.dependencies import get_asset_store
store_getters = [ store_getters = [
get_device_store, get_output_target_store, get_color_strip_store, get_device_store, get_output_target_store, get_color_strip_store,
get_picture_source_store, get_audio_source_store, get_value_source_store, get_picture_source_store, get_audio_source_store, get_value_source_store,
get_sync_clock_store, get_automation_store, get_scene_preset_store, get_sync_clock_store, get_automation_store, get_scene_preset_store,
get_template_store, get_audio_template_store, get_pp_template_store, get_template_store, get_audio_template_store, get_pp_template_store,
get_pattern_template_store, get_pattern_template_store, get_asset_store,
] ]
for getter in store_getters: for getter in store_getters:
try: try:
store = getter() store = getter()
except RuntimeError: except RuntimeError as e:
logger.debug("Store not available during entity count: %s", e)
continue continue
# BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices() # BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices()
fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None) fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None)
@@ -211,10 +213,10 @@ async def get_displays(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to get displays: {e}") logger.error("Failed to get displays: %s", e, exc_info=True)
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"Failed to retrieve display information: {str(e)}" detail="Internal server error"
) )
@@ -232,10 +234,10 @@ async def get_running_processes(_: AuthRequired):
sorted_procs = sorted(processes) sorted_procs = sorted(processes)
return ProcessListResponse(processes=sorted_procs, count=len(sorted_procs)) return ProcessListResponse(processes=sorted_procs, count=len(sorted_procs))
except Exception as e: except Exception as e:
logger.error(f"Failed to get processes: {e}") logger.error("Failed to get processes: %s", e, exc_info=True)
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"Failed to retrieve process list: {str(e)}" detail="Internal server error"
) )
@@ -300,7 +302,7 @@ def list_api_keys(_: AuthRequired):
"""List API key labels (read-only; keys are defined in the YAML config file).""" """List API key labels (read-only; keys are defined in the YAML config file)."""
config = get_config() config = get_config()
keys = [ keys = [
{"label": label, "masked": key[:4] + "****" + key[-4:] if len(key) >= 8 else "****"} {"label": label, "masked": key[:4] + "****" if len(key) >= 8 else "****"}
for label, key in config.auth.api_keys.items() for label, key in config.auth.api_keys.items()
] ]
return {"keys": keys, "count": len(keys)} return {"keys": keys, "count": len(keys)}

View File

@@ -191,8 +191,10 @@ async def logs_ws(
except Exception: except Exception:
break break
except WebSocketDisconnect: except WebSocketDisconnect:
logger.debug("Log stream WebSocket disconnected")
pass pass
except Exception: except Exception as e:
logger.debug("Log stream WebSocket error: %s", e)
pass pass
finally: finally:
log_broadcaster.unsubscribe(queue) log_broadcaster.unsubscribe(queue)
@@ -287,6 +289,7 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
address = request.address.strip() address = request.address.strip()
if not address: if not address:
raise HTTPException(status_code=400, detail="Address is required") raise HTTPException(status_code=400, detail="Address is required")
_validate_adb_address(address)
adb = _get_adb_path() adb = _get_adb_path()
logger.info(f"Disconnecting ADB device: {address}") logger.info(f"Disconnecting ADB device: {address}")

View File

@@ -76,8 +76,8 @@ async def list_templates(
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to list templates: {e}") logger.error("Failed to list templates: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/capture-templates", response_model=TemplateResponse, tags=["Templates"], status_code=201) @router.post("/api/v1/capture-templates", response_model=TemplateResponse, tags=["Templates"], status_code=201)
@@ -115,8 +115,8 @@ async def create_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to create template: {e}") logger.error("Failed to create template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"]) @router.get("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"])
@@ -180,8 +180,8 @@ async def update_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to update template: {e}") logger.error("Failed to update template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/capture-templates/{template_id}", status_code=204, tags=["Templates"]) @router.delete("/api/v1/capture-templates/{template_id}", status_code=204, tags=["Templates"])
@@ -222,8 +222,8 @@ async def delete_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Failed to delete template: {e}") logger.error("Failed to delete template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/capture-engines", response_model=EngineListResponse, tags=["Templates"]) @router.get("/api/v1/capture-engines", response_model=EngineListResponse, tags=["Templates"])
@@ -252,8 +252,8 @@ async def list_engines(_auth: AuthRequired):
return EngineListResponse(engines=engines, count=len(engines)) return EngineListResponse(engines=engines, count=len(engines))
except Exception as e: except Exception as e:
logger.error(f"Failed to list engines: {e}") logger.error("Failed to list engines: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"]) @router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
@@ -365,10 +365,11 @@ def test_template(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e: except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Engine error: {str(e)}") logger.error("Engine error during template test: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
except Exception as e: except Exception as e:
logger.error(f"Failed to test template: {e}", exc_info=True) logger.error("Failed to test template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail="Internal server error")
finally: finally:
if stream: if stream:
try: try:
@@ -432,6 +433,7 @@ async def test_template_ws(
try: try:
await stream_capture_test(websocket, engine_factory, duration, preview_width=pw) await stream_capture_test(websocket, engine_factory, duration, preview_width=pw)
except WebSocketDisconnect: except WebSocketDisconnect:
logger.debug("Capture template test WebSocket disconnected")
pass pass
except Exception as e: except Exception as e:
logger.error(f"Capture template test WS error: {e}") logger.error(f"Capture template test WS error: {e}")

View File

@@ -3,6 +3,7 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_update_service from wled_controller.api.dependencies import get_update_service
from wled_controller.api.schemas.update import ( from wled_controller.api.schemas.update import (
DismissRequest, DismissRequest,
@@ -20,6 +21,7 @@ router = APIRouter(prefix="/api/v1/system/update", tags=["update"])
@router.get("/status", response_model=UpdateStatusResponse) @router.get("/status", response_model=UpdateStatusResponse)
async def get_update_status( async def get_update_status(
_: AuthRequired,
service: UpdateService = Depends(get_update_service), service: UpdateService = Depends(get_update_service),
): ):
return service.get_status() return service.get_status()
@@ -27,6 +29,7 @@ async def get_update_status(
@router.post("/check", response_model=UpdateStatusResponse) @router.post("/check", response_model=UpdateStatusResponse)
async def check_for_updates( async def check_for_updates(
_: AuthRequired,
service: UpdateService = Depends(get_update_service), service: UpdateService = Depends(get_update_service),
): ):
return await service.check_now() return await service.check_now()
@@ -34,6 +37,7 @@ async def check_for_updates(
@router.post("/dismiss") @router.post("/dismiss")
async def dismiss_update( async def dismiss_update(
_: AuthRequired,
body: DismissRequest, body: DismissRequest,
service: UpdateService = Depends(get_update_service), service: UpdateService = Depends(get_update_service),
): ):
@@ -43,6 +47,7 @@ async def dismiss_update(
@router.post("/apply") @router.post("/apply")
async def apply_update( async def apply_update(
_: AuthRequired,
service: UpdateService = Depends(get_update_service), service: UpdateService = Depends(get_update_service),
): ):
"""Download (if needed) and apply the available update.""" """Download (if needed) and apply the available update."""
@@ -59,11 +64,12 @@ async def apply_update(
return {"ok": True, "message": "Update applied, server shutting down"} return {"ok": True, "message": "Update applied, server shutting down"}
except Exception as exc: except Exception as exc:
logger.error("Failed to apply update: %s", exc, exc_info=True) logger.error("Failed to apply update: %s", exc, exc_info=True)
return JSONResponse(status_code=500, content={"detail": str(exc)}) return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@router.get("/settings", response_model=UpdateSettingsResponse) @router.get("/settings", response_model=UpdateSettingsResponse)
async def get_update_settings( async def get_update_settings(
_: AuthRequired,
service: UpdateService = Depends(get_update_service), service: UpdateService = Depends(get_update_service),
): ):
return service.get_settings() return service.get_settings()
@@ -71,6 +77,7 @@ async def get_update_settings(
@router.put("/settings", response_model=UpdateSettingsResponse) @router.put("/settings", response_model=UpdateSettingsResponse)
async def update_update_settings( async def update_update_settings(
_: AuthRequired,
body: UpdateSettingsRequest, body: UpdateSettingsRequest,
service: UpdateService = Depends(get_update_service), service: UpdateService = Depends(get_update_service),
): ):

View File

@@ -245,6 +245,7 @@ async def test_value_source_ws(
await websocket.send_json({"value": round(value, 4)}) await websocket.send_json({"value": round(value, 4)})
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
except WebSocketDisconnect: except WebSocketDisconnect:
logger.debug("Value source test WebSocket disconnected for %s", source_id)
pass pass
except Exception as e: except Exception as e:
logger.error(f"Value source test WebSocket error for {source_id}: {e}") logger.error(f"Value source test WebSocket error for {source_id}: {e}")

View File

@@ -6,6 +6,7 @@ automations that have a webhook condition. No API-key auth is required —
the secret token itself authenticates the caller. the secret token itself authenticates the caller.
""" """
import secrets
import time import time
from collections import defaultdict from collections import defaultdict
@@ -43,6 +44,12 @@ def _check_rate_limit(client_ip: str) -> None:
) )
_rate_hits[client_ip].append(now) _rate_hits[client_ip].append(now)
# Periodic cleanup: remove IPs with no recent hits to prevent unbounded growth
if len(_rate_hits) > 100:
stale = [ip for ip, ts in _rate_hits.items() if not ts or ts[-1] < window_start]
for ip in stale:
del _rate_hits[ip]
class WebhookPayload(BaseModel): class WebhookPayload(BaseModel):
action: str = Field(description="'activate' or 'deactivate'") action: str = Field(description="'activate' or 'deactivate'")
@@ -68,7 +75,7 @@ async def handle_webhook(
# Find the automation that owns this token # Find the automation that owns this token
for automation in store.get_all_automations(): for automation in store.get_all_automations():
for condition in automation.conditions: for condition in automation.conditions:
if isinstance(condition, WebhookCondition) and condition.token == token: if isinstance(condition, WebhookCondition) and secrets.compare_digest(condition.token, token):
active = body.action == "activate" active = body.action == "activate"
await engine.set_webhook_state(token, active) await engine.set_webhook_state(token, active)
logger.info( logger.info(

View File

@@ -0,0 +1,37 @@
"""Asset schemas (CRUD)."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class AssetUpdate(BaseModel):
"""Request to update asset metadata."""
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name")
description: Optional[str] = Field(None, max_length=500, description="Optional description")
tags: Optional[List[str]] = Field(None, description="User-defined tags")
class AssetResponse(BaseModel):
"""Asset response."""
id: str = Field(description="Asset ID")
name: str = Field(description="Display name")
filename: str = Field(description="Original upload filename")
mime_type: str = Field(description="MIME type")
asset_type: str = Field(description="Asset type: sound, image, video, other")
size_bytes: int = Field(description="File size in bytes")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
prebuilt: bool = Field(False, description="Whether this is a shipped prebuilt asset")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class AssetListResponse(BaseModel):
"""List of assets."""
assets: List[AssetResponse] = Field(description="List of assets")
count: int = Field(description="Number of assets")

View File

@@ -8,6 +8,13 @@ from pydantic import BaseModel, Field, model_validator
from wled_controller.api.schemas.devices import Calibration from wled_controller.api.schemas.devices import Calibration
class AppSoundOverride(BaseModel):
"""Per-application sound override for notification sources."""
sound_asset_id: Optional[str] = Field(None, description="Asset ID for the sound (None = mute this app)")
volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Volume override (None = use global)")
class AnimationConfig(BaseModel): class AnimationConfig(BaseModel):
"""Procedural animation configuration for static/gradient color strip sources.""" """Procedural animation configuration for static/gradient color strip sources."""
@@ -102,6 +109,9 @@ class ColorStripSourceCreate(BaseModel):
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist") app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications") os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
sound_volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(None, description="Per-app sound overrides")
# daylight-type fields # daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0) speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle") use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
@@ -173,6 +183,9 @@ class ColorStripSourceUpdate(BaseModel):
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist") app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications") os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
sound_volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(None, description="Per-app sound overrides")
# daylight-type fields # daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0) speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle") use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
@@ -245,6 +258,9 @@ class ColorStripSourceResponse(BaseModel):
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist") app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter") app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications") os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
sound_volume: Optional[float] = Field(None, description="Global notification sound volume")
app_sounds: Optional[Dict[str, dict]] = Field(None, description="Per-app sound overrides")
# daylight-type fields # daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier") speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle") use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")

View File

@@ -16,11 +16,11 @@ class PictureSourceCreate(BaseModel):
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90) target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)") source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)") postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)") image_asset_id: Optional[str] = Field(None, description="Image asset ID (static_image streams)")
description: Optional[str] = Field(None, description="Stream description", max_length=500) description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
# Video fields # Video fields
url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL") video_asset_id: Optional[str] = Field(None, description="Video asset ID (video streams)")
loop: bool = Field(True, description="Loop video playback") loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0) playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0) start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
@@ -38,11 +38,11 @@ class PictureSourceUpdate(BaseModel):
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90) target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)") source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)") postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)") image_asset_id: Optional[str] = Field(None, description="Image asset ID (static_image streams)")
description: Optional[str] = Field(None, description="Stream description", max_length=500) description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
# Video fields # Video fields
url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL") video_asset_id: Optional[str] = Field(None, description="Video asset ID (video streams)")
loop: Optional[bool] = Field(None, description="Loop video playback") loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier", ge=0.1, le=10.0) playback_speed: Optional[float] = Field(None, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0) start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
@@ -62,13 +62,13 @@ class PictureSourceResponse(BaseModel):
target_fps: Optional[int] = Field(None, description="Target FPS") target_fps: Optional[int] = Field(None, description="Target FPS")
source_stream_id: Optional[str] = Field(None, description="Source stream ID") source_stream_id: Optional[str] = Field(None, description="Source stream ID")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID") postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID")
image_source: Optional[str] = Field(None, description="Image URL or file path") image_asset_id: Optional[str] = Field(None, description="Image asset ID")
tags: List[str] = Field(default_factory=list, description="User-defined tags") tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Stream description") description: Optional[str] = Field(None, description="Stream description")
# Video fields # Video fields
url: Optional[str] = Field(None, description="Video URL") video_asset_id: Optional[str] = Field(None, description="Video asset ID")
loop: Optional[bool] = Field(None, description="Loop video playback") loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier") playback_speed: Optional[float] = Field(None, description="Playback speed multiplier")
start_time: Optional[float] = Field(None, description="Trim start time in seconds") start_time: Optional[float] = Field(None, description="Trim start time in seconds")

View File

@@ -15,7 +15,7 @@ class ServerConfig(BaseSettings):
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 8080 port: int = 8080
log_level: str = "INFO" log_level: str = "INFO"
cors_origins: List[str] = ["*"] cors_origins: List[str] = ["http://localhost:8080"]
class AuthConfig(BaseSettings): class AuthConfig(BaseSettings):
@@ -24,6 +24,13 @@ class AuthConfig(BaseSettings):
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled) api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
class AssetsConfig(BaseSettings):
"""Assets configuration."""
max_file_size_mb: int = 50 # Max upload size in MB
assets_dir: str = "data/assets" # Directory for uploaded asset files
class StorageConfig(BaseSettings): class StorageConfig(BaseSettings):
"""Storage configuration.""" """Storage configuration."""
@@ -65,16 +72,21 @@ class Config(BaseSettings):
server: ServerConfig = Field(default_factory=ServerConfig) server: ServerConfig = Field(default_factory=ServerConfig)
auth: AuthConfig = Field(default_factory=AuthConfig) auth: AuthConfig = Field(default_factory=AuthConfig)
storage: StorageConfig = Field(default_factory=StorageConfig) storage: StorageConfig = Field(default_factory=StorageConfig)
assets: AssetsConfig = Field(default_factory=AssetsConfig)
mqtt: MQTTConfig = Field(default_factory=MQTTConfig) mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig) logging: LoggingConfig = Field(default_factory=LoggingConfig)
def model_post_init(self, __context: object) -> None: def model_post_init(self, __context: object) -> None:
"""Override storage paths when demo mode is active.""" """Override storage and assets paths when demo mode is active."""
if self.demo: if self.demo:
for field_name in self.storage.model_fields: for field_name in StorageConfig.model_fields:
value = getattr(self.storage, field_name) value = getattr(self.storage, field_name)
if isinstance(value, str) and value.startswith("data/"): if isinstance(value, str) and value.startswith("data/"):
setattr(self.storage, field_name, value.replace("data/", "data/demo/", 1)) setattr(self.storage, field_name, value.replace("data/", "data/demo/", 1))
for field_name in AssetsConfig.model_fields:
value = getattr(self.assets, field_name)
if isinstance(value, str) and value.startswith("data/"):
setattr(self.assets, field_name, value.replace("data/", "data/demo/", 1))
@classmethod @classmethod
def from_yaml(cls, config_path: str | Path) -> "Config": def from_yaml(cls, config_path: str | Path) -> "Config":

View File

@@ -141,7 +141,8 @@ class ManagedAudioStream:
if stream is not None: if stream is not None:
try: try:
stream.cleanup() stream.cleanup()
except Exception: except Exception as e:
logger.debug("Audio stream cleanup error: %s", e)
pass pass
self._running = False self._running = False
logger.info( logger.info(

View File

@@ -75,7 +75,8 @@ class SounddeviceCaptureStream(AudioCaptureStreamBase):
try: try:
self._sd_stream.stop() self._sd_stream.stop()
self._sd_stream.close() self._sd_stream.close()
except Exception: except Exception as e:
logger.debug("Sounddevice stream cleanup: %s", e)
pass pass
self._sd_stream = None self._sd_stream = None
self._initialized = False self._initialized = False
@@ -104,7 +105,8 @@ class SounddeviceEngine(AudioCaptureEngine):
try: try:
import sounddevice # noqa: F401 import sounddevice # noqa: F401
return True return True
except ImportError: except ImportError as e:
logger.debug("Sounddevice engine unavailable: %s", e)
return False return False
@classmethod @classmethod
@@ -118,7 +120,8 @@ class SounddeviceEngine(AudioCaptureEngine):
def enumerate_devices(cls) -> List[AudioDeviceInfo]: def enumerate_devices(cls) -> List[AudioDeviceInfo]:
try: try:
import sounddevice as sd import sounddevice as sd
except ImportError: except ImportError as e:
logger.debug("Cannot enumerate sounddevice devices: %s", e)
return [] return []
try: try:

View File

@@ -85,13 +85,15 @@ class WasapiCaptureStream(AudioCaptureStreamBase):
try: try:
self._stream.stop_stream() self._stream.stop_stream()
self._stream.close() self._stream.close()
except Exception: except Exception as e:
logger.debug("WASAPI stream cleanup: %s", e)
pass pass
self._stream = None self._stream = None
if self._pa is not None: if self._pa is not None:
try: try:
self._pa.terminate() self._pa.terminate()
except Exception: except Exception as e:
logger.debug("PyAudio terminate during cleanup: %s", e)
pass pass
self._pa = None self._pa = None
self._initialized = False self._initialized = False
@@ -139,7 +141,8 @@ class WasapiEngine(AudioCaptureEngine):
try: try:
import pyaudiowpatch # noqa: F401 import pyaudiowpatch # noqa: F401
return True return True
except ImportError: except ImportError as e:
logger.debug("WASAPI engine unavailable (pyaudiowpatch not installed): %s", e)
return False return False
@classmethod @classmethod
@@ -153,7 +156,8 @@ class WasapiEngine(AudioCaptureEngine):
def enumerate_devices(cls) -> List[AudioDeviceInfo]: def enumerate_devices(cls) -> List[AudioDeviceInfo]:
try: try:
import pyaudiowpatch as pyaudio import pyaudiowpatch as pyaudio
except ImportError: except ImportError as e:
logger.debug("Cannot enumerate WASAPI devices (pyaudiowpatch not installed): %s", e)
return [] return []
pa = None pa = None
@@ -223,7 +227,8 @@ class WasapiEngine(AudioCaptureEngine):
if pa is not None: if pa is not None:
try: try:
pa.terminate() pa.terminate()
except Exception: except Exception as e:
logger.debug("PyAudio terminate in enumerate cleanup: %s", e)
pass pass
@classmethod @classmethod

View File

@@ -74,6 +74,7 @@ class AutomationEngine:
try: try:
await self._task await self._task
except asyncio.CancelledError: except asyncio.CancelledError:
logger.debug("Automation engine task cancelled")
pass pass
self._task = None self._task = None
@@ -92,6 +93,7 @@ class AutomationEngine:
logger.error(f"Automation evaluation error: {e}", exc_info=True) logger.error(f"Automation evaluation error: {e}", exc_info=True)
await asyncio.sleep(self._poll_interval) await asyncio.sleep(self._poll_interval)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.debug("Automation poll loop cancelled")
pass pass
async def _evaluate_all(self) -> None: async def _evaluate_all(self) -> None:
@@ -262,7 +264,8 @@ class AutomationEngine:
return False return False
try: try:
return matcher() return matcher()
except re.error: except re.error as e:
logger.debug("MQTT condition regex error: %s", e)
return False return False
def _evaluate_app_condition( def _evaluate_app_condition(

View File

@@ -75,7 +75,8 @@ class PlatformDetector:
# Data: 0=off, 1=on, 2=dimmed (treat dimmed as on) # Data: 0=off, 1=on, 2=dimmed (treat dimmed as on)
value = setting.Data[0] value = setting.Data[0]
self._display_on = value != 0 self._display_on = value != 0
except Exception: except Exception as e:
logger.debug("Failed to parse display power setting: %s", e)
pass pass
return 0 return 0
return user32.DefWindowProcW(hwnd, msg, wparam, lparam) return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
@@ -309,7 +310,8 @@ class PlatformDetector:
and win_rect.right >= mr.right and win_rect.right >= mr.right
and win_rect.bottom >= mr.bottom and win_rect.bottom >= mr.bottom
) )
except Exception: except Exception as e:
logger.debug("Fullscreen check failed for hwnd: %s", e)
return False return False
def _get_fullscreen_processes_sync(self) -> Set[str]: def _get_fullscreen_processes_sync(self) -> Set[str]:

View File

@@ -108,6 +108,7 @@ class AutoBackupEngine:
except Exception as e: except Exception as e:
logger.error(f"Auto-backup failed: {e}", exc_info=True) logger.error(f"Auto-backup failed: {e}", exc_info=True)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.debug("Auto-backup loop cancelled")
pass pass
# ─── Backup operations ───────────────────────────────────── # ─── Backup operations ─────────────────────────────────────

View File

@@ -39,7 +39,8 @@ class BetterCamCaptureStream(CaptureStream):
# Clear global camera cache for fresh DXGI state # Clear global camera cache for fresh DXGI state
try: try:
self._bettercam.__factory.clean_up() self._bettercam.__factory.clean_up()
except Exception: except Exception as e:
logger.debug("BetterCam factory cleanup on init: %s", e)
pass pass
self._camera = self._bettercam.create( self._camera = self._bettercam.create(
@@ -59,7 +60,8 @@ class BetterCamCaptureStream(CaptureStream):
try: try:
if self._camera.is_capturing: if self._camera.is_capturing:
self._camera.stop() self._camera.stop()
except Exception: except Exception as e:
logger.debug("BetterCam camera stop during cleanup: %s", e)
pass pass
try: try:
self._camera.release() self._camera.release()
@@ -70,7 +72,8 @@ class BetterCamCaptureStream(CaptureStream):
if self._bettercam: if self._bettercam:
try: try:
self._bettercam.__factory.clean_up() self._bettercam.__factory.clean_up()
except Exception: except Exception as e:
logger.debug("BetterCam factory cleanup on teardown: %s", e)
pass pass
self._initialized = False self._initialized = False

View File

@@ -408,7 +408,8 @@ class CameraEngine(CaptureEngine):
try: try:
import cv2 # noqa: F401 import cv2 # noqa: F401
return True return True
except ImportError: except ImportError as e:
logger.debug("Camera engine unavailable (cv2 not installed): %s", e)
return False return False
@classmethod @classmethod

View File

@@ -39,7 +39,8 @@ class DXcamCaptureStream(CaptureStream):
# Clear global camera cache for fresh DXGI state # Clear global camera cache for fresh DXGI state
try: try:
self._dxcam.__factory.clean_up() self._dxcam.__factory.clean_up()
except Exception: except Exception as e:
logger.debug("DXcam factory cleanup on init: %s", e)
pass pass
self._camera = self._dxcam.create( self._camera = self._dxcam.create(
@@ -59,7 +60,8 @@ class DXcamCaptureStream(CaptureStream):
try: try:
if self._camera.is_capturing: if self._camera.is_capturing:
self._camera.stop() self._camera.stop()
except Exception: except Exception as e:
logger.debug("DXcam camera stop during cleanup: %s", e)
pass pass
try: try:
self._camera.release() self._camera.release()
@@ -70,7 +72,8 @@ class DXcamCaptureStream(CaptureStream):
if self._dxcam: if self._dxcam:
try: try:
self._dxcam.__factory.clean_up() self._dxcam.__factory.clean_up()
except Exception: except Exception as e:
logger.debug("DXcam factory cleanup on teardown: %s", e)
pass pass
self._initialized = False self._initialized = False

View File

@@ -115,7 +115,8 @@ class WGCCaptureStream(CaptureStream):
import platform import platform
build = int(platform.version().split(".")[2]) build = int(platform.version().split(".")[2])
return build >= 22621 return build >= 22621
except Exception: except Exception as e:
logger.debug("Failed to detect WGC border toggle support: %s", e)
return False return False
def _cleanup_internal(self) -> None: def _cleanup_internal(self) -> None:
@@ -133,7 +134,8 @@ class WGCCaptureStream(CaptureStream):
if self._capture_instance: if self._capture_instance:
try: try:
del self._capture_instance del self._capture_instance
except Exception: except Exception as e:
logger.debug("WGC capture instance cleanup: %s", e)
pass pass
self._capture_instance = None self._capture_instance = None
@@ -215,7 +217,8 @@ class WGCEngine(CaptureEngine):
build = int(parts[2]) build = int(parts[2])
if major < 10 or (major == 10 and minor == 0 and build < 17134): if major < 10 or (major == 10 and minor == 0 and build < 17134):
return False return False
except Exception: except Exception as e:
logger.debug("Failed to check Windows version for WGC availability: %s", e)
pass pass
try: try:

View File

@@ -201,8 +201,8 @@ def _build_picture_sources() -> dict:
"updated_at": _NOW, "updated_at": _NOW,
"source_stream_id": None, "source_stream_id": None,
"postprocessing_template_id": None, "postprocessing_template_id": None,
"image_source": None, "image_asset_id": None,
"url": None, "video_asset_id": None,
"loop": None, "loop": None,
"playback_speed": None, "playback_speed": None,
"start_time": None, "start_time": None,
@@ -223,8 +223,8 @@ def _build_picture_sources() -> dict:
"updated_at": _NOW, "updated_at": _NOW,
"source_stream_id": None, "source_stream_id": None,
"postprocessing_template_id": None, "postprocessing_template_id": None,
"image_source": None, "image_asset_id": None,
"url": None, "video_asset_id": None,
"loop": None, "loop": None,
"playback_speed": None, "playback_speed": None,
"start_time": None, "start_time": None,

View File

@@ -60,6 +60,7 @@ class MQTTService:
try: try:
await self._task await self._task
except asyncio.CancelledError: except asyncio.CancelledError:
logger.debug("MQTT background task cancelled")
pass pass
self._task = None self._task = None
self._connected = False self._connected = False
@@ -79,6 +80,7 @@ class MQTTService:
try: try:
self._publish_queue.put_nowait((topic, payload, retain, qos)) self._publish_queue.put_nowait((topic, payload, retain, qos))
except asyncio.QueueFull: except asyncio.QueueFull:
logger.warning("MQTT publish queue full, dropping message for topic %s", topic)
pass pass
async def subscribe(self, topic: str, callback: Callable) -> None: async def subscribe(self, topic: str, callback: Callable) -> None:

View File

@@ -115,7 +115,8 @@ class AudioColorStripStream(ColorStripStream):
tpl = self._audio_template_store.get_template(resolved.audio_template_id) tpl = self._audio_template_store.get_template(resolved.audio_template_id)
self._audio_engine_type = tpl.engine_type self._audio_engine_type = tpl.engine_type
self._audio_engine_config = tpl.engine_config self._audio_engine_config = tpl.engine_config
except ValueError: except ValueError as e:
logger.warning("Audio template %s not found, using default engine: %s", resolved.audio_template_id, e)
pass pass
except ValueError as e: except ValueError as e:
logger.warning(f"Failed to resolve audio source {audio_source_id}: {e}") logger.warning(f"Failed to resolve audio source {audio_source_id}: {e}")

View File

@@ -69,7 +69,7 @@ class ColorStripStreamManager:
keyed by ``{css_id}:{consumer_id}``. keyed by ``{css_id}:{consumer_id}``.
""" """
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None, gradient_store=None, weather_manager=None): def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None, gradient_store=None, weather_manager=None, asset_store=None):
""" """
Args: Args:
color_strip_store: ColorStripStore for resolving source configs color_strip_store: ColorStripStore for resolving source configs
@@ -91,6 +91,7 @@ class ColorStripStreamManager:
self._cspt_store = cspt_store self._cspt_store = cspt_store
self._gradient_store = gradient_store self._gradient_store = gradient_store
self._weather_manager = weather_manager self._weather_manager = weather_manager
self._asset_store = asset_store
self._streams: Dict[str, _ColorStripEntry] = {} self._streams: Dict[str, _ColorStripEntry] = {}
def _inject_clock(self, css_stream, source) -> Optional[str]: def _inject_clock(self, css_stream, source) -> Optional[str]:
@@ -125,7 +126,8 @@ class ColorStripStreamManager:
clock_id = getattr(source, "clock_id", None) clock_id = getattr(source, "clock_id", None)
if clock_id: if clock_id:
self._sync_clock_manager.release(clock_id) self._sync_clock_manager.release(clock_id)
except Exception: except Exception as e:
logger.debug("Sync clock release during stream cleanup: %s", e)
pass # source may have been deleted already pass # source may have been deleted already
def _resolve_key(self, css_id: str, consumer_id: str) -> str: def _resolve_key(self, css_id: str, consumer_id: str) -> str:
@@ -186,6 +188,9 @@ class ColorStripStreamManager:
# Inject gradient store for palette resolution # Inject gradient store for palette resolution
if self._gradient_store and hasattr(css_stream, "set_gradient_store"): if self._gradient_store and hasattr(css_stream, "set_gradient_store"):
css_stream.set_gradient_store(self._gradient_store) css_stream.set_gradient_store(self._gradient_store)
# Inject asset store for notification sound playback
if self._asset_store and hasattr(css_stream, "set_asset_store"):
css_stream.set_asset_store(self._asset_store)
# Inject sync clock runtime if source references a clock # Inject sync clock runtime if source references a clock
acquired_clock_id = self._inject_clock(css_stream, source) acquired_clock_id = self._inject_clock(css_stream, source)
css_stream.start() css_stream.start()

View File

@@ -55,6 +55,8 @@ class CompositeColorStripStream(ColorStripStream):
self._colors_lock = threading.Lock() self._colors_lock = threading.Lock()
self._need_layer_snapshots: bool = False # set True when get_layer_colors() is called self._need_layer_snapshots: bool = False # set True when get_layer_colors() is called
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
self._resize_cache: Dict[tuple, tuple] = {}
# layer_index -> (source_id, consumer_id, stream) # layer_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {} self._sub_streams: Dict[int, tuple] = {}
# layer_index -> (vs_id, value_stream) # layer_index -> (vs_id, value_stream)
@@ -560,9 +562,16 @@ class CompositeColorStripStream(ColorStripStream):
continue continue
# Resize to zone length # Resize to zone length
if len(colors) != zone_len: if len(colors) != zone_len:
src_x = np.linspace(0, 1, len(colors)) rkey = (len(colors), zone_len)
dst_x = np.linspace(0, 1, zone_len) cached = self._resize_cache.get(rkey)
resized = np.empty((zone_len, 3), dtype=np.uint8) if cached is None:
cached = (
np.linspace(0, 1, len(colors)),
np.linspace(0, 1, zone_len),
np.empty((zone_len, 3), dtype=np.uint8),
)
self._resize_cache[rkey] = cached
src_x, dst_x, resized = cached
for ch in range(3): for ch in range(3):
np.copyto(resized[:, ch], np.interp(dst_x, src_x, colors[:, ch]), casting="unsafe") np.copyto(resized[:, ch], np.interp(dst_x, src_x, colors[:, ch]), casting="unsafe")
colors = resized colors = resized

View File

@@ -100,6 +100,7 @@ class DeviceHealthMixin:
interval = ACTIVE_INTERVAL if self._is_device_streaming(device_id) else IDLE_INTERVAL interval = ACTIVE_INTERVAL if self._is_device_streaming(device_id) else IDLE_INTERVAL
await asyncio.sleep(interval) await asyncio.sleep(interval)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.debug("Device health monitor cancelled for %s", device_id)
pass pass
except Exception as e: except Exception as e:
logger.error(f"Fatal error in health check loop for {device_id}: {e}") logger.error(f"Fatal error in health check loop for {device_id}: {e}")

View File

@@ -193,6 +193,7 @@ class KCTargetProcessor(TargetProcessor):
try: try:
await self._task await self._task
except asyncio.CancelledError: except asyncio.CancelledError:
logger.debug("KC target processor task cancelled")
pass pass
self._task = None self._task = None
@@ -476,7 +477,8 @@ class KCTargetProcessor(TargetProcessor):
try: try:
await ws.send_text(message) await ws.send_text(message)
return True return True
except Exception: except Exception as e:
logger.debug("KC WS send failed: %s", e)
return False return False
clients = list(self._ws_clients) clients = list(self._ws_clients)

View File

@@ -11,7 +11,6 @@ releases them.
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, Optional from typing import Dict, Optional
import httpx
import numpy as np import numpy as np
from wled_controller.core.capture_engines import EngineRegistry from wled_controller.core.capture_engines import EngineRegistry
@@ -54,17 +53,19 @@ class LiveStreamManager:
enabling sharing at every level of the stream chain. enabling sharing at every level of the stream chain.
""" """
def __init__(self, picture_source_store, capture_template_store=None, pp_template_store=None): def __init__(self, picture_source_store, capture_template_store=None, pp_template_store=None, asset_store=None):
"""Initialize the live stream manager. """Initialize the live stream manager.
Args: Args:
picture_source_store: PictureSourceStore for resolving stream configs picture_source_store: PictureSourceStore for resolving stream configs
capture_template_store: TemplateStore for resolving capture engine settings capture_template_store: TemplateStore for resolving capture engine settings
pp_template_store: PostprocessingTemplateStore for resolving filter chains pp_template_store: PostprocessingTemplateStore for resolving filter chains
asset_store: AssetStore for resolving asset IDs to file paths
""" """
self._picture_source_store = picture_source_store self._picture_source_store = picture_source_store
self._capture_template_store = capture_template_store self._capture_template_store = capture_template_store
self._pp_template_store = pp_template_store self._pp_template_store = pp_template_store
self._asset_store = asset_store
self._streams: Dict[str, _LiveStreamEntry] = {} self._streams: Dict[str, _LiveStreamEntry] = {}
def acquire(self, picture_source_id: str) -> LiveStream: def acquire(self, picture_source_id: str) -> LiveStream:
@@ -268,6 +269,21 @@ class LiveStreamManager:
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}") logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
return resolved return resolved
def _resolve_asset_path(self, asset_id: str | None, label: str) -> str:
"""Resolve an asset ID to its on-disk file path string.
Raises:
ValueError: If asset not found, deleted, or file missing.
"""
if not asset_id:
raise ValueError(f"{label} has no asset ID configured")
if not self._asset_store:
raise ValueError(f"AssetStore not available to resolve {label}")
path = self._asset_store.get_file_path(asset_id)
if not path:
raise ValueError(f"Asset {asset_id} not found or file missing for {label}")
return str(path)
def _create_video_live_stream(self, config): def _create_video_live_stream(self, config):
"""Create a VideoCaptureLiveStream from a VideoCaptureSource config.""" """Create a VideoCaptureLiveStream from a VideoCaptureSource config."""
if not _has_video: if not _has_video:
@@ -275,8 +291,9 @@ class LiveStreamManager:
"OpenCV is required for video stream support. " "OpenCV is required for video stream support. "
"Install it with: pip install opencv-python-headless" "Install it with: pip install opencv-python-headless"
) )
video_path = self._resolve_asset_path(config.video_asset_id, "video source")
stream = VideoCaptureLiveStream( stream = VideoCaptureLiveStream(
url=config.url, url=video_path,
loop=config.loop, loop=config.loop,
playback_speed=config.playback_speed, playback_speed=config.playback_speed,
start_time=config.start_time, start_time=config.start_time,
@@ -300,27 +317,18 @@ class LiveStreamManager:
def _create_static_image_live_stream(self, config) -> StaticImageLiveStream: def _create_static_image_live_stream(self, config) -> StaticImageLiveStream:
"""Create a StaticImageLiveStream from a StaticImagePictureSource config.""" """Create a StaticImageLiveStream from a StaticImagePictureSource config."""
image = self._load_static_image(config.image_source) image_path = self._resolve_asset_path(config.image_asset_id, "static image source")
image = self._load_static_image(image_path)
return StaticImageLiveStream(image) return StaticImageLiveStream(image)
@staticmethod @staticmethod
def _load_static_image(image_source: str) -> np.ndarray: def _load_static_image(file_path: str) -> np.ndarray:
"""Load a static image from URL or file path, return as RGB numpy array. """Load a static image from a local file path, return as RGB numpy array."""
Note: Uses synchronous httpx.get() for URLs, which blocks up to 15s.
This is acceptable because acquire() (the only caller chain) is always
invoked from background worker threads, never from the async event loop.
"""
from pathlib import Path from pathlib import Path
from wled_controller.utils.image_codec import load_image_bytes, load_image_file from wled_controller.utils.image_codec import load_image_file
if image_source.startswith(("http://", "https://")): path = Path(file_path)
response = httpx.get(image_source, timeout=15.0, follow_redirects=True) if not path.exists():
response.raise_for_status() raise FileNotFoundError(f"Image file not found: {file_path}")
return load_image_bytes(response.content) return load_image_file(path)
else:
path = Path(image_source)
if not path.exists():
raise FileNotFoundError(f"Image file not found: {image_source}")
return load_image_file(path)

View File

@@ -40,6 +40,8 @@ class MappedColorStripStream(ColorStripStream):
# zone_index -> (source_id, consumer_id, stream) # zone_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {} self._sub_streams: Dict[int, tuple] = {}
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
self._resize_cache: Dict[tuple, tuple] = {}
self._sub_lock = threading.Lock() # guards _sub_streams access across threads self._sub_lock = threading.Lock() # guards _sub_streams access across threads
# ── ColorStripStream interface ────────────────────────────── # ── ColorStripStream interface ──────────────────────────────
@@ -210,9 +212,16 @@ class MappedColorStripStream(ColorStripStream):
# Resize sub-stream output to zone length if needed # Resize sub-stream output to zone length if needed
if len(colors) != zone_len: if len(colors) != zone_len:
src_x = np.linspace(0, 1, len(colors)) rkey = (len(colors), zone_len)
dst_x = np.linspace(0, 1, zone_len) cached = self._resize_cache.get(rkey)
resized = np.empty((zone_len, 3), dtype=np.uint8) if cached is None:
cached = (
np.linspace(0, 1, len(colors)),
np.linspace(0, 1, zone_len),
np.empty((zone_len, 3), dtype=np.uint8),
)
self._resize_cache[rkey] = cached
src_x, dst_x, resized = cached
for ch in range(3): for ch in range(3):
np.copyto( np.copyto(
resized[:, ch], resized[:, ch],

View File

@@ -38,7 +38,8 @@ def _collect_system_snapshot() -> dict:
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU) temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
snapshot["gpu_util"] = float(util.gpu) snapshot["gpu_util"] = float(util.gpu)
snapshot["gpu_temp"] = float(temp) snapshot["gpu_temp"] = float(temp)
except Exception: except Exception as e:
logger.debug("GPU metrics collection failed: %s", e)
pass pass
return snapshot return snapshot
@@ -67,6 +68,7 @@ class MetricsHistory:
try: try:
await self._task await self._task
except asyncio.CancelledError: except asyncio.CancelledError:
logger.debug("Metrics history collection task cancelled")
pass pass
self._task = None self._task = None
logger.info("Metrics history sampling stopped") logger.info("Metrics history sampling stopped")

View File

@@ -6,6 +6,7 @@ from any thread (REST handler) while get_latest_colors() is called from the
target processor thread. target processor thread.
Uses a background render loop at 30 FPS with double-buffered output. Uses a background render loop at 30 FPS with double-buffered output.
Optionally plays a notification sound via the asset store and sound player.
""" """
import collections import collections
@@ -61,8 +62,15 @@ class NotificationColorStripStream(ColorStripStream):
# Active effect state # Active effect state
self._active_effect: Optional[dict] = None # {"color": (r,g,b), "start": float} self._active_effect: Optional[dict] = None # {"color": (r,g,b), "start": float}
# Asset store for resolving sound file paths (injected via set_asset_store)
self._asset_store = None
self._update_from_source(source) self._update_from_source(source)
def set_asset_store(self, asset_store) -> None:
"""Inject asset store for resolving notification sound file paths."""
self._asset_store = asset_store
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
"""Parse config from source dataclass.""" """Parse config from source dataclass."""
self._notification_effect = getattr(source, "notification_effect", "flash") self._notification_effect = getattr(source, "notification_effect", "flash")
@@ -73,6 +81,11 @@ class NotificationColorStripStream(ColorStripStream):
self._app_filter_list = [a.lower() for a in getattr(source, "app_filter_list", [])] self._app_filter_list = [a.lower() for a in getattr(source, "app_filter_list", [])]
self._auto_size = not getattr(source, "led_count", 0) self._auto_size = not getattr(source, "led_count", 0)
self._led_count = getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1 self._led_count = getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1
# Sound config
self._sound_asset_id = getattr(source, "sound_asset_id", None)
self._sound_volume = float(getattr(source, "sound_volume", 1.0))
raw_app_sounds = dict(getattr(source, "app_sounds", {}))
self._app_sounds = {k.lower(): v for k, v in raw_app_sounds.items()}
with self._colors_lock: with self._colors_lock:
self._colors: Optional[np.ndarray] = np.zeros((self._led_count, 3), dtype=np.uint8) self._colors: Optional[np.ndarray] = np.zeros((self._led_count, 3), dtype=np.uint8)
@@ -109,8 +122,52 @@ class NotificationColorStripStream(ColorStripStream):
# Priority: 0 = normal, 1 = high (high interrupts current effect) # Priority: 0 = normal, 1 = high (high interrupts current effect)
priority = 1 if color_override else 0 priority = 1 if color_override else 0
self._event_queue.append({"color": color, "start": time.monotonic(), "priority": priority}) self._event_queue.append({"color": color, "start": time.monotonic(), "priority": priority})
# Play notification sound
self._play_notification_sound(app_lower)
return True return True
def _play_notification_sound(self, app_lower: Optional[str]) -> None:
"""Resolve and play the notification sound for the given app."""
if self._asset_store is None:
return
# Resolve sound: per-app override > global sound_asset_id
sound_asset_id = None
volume = self._sound_volume
if app_lower and app_lower in self._app_sounds:
override = self._app_sounds[app_lower]
if isinstance(override, dict):
# sound_asset_id=None in override means mute this app
if "sound_asset_id" not in override:
# No override entry, fall through to global
sound_asset_id = self._sound_asset_id
else:
sound_asset_id = override.get("sound_asset_id")
if sound_asset_id is None:
return # Muted for this app
override_volume = override.get("volume")
if override_volume is not None:
volume = float(override_volume)
else:
sound_asset_id = self._sound_asset_id
if not sound_asset_id:
return
file_path = self._asset_store.get_file_path(sound_asset_id)
if file_path is None:
logger.debug(f"Sound asset not found: {sound_asset_id}")
return
try:
from wled_controller.utils.sound_player import play_sound_async
play_sound_async(file_path, volume=volume)
except Exception as e:
logger.error(f"Failed to play notification sound: {e}")
def configure(self, device_led_count: int) -> None: def configure(self, device_led_count: int) -> None:
"""Set LED count from the target device (called on target start).""" """Set LED count from the target device (called on target start)."""
if self._auto_size and device_led_count > 0: if self._auto_size and device_led_count > 0:

View File

@@ -12,9 +12,11 @@ Supported platforms:
import asyncio import asyncio
import collections import collections
import json
import platform import platform
import threading import threading
import time import time
from pathlib import Path
from typing import Dict, List, Optional, Set from typing import Dict, List, Optional, Set
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -22,6 +24,8 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
_POLL_INTERVAL = 0.5 # seconds between polls (Windows only) _POLL_INTERVAL = 0.5 # seconds between polls (Windows only)
_HISTORY_FILE = Path("data/notification_history.json")
_HISTORY_MAX = 50
# Module-level singleton for dependency access # Module-level singleton for dependency access
_instance: Optional["OsNotificationListener"] = None _instance: Optional["OsNotificationListener"] = None
@@ -48,7 +52,8 @@ def _import_winrt_notifications():
) )
from winrt.windows.ui.notifications import NotificationKinds from winrt.windows.ui.notifications import NotificationKinds
return UserNotificationListener, UserNotificationListenerAccessStatus, NotificationKinds, "winrt" return UserNotificationListener, UserNotificationListenerAccessStatus, NotificationKinds, "winrt"
except ImportError: except ImportError as e:
logger.debug("winrt notification packages not available, trying winsdk: %s", e)
pass pass
# Fallback: winsdk (~35MB, may already be installed) # Fallback: winsdk (~35MB, may already be installed)
@@ -282,7 +287,8 @@ class OsNotificationListener:
self._available = False self._available = False
self._backend = None self._backend = None
# Recent notification history (thread-safe deque, newest first) # Recent notification history (thread-safe deque, newest first)
self._history: collections.deque = collections.deque(maxlen=50) self._history: collections.deque = collections.deque(maxlen=_HISTORY_MAX)
self._load_history()
@property @property
def available(self) -> bool: def available(self) -> bool:
@@ -308,6 +314,7 @@ class OsNotificationListener:
if self._backend: if self._backend:
self._backend.stop() self._backend.stop()
self._backend = None self._backend = None
self._save_history()
logger.info("OS notification listener stopped") logger.info("OS notification listener stopped")
@property @property
@@ -315,6 +322,29 @@ class OsNotificationListener:
"""Return recent notification history (newest first).""" """Return recent notification history (newest first)."""
return list(self._history) return list(self._history)
def _load_history(self) -> None:
"""Load persisted notification history from disk."""
try:
if _HISTORY_FILE.exists():
data = json.loads(_HISTORY_FILE.read_text(encoding="utf-8"))
if isinstance(data, list):
for entry in data[:_HISTORY_MAX]:
self._history.append(entry)
logger.info(f"Loaded {len(self._history)} notification history entries")
except Exception as exc:
logger.warning(f"Failed to load notification history: {exc}")
def _save_history(self) -> None:
"""Persist notification history to disk."""
try:
_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
_HISTORY_FILE.write_text(
json.dumps(list(self._history), ensure_ascii=False),
encoding="utf-8",
)
except Exception as exc:
logger.warning(f"Failed to save notification history: {exc}")
def _on_new_notification(self, app_name: Optional[str]) -> None: def _on_new_notification(self, app_name: Optional[str]) -> None:
"""Handle a new OS notification — fire matching streams.""" """Handle a new OS notification — fire matching streams."""
from wled_controller.storage.color_strip_source import NotificationColorStripSource from wled_controller.storage.color_strip_source import NotificationColorStripSource
@@ -347,5 +377,6 @@ class OsNotificationListener:
"filtered": filtered, "filtered": filtered,
} }
self._history.appendleft(entry) self._history.appendleft(entry)
self._save_history()
logger.info(f"OS notification captured: app={app_name!r}, fired={fired}, filtered={filtered}") logger.info(f"OS notification captured: app={app_name!r}, fired={fired}, filtered={filtered}")

View File

@@ -28,6 +28,20 @@ from wled_controller.core.processing.auto_restart import (
RESTART_MAX_ATTEMPTS as _RESTART_MAX_ATTEMPTS, RESTART_MAX_ATTEMPTS as _RESTART_MAX_ATTEMPTS,
RESTART_WINDOW_SEC as _RESTART_WINDOW_SEC, RESTART_WINDOW_SEC as _RESTART_WINDOW_SEC,
) )
from wled_controller.storage import DeviceStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.asset_store import AssetStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.processing.device_health import DeviceHealthMixin from wled_controller.core.processing.device_health import DeviceHealthMixin
from wled_controller.core.processing.device_test_mode import DeviceTestModeMixin from wled_controller.core.processing.device_test_mode import DeviceTestModeMixin
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -44,19 +58,20 @@ class ProcessorDependencies:
Keeps the constructor signature stable when new stores are added. Keeps the constructor signature stable when new stores are added.
""" """
picture_source_store: object = None picture_source_store: Optional[PictureSourceStore] = None
capture_template_store: object = None capture_template_store: Optional[TemplateStore] = None
pp_template_store: object = None pp_template_store: Optional[PostprocessingTemplateStore] = None
pattern_template_store: object = None pattern_template_store: Optional[PatternTemplateStore] = None
device_store: object = None device_store: Optional[DeviceStore] = None
color_strip_store: object = None color_strip_store: Optional[ColorStripStore] = None
audio_source_store: object = None audio_source_store: Optional[AudioSourceStore] = None
audio_template_store: object = None audio_template_store: Optional[AudioTemplateStore] = None
value_source_store: object = None value_source_store: Optional[ValueSourceStore] = None
sync_clock_manager: object = None sync_clock_manager: Optional[SyncClockManager] = None
cspt_store: object = None cspt_store: Optional[ColorStripProcessingTemplateStore] = None
gradient_store: object = None gradient_store: Optional[GradientStore] = None
weather_manager: object = None weather_manager: Optional[WeatherManager] = None
asset_store: Optional[AssetStore] = None
@dataclass @dataclass
@@ -119,7 +134,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
self._value_source_store = deps.value_source_store self._value_source_store = deps.value_source_store
self._cspt_store = deps.cspt_store self._cspt_store = deps.cspt_store
self._live_stream_manager = LiveStreamManager( self._live_stream_manager = LiveStreamManager(
deps.picture_source_store, deps.capture_template_store, deps.pp_template_store deps.picture_source_store, deps.capture_template_store, deps.pp_template_store,
asset_store=deps.asset_store,
) )
self._audio_capture_manager = AudioCaptureManager() self._audio_capture_manager = AudioCaptureManager()
self._sync_clock_manager = deps.sync_clock_manager self._sync_clock_manager = deps.sync_clock_manager
@@ -133,6 +149,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
cspt_store=deps.cspt_store, cspt_store=deps.cspt_store,
gradient_store=deps.gradient_store, gradient_store=deps.gradient_store,
weather_manager=deps.weather_manager, weather_manager=deps.weather_manager,
asset_store=deps.asset_store,
) )
self._value_stream_manager = ValueStreamManager( self._value_stream_manager = ValueStreamManager(
value_source_store=deps.value_source_store, value_source_store=deps.value_source_store,
@@ -206,7 +223,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
dev = self._device_store.get_device(ds.device_id) dev = self._device_store.get_device(ds.device_id)
for key, default in self._DEVICE_FIELD_DEFAULTS.items(): for key, default in self._DEVICE_FIELD_DEFAULTS.items():
extras[key] = getattr(dev, key, default) extras[key] = getattr(dev, key, default)
except ValueError: except ValueError as e:
logger.debug("Device %s not found in store, using defaults: %s", ds.device_id, e)
pass pass
return DeviceInfo( return DeviceInfo(
@@ -348,7 +366,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
try: try:
dev = self._device_store.get_device(device_id) dev = self._device_store.get_device(device_id)
rgbw = getattr(dev, "rgbw", False) rgbw = getattr(dev, "rgbw", False)
except ValueError: except ValueError as e:
logger.debug("Device %s not found for RGBW lookup: %s", device_id, e)
pass pass
return { return {
"device_id": device_id, "device_id": device_id,
@@ -523,7 +542,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
try: try:
dev = self._device_store.get_device(proc.device_id) dev = self._device_store.get_device(proc.device_id)
dev_name = dev.name dev_name = dev.name
except ValueError: except ValueError as e:
logger.debug("Device %s not found for name lookup: %s", proc.device_id, e)
pass pass
raise RuntimeError( raise RuntimeError(
f"Device '{dev_name}' is already being processed by target {tgt_name}" f"Device '{dev_name}' is already being processed by target {tgt_name}"

View File

@@ -43,11 +43,18 @@ class SyncClockRuntime:
"""Pause-aware elapsed seconds since creation/last reset. """Pause-aware elapsed seconds since creation/last reset.
Returns *real* (wall-clock) elapsed time, not speed-scaled. Returns *real* (wall-clock) elapsed time, not speed-scaled.
Lock-free: under CPython's GIL, reading individual attributes is
atomic. pause()/resume() update _offset and _epoch under a lock,
so the reader sees a consistent pre- or post-update snapshot.
The worst case is a one-frame stale value, which is imperceptible.
""" """
with self._lock: running = self._running
if not self._running: offset = self._offset
return self._offset epoch = self._epoch
return self._offset + (time.perf_counter() - self._epoch) if not running:
return offset
return offset + (time.perf_counter() - epoch)
# ── Control ──────────────────────────────────────────────────── # ── Control ────────────────────────────────────────────────────

View File

@@ -207,7 +207,8 @@ class AudioValueStream(ValueStream):
tpl = self._audio_template_store.get_template(template_id) tpl = self._audio_template_store.get_template(template_id)
self._audio_engine_type = tpl.engine_type self._audio_engine_type = tpl.engine_type
self._audio_engine_config = tpl.engine_config self._audio_engine_config = tpl.engine_config
except ValueError: except ValueError as e:
logger.warning("Audio template %s not found for value stream, using default engine: %s", template_id, e)
pass pass
except ValueError as e: except ValueError as e:
logger.warning(f"Failed to resolve audio source {self._audio_source_id}: {e}") logger.warning(f"Failed to resolve audio source {self._audio_source_id}: {e}")
@@ -671,7 +672,8 @@ class ValueStreamManager:
"""Hot-update the shared stream for the given ValueSource.""" """Hot-update the shared stream for the given ValueSource."""
try: try:
source = self._value_source_store.get_source(vs_id) source = self._value_source_store.get_source(vs_id)
except ValueError: except ValueError as e:
logger.debug("Value source %s not found for hot-update: %s", vs_id, e)
return return
stream = self._streams.get(vs_id) stream = self._streams.get(vs_id)

View File

@@ -181,7 +181,8 @@ class WeatherColorStripStream(ColorStripStream):
if self._weather_source_id: if self._weather_source_id:
try: try:
self._weather_manager.release(self._weather_source_id) self._weather_manager.release(self._weather_source_id)
except Exception: except Exception as e:
logger.debug("Weather source release during update: %s", e)
pass pass
self._weather_source_id = new_ws_id self._weather_source_id = new_ws_id
if new_ws_id: if new_ws_id:
@@ -208,7 +209,8 @@ class WeatherColorStripStream(ColorStripStream):
# are looked up at the ProcessorManager level when the stream # are looked up at the ProcessorManager level when the stream
# is created. For now, return None and use wall time. # is created. For now, return None and use wall time.
return None return None
except Exception: except Exception as e:
logger.debug("Sync clock lookup failed for weather stream: %s", e)
return None return None
def _animate_loop(self) -> None: def _animate_loop(self) -> None:
@@ -278,5 +280,6 @@ class WeatherColorStripStream(ColorStripStream):
return DEFAULT_WEATHER return DEFAULT_WEATHER
try: try:
return self._weather_manager.get_data(self._weather_source_id) return self._weather_manager.get_data(self._weather_source_id)
except Exception: except Exception as e:
logger.debug("Weather data fetch failed, using default: %s", e)
return DEFAULT_WEATHER return DEFAULT_WEATHER

View File

@@ -72,6 +72,7 @@ class WledTargetProcessor(TargetProcessor):
self._fit_cache_key: tuple = (0, 0) self._fit_cache_key: tuple = (0, 0)
self._fit_cache_src: Optional[np.ndarray] = None self._fit_cache_src: Optional[np.ndarray] = None
self._fit_cache_dst: Optional[np.ndarray] = None self._fit_cache_dst: Optional[np.ndarray] = None
self._fit_result_buf: Optional[np.ndarray] = None
# LED preview WebSocket clients # LED preview WebSocket clients
self._preview_clients: list = [] self._preview_clients: list = []
@@ -207,6 +208,7 @@ class WledTargetProcessor(TargetProcessor):
try: try:
await self._task await self._task
except asyncio.CancelledError: except asyncio.CancelledError:
logger.debug("WLED target processor task cancelled")
pass pass
self._task = None self._task = None
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
@@ -341,7 +343,8 @@ class WledTargetProcessor(TargetProcessor):
try: try:
resp = await client.get(f"{device_url}/json/info") resp = await client.get(f"{device_url}/json/info")
return resp.status_code == 200 return resp.status_code == 200
except Exception: except Exception as e:
logger.debug("Device probe failed for %s: %s", device_url, e)
return False return False
def get_display_index(self) -> Optional[int]: def get_display_index(self) -> Optional[int]:
@@ -525,7 +528,8 @@ class WledTargetProcessor(TargetProcessor):
async def _send_preview_to(ws, data: bytes) -> None: async def _send_preview_to(ws, data: bytes) -> None:
try: try:
await ws.send_bytes(data) await ws.send_bytes(data)
except Exception: except Exception as e:
logger.debug("LED preview WS send failed: %s", e)
pass pass
def remove_led_preview_client(self, ws) -> None: def remove_led_preview_client(self, ws) -> None:
@@ -569,7 +573,8 @@ class WledTargetProcessor(TargetProcessor):
try: try:
await ws.send_bytes(data) await ws.send_bytes(data)
return True return True
except Exception: except Exception as e:
logger.debug("LED preview broadcast WS send failed: %s", e)
return False return False
clients = list(self._preview_clients) clients = list(self._preview_clients)
@@ -591,11 +596,62 @@ class WledTargetProcessor(TargetProcessor):
self._fit_cache_src = np.linspace(0, 1, n) self._fit_cache_src = np.linspace(0, 1, n)
self._fit_cache_dst = np.linspace(0, 1, device_led_count) self._fit_cache_dst = np.linspace(0, 1, device_led_count)
self._fit_cache_key = key self._fit_cache_key = key
result = np.column_stack([ self._fit_result_buf = np.empty((device_led_count, 3), dtype=np.uint8)
np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]).astype(np.uint8) buf = self._fit_result_buf
for ch in range(colors.shape[1]) for ch in range(min(colors.shape[1], 3)):
]) np.copyto(
return result buf[:, ch],
np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]),
casting="unsafe",
)
return buf
async def _send_to_device(self, send_colors: np.ndarray) -> float:
"""Send colors to LED device and return send time in ms."""
t_start = time.perf_counter()
if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors)
else:
await self._led_client.send_pixels(send_colors)
return (time.perf_counter() - t_start) * 1000
@staticmethod
def _emit_diagnostics(
target_id: str,
sleep_jitters: collections.deque,
iter_times: collections.deque,
slow_iters: collections.deque,
frame_time: float,
diag_interval: float,
) -> None:
"""Log periodic timing diagnostics and clear the deques."""
if sleep_jitters:
jitters = [a - r for r, a in sleep_jitters]
avg_j = sum(jitters) / len(jitters)
max_j = max(jitters)
p95_j = sorted(jitters)[int(len(jitters) * 0.95)] if len(jitters) >= 20 else max_j
logger.info(
f"[DIAG] {target_id} sleep jitter: "
f"avg={avg_j:.1f}ms max={max_j:.1f}ms p95={p95_j:.1f}ms "
f"(n={len(sleep_jitters)})"
)
if iter_times:
avg_iter = sum(iter_times) / len(iter_times)
max_iter = max(iter_times)
logger.info(
f"[DIAG] {target_id} iter: "
f"avg={avg_iter:.1f}ms max={max_iter:.1f}ms "
f"target={frame_time*1000:.1f}ms iters={len(iter_times)}"
)
if slow_iters:
logger.warning(
f"[DIAG] {target_id} slow iterations: "
f"{len(slow_iters)} in last {diag_interval}s — "
f"{list(slow_iters)[:5]}"
)
sleep_jitters.clear()
slow_iters.clear()
iter_times.clear()
async def _processing_loop(self) -> None: async def _processing_loop(self) -> None:
"""Main processing loop — poll CSS stream -> brightness -> send.""" """Main processing loop — poll CSS stream -> brightness -> send."""
@@ -608,7 +664,9 @@ class WledTargetProcessor(TargetProcessor):
def _fps_current_from_timestamps(): def _fps_current_from_timestamps():
"""Count timestamps within the last second.""" """Count timestamps within the last second."""
cutoff = time.perf_counter() - 1.0 cutoff = time.perf_counter() - 1.0
return sum(1 for ts in send_timestamps if ts > cutoff) while send_timestamps and send_timestamps[0] <= cutoff:
send_timestamps.popleft()
return len(send_timestamps)
last_send_time = 0.0 last_send_time = 0.0
_last_preview_broadcast = 0.0 _last_preview_broadcast = 0.0
@@ -844,10 +902,7 @@ class WledTargetProcessor(TargetProcessor):
self._fit_to_device(prev_frame_ref, _total_leds), self._fit_to_device(prev_frame_ref, _total_leds),
cur_brightness, cur_brightness,
) )
if self._led_client.supports_fast_send: await self._send_to_device(send_colors)
self._led_client.send_pixels_fast(send_colors)
else:
await self._led_client.send_pixels(send_colors)
now = time.perf_counter() now = time.perf_counter()
last_send_time = now last_send_time = now
send_timestamps.append(now) send_timestamps.append(now)
@@ -879,10 +934,7 @@ class WledTargetProcessor(TargetProcessor):
self._fit_to_device(prev_frame_ref, _total_leds), self._fit_to_device(prev_frame_ref, _total_leds),
cur_brightness, cur_brightness,
) )
if self._led_client.supports_fast_send: await self._send_to_device(send_colors)
self._led_client.send_pixels_fast(send_colors)
else:
await self._led_client.send_pixels(send_colors)
now = time.perf_counter() now = time.perf_counter()
last_send_time = now last_send_time = now
send_timestamps.append(now) send_timestamps.append(now)
@@ -910,12 +962,7 @@ class WledTargetProcessor(TargetProcessor):
# Send to LED device # Send to LED device
if not self._is_running or self._led_client is None: if not self._is_running or self._led_client is None:
break break
t_send_start = time.perf_counter() send_ms = await self._send_to_device(send_colors)
if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors)
else:
await self._led_client.send_pixels(send_colors)
send_ms = (time.perf_counter() - t_send_start) * 1000
now = time.perf_counter() now = time.perf_counter()
last_send_time = now last_send_time = now
@@ -984,33 +1031,11 @@ class WledTargetProcessor(TargetProcessor):
# Periodic diagnostics report # Periodic diagnostics report
if iter_end >= _diag_next_report: if iter_end >= _diag_next_report:
_diag_next_report = iter_end + _diag_interval _diag_next_report = iter_end + _diag_interval
if _diag_sleep_jitters: self._emit_diagnostics(
jitters = [a - r for r, a in _diag_sleep_jitters] self._target_id, _diag_sleep_jitters,
avg_j = sum(jitters) / len(jitters) _diag_iter_times, _diag_slow_iters,
max_j = max(jitters) frame_time, _diag_interval,
p95_j = sorted(jitters)[int(len(jitters) * 0.95)] if len(jitters) >= 20 else max_j )
logger.info(
f"[DIAG] {self._target_id} sleep jitter: "
f"avg={avg_j:.1f}ms max={max_j:.1f}ms p95={p95_j:.1f}ms "
f"(n={len(_diag_sleep_jitters)})"
)
if _diag_iter_times:
avg_iter = sum(_diag_iter_times) / len(_diag_iter_times)
max_iter = max(_diag_iter_times)
logger.info(
f"[DIAG] {self._target_id} iter: "
f"avg={avg_iter:.1f}ms max={max_iter:.1f}ms "
f"target={frame_time*1000:.1f}ms iters={len(_diag_iter_times)}"
)
if _diag_slow_iters:
logger.warning(
f"[DIAG] {self._target_id} slow iterations: "
f"{len(_diag_slow_iters)} in last {_diag_interval}s — "
f"{list(_diag_slow_iters)[:5]}"
)
_diag_sleep_jitters.clear()
_diag_slow_iters.clear()
_diag_iter_times.clear()
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info(f"Processing loop cancelled for target {self._target_id}") logger.info(f"Processing loop cancelled for target {self._target_id}")

View File

@@ -131,6 +131,7 @@ class UpdateService:
except Exception as exc: except Exception as exc:
logger.error("Update check failed: %s", exc, exc_info=True) logger.error("Update check failed: %s", exc, exc_info=True)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.debug("Update check loop cancelled")
pass pass
# ── Core check logic ─────────────────────────────────────── # ── Core check logic ───────────────────────────────────────
@@ -172,7 +173,8 @@ class UpdateService:
continue continue
try: try:
normalize_version(release.version) normalize_version(release.version)
except Exception: except Exception as e:
logger.debug("Skipping release with unparseable version %s: %s", release.version, e)
continue continue
if is_newer(release.version, __version__): if is_newer(release.version, __version__):
return release return release
@@ -317,6 +319,10 @@ class UpdateService:
shutil.rmtree(staging) shutil.rmtree(staging)
staging.mkdir(parents=True) staging.mkdir(parents=True)
with zipfile.ZipFile(zip_path, "r") as zf: with zipfile.ZipFile(zip_path, "r") as zf:
for member in zf.namelist():
target = (staging / member).resolve()
if not target.is_relative_to(staging.resolve()):
raise ValueError(f"Zip entry escapes target directory: {member}")
zf.extractall(staging) zf.extractall(staging)
await asyncio.to_thread(_extract) await asyncio.to_thread(_extract)

View File

@@ -4,10 +4,13 @@ Normalizes Gitea-style tags (v0.3.0-alpha.1) to PEP 440 (0.3.0a1)
so that ``packaging.version.Version`` can compare them correctly. so that ``packaging.version.Version`` can compare them correctly.
""" """
import logging
import re import re
from packaging.version import InvalidVersion, Version from packaging.version import InvalidVersion, Version
logger = logging.getLogger(__name__)
_PRE_MAP = { _PRE_MAP = {
"alpha": "a", "alpha": "a",
@@ -41,5 +44,6 @@ def is_newer(candidate: str, current: str) -> bool:
""" """
try: try:
return normalize_version(candidate) > normalize_version(current) return normalize_version(candidate) > normalize_version(current)
except InvalidVersion: except InvalidVersion as e:
logger.debug("Unparseable version string in comparison: %s", e)
return False return False

View File

@@ -34,6 +34,7 @@ from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.storage.gradient_store import GradientStore from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.weather_source_store import WeatherSourceStore from wled_controller.storage.weather_source_store import WeatherSourceStore
from wled_controller.storage.asset_store import AssetStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.core.weather.weather_manager import WeatherManager from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.automations.automation_engine import AutomationEngine from wled_controller.core.automations.automation_engine import AutomationEngine
@@ -80,6 +81,12 @@ cspt_store = ColorStripProcessingTemplateStore(db)
gradient_store = GradientStore(db) gradient_store = GradientStore(db)
gradient_store.migrate_palette_references(color_strip_store) gradient_store.migrate_palette_references(color_strip_store)
weather_source_store = WeatherSourceStore(db) weather_source_store = WeatherSourceStore(db)
asset_store = AssetStore(db, config.assets.assets_dir)
# Import prebuilt notification sounds on first run
_prebuilt_sounds_dir = Path(__file__).parent / "data" / "prebuilt_sounds"
asset_store.import_prebuilt_sounds(_prebuilt_sounds_dir)
sync_clock_manager = SyncClockManager(sync_clock_store) sync_clock_manager = SyncClockManager(sync_clock_store)
weather_manager = WeatherManager(weather_source_store) weather_manager = WeatherManager(weather_source_store)
@@ -98,6 +105,7 @@ processor_manager = ProcessorManager(
cspt_store=cspt_store, cspt_store=cspt_store,
gradient_store=gradient_store, gradient_store=gradient_store,
weather_manager=weather_manager, weather_manager=weather_manager,
asset_store=asset_store,
) )
) )
@@ -190,6 +198,7 @@ async def lifespan(app: FastAPI):
weather_source_store=weather_source_store, weather_source_store=weather_source_store,
weather_manager=weather_manager, weather_manager=weather_manager,
update_service=update_service, update_service=update_service,
asset_store=asset_store,
) )
# Register devices in processor manager for health monitoring # Register devices in processor manager for health monitoring

View File

@@ -7,6 +7,10 @@ mechanism the system tray "Shutdown" menu item uses.
from typing import Any, Optional from typing import Any, Optional
from wled_controller.utils import get_logger
logger = get_logger(__name__)
_server: Optional[Any] = None # uvicorn.Server _server: Optional[Any] = None # uvicorn.Server
_tray: Optional[Any] = None # TrayManager _tray: Optional[Any] = None # TrayManager
@@ -39,7 +43,8 @@ def request_shutdown() -> None:
try: try:
from wled_controller.main import _save_all_stores from wled_controller.main import _save_all_stores
_save_all_stores() _save_all_stores()
except Exception: except Exception as e:
logger.debug("Best-effort store save on shutdown failed: %s", e)
pass # best-effort; lifespan handler is the backup pass # best-effort; lifespan handler is the backup
if _server is not None: if _server is not None:
@@ -55,5 +60,6 @@ def _broadcast_restarting() -> None:
pm = _deps.get("processor_manager") pm = _deps.get("processor_manager")
if pm is not None: if pm is not None:
pm.fire_event({"type": "server_restarting"}) pm.fire_event({"type": "server_restarting"})
except Exception: except Exception as e:
logger.debug("Failed to broadcast server_restarting event: %s", e)
pass pass

View File

@@ -215,21 +215,6 @@
align-items: center; align-items: center;
} }
.btn-browse-apps {
background: none;
border: 1px solid var(--border-color);
color: var(--text-color);
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.btn-browse-apps:hover {
border-color: var(--primary-color);
background: rgba(33, 150, 243, 0.1);
}
/* Webhook URL row */ /* Webhook URL row */
.webhook-url-row { .webhook-url-row {

View File

@@ -1466,64 +1466,42 @@
line-height: 1; line-height: 1;
} }
/* ── Notification app color mappings ─────────────────────────── */ /* ── Notification per-app overrides (unified color + sound) ──── */
.notif-app-color-row { .notif-override-row {
display: flex; display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 4px 4px;
align-items: center; align-items: center;
gap: 6px; margin-bottom: 6px;
margin-bottom: 4px; padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
} }
.notif-app-color-row .notif-app-name { .notif-override-row .notif-override-name,
flex: 1; .notif-override-row .notif-override-sound {
min-width: 0; min-width: 0;
} }
.notif-app-color-row .notif-app-color { /* Sound select spans the first column, volume spans browse+color columns */
width: 28px; .notif-override-row .notif-override-sound {
height: 28px; grid-column: 1;
}
.notif-override-row .notif-override-volume {
grid-column: 2 / 4;
width: 100%;
}
.notif-override-row .notif-override-color {
width: 26px;
height: 26px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
padding: 1px; padding: 1px;
cursor: pointer; cursor: pointer;
background: transparent; background: transparent;
flex-shrink: 0;
} }
.notif-app-browse,
.notif-app-color-remove {
background: none;
border: 1px solid var(--border-color);
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
min-width: unset;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color 0.2s, color 0.2s;
}
.notif-app-browse svg {
width: 14px;
height: 14px;
}
.notif-app-color-remove {
font-size: 0.75rem;
line-height: 1;
}
.notif-app-browse:hover,
.notif-app-color-remove:hover {
border-color: var(--primary-color);
color: var(--text-color);
}
/* ── Notification history list ─────────────────────────────────── */ /* ── Notification history list ─────────────────────────────────── */
@@ -1866,3 +1844,128 @@ body.composite-layer-dragging .composite-layer-drag-handle {
opacity: 0 !important; opacity: 0 !important;
} }
/* ── File drop zone ── */
.file-dropzone {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px 20px;
border: 2px dashed var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-color);
cursor: pointer;
transition: border-color var(--duration-normal) ease,
background var(--duration-normal) ease,
box-shadow var(--duration-normal) ease;
user-select: none;
outline: none;
}
.file-dropzone:hover {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--bg-color));
}
.file-dropzone:focus-visible {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15);
}
.file-dropzone.dragover {
border-color: var(--primary-color);
border-style: solid;
background: color-mix(in srgb, var(--primary-color) 10%, var(--bg-color));
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15),
inset 0 0 20px rgba(76, 175, 80, 0.06);
}
.file-dropzone.has-file {
border-style: solid;
border-color: var(--primary-color);
padding: 16px 20px;
}
.file-dropzone-icon {
color: var(--text-muted);
transition: color var(--duration-normal) ease, transform var(--duration-normal) var(--ease-spring);
}
.file-dropzone:hover .file-dropzone-icon,
.file-dropzone.dragover .file-dropzone-icon {
color: var(--primary-color);
transform: translateY(-2px);
}
.file-dropzone.has-file .file-dropzone-icon {
color: var(--primary-color);
}
.file-dropzone-text {
text-align: center;
}
.file-dropzone-label {
font-size: 0.9rem;
color: var(--text-muted);
transition: color var(--duration-normal) ease;
}
.file-dropzone:hover .file-dropzone-label {
color: var(--text-secondary);
}
.file-dropzone-info {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: var(--radius-sm);
background: var(--bg-secondary);
width: 100%;
max-width: 100%;
overflow: hidden;
}
.file-dropzone-filename {
flex: 1;
font-size: 0.88rem;
font-weight: var(--weight-medium);
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-dropzone-filesize {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
font-family: var(--font-mono);
}
.file-dropzone-remove {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font-size: 1.1rem;
cursor: pointer;
transition: background var(--duration-fast) ease, color var(--duration-fast) ease;
line-height: 1;
padding: 0;
}
.file-dropzone-remove:hover {
background: var(--danger-color);
color: white;
}

View File

@@ -137,7 +137,7 @@ import {
previewCSSFromEditor, previewCSSFromEditor,
copyEndpointUrl, copyEndpointUrl,
onNotificationFilterModeChange, onNotificationFilterModeChange,
notificationAddAppColor, notificationRemoveAppColor, notificationAddAppOverride, notificationRemoveAppOverride,
testNotification, testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer, testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
@@ -464,7 +464,7 @@ Object.assign(window, {
previewCSSFromEditor, previewCSSFromEditor,
copyEndpointUrl, copyEndpointUrl,
onNotificationFilterModeChange, onNotificationFilterModeChange,
notificationAddAppColor, notificationRemoveAppColor, notificationAddAppOverride, notificationRemoveAppOverride,
testNotification, testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer, testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,

View File

@@ -84,3 +84,6 @@ export const circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0
export const externalLink = '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>'; export const externalLink = '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>';
export const thermometer = '<path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"/>'; export const thermometer = '<path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"/>';
export const xIcon = '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'; export const xIcon = '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>';
export const fileUp = '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>';
export const fileAudio = '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><circle cx="10" cy="16" r="2"/><path d="M12 12v4"/>';
export const packageIcon = '<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>';

View File

@@ -186,3 +186,17 @@ export const ICON_LIST_CHECKS = _svg(P.listChecks);
export const ICON_CIRCLE_OFF = _svg(P.circleOff); export const ICON_CIRCLE_OFF = _svg(P.circleOff);
export const ICON_EXTERNAL_LINK = _svg(P.externalLink); export const ICON_EXTERNAL_LINK = _svg(P.externalLink);
export const ICON_X = _svg(P.xIcon); export const ICON_X = _svg(P.xIcon);
export const ICON_FILE_UP = _svg(P.fileUp);
export const ICON_FILE_AUDIO = _svg(P.fileAudio);
export const ICON_ASSET = _svg(P.packageIcon);
/** Asset type → icon (fallback: file) */
export function getAssetTypeIcon(assetType: string): string {
const map: Record<string, string> = {
sound: _svg(P.volume2),
image: _svg(P.image),
video: _svg(P.film),
other: _svg(P.fileText),
};
return map[assetType] || _svg(P.fileText);
}

View File

@@ -10,7 +10,7 @@ import { DataCache } from './cache.ts';
import type { import type {
Device, OutputTarget, ColorStripSource, PatternTemplate, Device, OutputTarget, ColorStripSource, PatternTemplate,
ValueSource, AudioSource, PictureSource, ScenePreset, ValueSource, AudioSource, PictureSource, ScenePreset,
SyncClock, WeatherSource, Automation, Display, FilterDef, EngineInfo, SyncClock, WeatherSource, Asset, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate, CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle, ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
} from '../types.ts'; } from '../types.ts';
@@ -226,6 +226,7 @@ export let _cachedValueSources: ValueSource[] = [];
// Sync clocks // Sync clocks
export let _cachedSyncClocks: SyncClock[] = []; export let _cachedSyncClocks: SyncClock[] = [];
export let _cachedWeatherSources: WeatherSource[] = []; export let _cachedWeatherSources: WeatherSource[] = [];
export let _cachedAssets: Asset[] = [];
// Automations // Automations
export let _automationsCache: Automation[] | null = null; export let _automationsCache: Automation[] | null = null;
@@ -289,6 +290,12 @@ export const weatherSourcesCache = new DataCache<WeatherSource[]>({
}); });
weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; }); weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; });
export const assetsCache = new DataCache<Asset[]>({
endpoint: '/assets',
extractData: json => json.assets || [],
});
assetsCache.subscribe(v => { _cachedAssets = v; });
export const filtersCache = new DataCache<FilterDef[]>({ export const filtersCache = new DataCache<FilterDef[]>({
endpoint: '/filters', endpoint: '/filters',
extractData: json => json.filters || [], extractData: json => json.filters || [],

View File

@@ -0,0 +1,475 @@
/**
* Assets — file upload/download, CRUD, cards, modal.
*/
import { _cachedAssets, assetsCache } from '../core/state.ts';
import { fetchWithAuth, escapeHtml, API_BASE, getHeaders } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_DOWNLOAD, ICON_ASSET, ICON_TRASH, getAssetTypeIcon } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { loadPictureSources } from './streams.ts';
import type { Asset } from '../types.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
const ICON_PLAY_SOUND = _icon(P.play);
const ICON_UPLOAD = _icon(P.fileUp);
const ICON_RESTORE = _icon(P.rotateCcw);
// ── Helpers ──
let _dropzoneInitialized = false;
/** Initialise the drop-zone wiring for the upload modal (once). */
function initUploadDropzone(): void {
if (_dropzoneInitialized) return;
_dropzoneInitialized = true;
const dropzone = document.getElementById('asset-upload-dropzone')!;
const fileInput = document.getElementById('asset-upload-file') as HTMLInputElement;
const infoEl = document.getElementById('asset-upload-file-info')!;
const nameEl = document.getElementById('asset-upload-file-name')!;
const sizeEl = document.getElementById('asset-upload-file-size')!;
const removeBtn = document.getElementById('asset-upload-file-remove')!;
const showFile = (file: File) => {
nameEl.textContent = file.name;
sizeEl.textContent = formatFileSize(file.size);
infoEl.style.display = '';
dropzone.classList.add('has-file');
// Hide the prompt text when a file is selected
const labelEl = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
if (labelEl) labelEl.style.display = 'none';
const iconEl = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
if (iconEl) iconEl.style.display = 'none';
};
const clearFile = () => {
fileInput.value = '';
infoEl.style.display = 'none';
dropzone.classList.remove('has-file');
const labelEl = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
if (labelEl) labelEl.style.display = '';
const iconEl = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
if (iconEl) iconEl.style.display = '';
};
// Click → open file picker
dropzone.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('.file-dropzone-remove')) return;
fileInput.click();
});
// Keyboard: Enter/Space
dropzone.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInput.click();
}
});
// File input change
fileInput.addEventListener('change', () => {
if (fileInput.files && fileInput.files.length > 0) {
showFile(fileInput.files[0]);
} else {
clearFile();
}
});
// Drag events
let dragCounter = 0;
dropzone.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', () => {
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
dropzone.classList.remove('dragover');
}
});
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
});
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dragCounter = 0;
dropzone.classList.remove('dragover');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
fileInput.files = files;
showFile(files[0]);
}
});
// Remove button
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
clearFile();
});
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getAssetTypeLabel(assetType: string): string {
const map: Record<string, string> = {
sound: t('asset.type.sound'),
image: t('asset.type.image'),
video: t('asset.type.video'),
other: t('asset.type.other'),
};
return map[assetType] || assetType;
}
// ── Card builder ──
export function createAssetCard(asset: Asset): string {
const icon = getAssetTypeIcon(asset.asset_type);
const sizeStr = formatFileSize(asset.size_bytes);
const prebuiltBadge = asset.prebuilt
? `<span class="stream-card-prop" title="${escapeHtml(t('asset.prebuilt'))}">${_icon(P.star)} ${t('asset.prebuilt')}</span>`
: '';
let playBtn = '';
if (asset.asset_type === 'sound') {
playBtn = `<button class="btn btn-icon btn-secondary" data-action="play" title="${escapeHtml(t('asset.play'))}">${ICON_PLAY_SOUND}</button>`;
}
return wrapCard({
dataAttr: 'data-id',
id: asset.id,
removeOnclick: `deleteAsset('${asset.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(asset.name)}">
${icon} <span class="card-title-text">${escapeHtml(asset.name)}</span>
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${getAssetTypeIcon(asset.asset_type)} ${escapeHtml(getAssetTypeLabel(asset.asset_type))}</span>
<span class="stream-card-prop">${_icon(P.fileText)} ${sizeStr}</span>
${prebuiltBadge}
</div>
${renderTagChips(asset.tags)}`,
actions: `
${playBtn}
<button class="btn btn-icon btn-secondary" data-action="download" title="${escapeHtml(t('asset.download'))}">${ICON_DOWNLOAD}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" title="${escapeHtml(t('common.edit'))}">${ICON_EDIT}</button>`,
});
}
// ── Sound playback ──
let _currentAudio: HTMLAudioElement | null = null;
async function _playAssetSound(assetId: string) {
if (_currentAudio) {
_currentAudio.pause();
_currentAudio = null;
}
try {
const res = await fetchWithAuth(`/assets/${assetId}/file`);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const audio = new Audio(blobUrl);
audio.addEventListener('ended', () => URL.revokeObjectURL(blobUrl));
audio.play().catch(() => {});
_currentAudio = audio;
} catch { /* ignore playback errors */ }
}
// ── Modal ──
let _assetTagsInput: TagInput | null = null;
let _uploadTagsInput: TagInput | null = null;
class AssetEditorModal extends Modal {
constructor() { super('asset-editor-modal'); }
snapshotValues() {
return {
name: (document.getElementById('asset-editor-name') as HTMLInputElement).value,
description: (document.getElementById('asset-editor-description') as HTMLInputElement).value,
tags: JSON.stringify(_assetTagsInput ? _assetTagsInput.getValue() : []),
};
}
onForceClose() {
if (_assetTagsInput) { _assetTagsInput.destroy(); _assetTagsInput = null; }
}
}
const assetEditorModal = new AssetEditorModal();
class AssetUploadModal extends Modal {
constructor() { super('asset-upload-modal'); }
snapshotValues() {
return {
name: (document.getElementById('asset-upload-name') as HTMLInputElement).value,
file: (document.getElementById('asset-upload-file') as HTMLInputElement).value,
tags: JSON.stringify(_uploadTagsInput ? _uploadTagsInput.getValue() : []),
};
}
onForceClose() {
if (_uploadTagsInput) { _uploadTagsInput.destroy(); _uploadTagsInput = null; }
}
}
const assetUploadModal = new AssetUploadModal();
// ── CRUD: Upload ──
export async function showAssetUploadModal(): Promise<void> {
const titleEl = document.getElementById('asset-upload-title')!;
titleEl.innerHTML = `${ICON_UPLOAD} ${t('asset.upload')}`;
(document.getElementById('asset-upload-name') as HTMLInputElement).value = '';
(document.getElementById('asset-upload-description') as HTMLInputElement).value = '';
(document.getElementById('asset-upload-file') as HTMLInputElement).value = '';
document.getElementById('asset-upload-error')!.style.display = 'none';
// Tags
if (_uploadTagsInput) { _uploadTagsInput.destroy(); _uploadTagsInput = null; }
const tagsContainer = document.getElementById('asset-upload-tags-container')!;
_uploadTagsInput = new TagInput(tagsContainer, { entityType: 'asset' });
// Reset dropzone visual state
const dropzone = document.getElementById('asset-upload-dropzone')!;
dropzone.classList.remove('has-file', 'dragover');
const dzLabel = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
if (dzLabel) dzLabel.style.display = '';
const dzIcon = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
if (dzIcon) dzIcon.style.display = '';
document.getElementById('asset-upload-file-info')!.style.display = 'none';
initUploadDropzone();
assetUploadModal.open();
assetUploadModal.snapshot();
}
export async function uploadAsset(): Promise<void> {
const fileInput = document.getElementById('asset-upload-file') as HTMLInputElement;
const nameInput = document.getElementById('asset-upload-name') as HTMLInputElement;
const descInput = document.getElementById('asset-upload-description') as HTMLInputElement;
const errorEl = document.getElementById('asset-upload-error')!;
if (!fileInput.files || fileInput.files.length === 0) {
errorEl.textContent = t('asset.error.no_file');
errorEl.style.display = '';
return;
}
const file = fileInput.files[0];
const formData = new FormData();
formData.append('file', file);
let url = `${API_BASE}/assets`;
const params = new URLSearchParams();
const name = nameInput.value.trim();
if (name) params.set('name', name);
const desc = descInput.value.trim();
if (desc) params.set('description', desc);
if (params.toString()) url += `?${params.toString()}`;
try {
const headers = getHeaders();
delete headers['Content-Type']; // Let browser set multipart boundary
const res = await fetch(url, {
method: 'POST',
headers,
body: formData,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Upload failed');
}
// Set tags via metadata update if any were specified
const tags = _uploadTagsInput ? _uploadTagsInput.getValue() : [];
const result = await res.json();
if (tags.length > 0 && result.id) {
await fetchWithAuth(`/assets/${result.id}`, {
method: 'PUT',
body: JSON.stringify({ tags }),
});
}
showToast(t('asset.uploaded'), 'success');
assetsCache.invalidate();
assetUploadModal.forceClose();
await loadPictureSources();
} catch (e: any) {
errorEl.textContent = e.message;
errorEl.style.display = '';
}
}
export function closeAssetUploadModal() {
assetUploadModal.close();
}
// ── CRUD: Edit metadata ──
export async function showAssetEditor(editId: string): Promise<void> {
const titleEl = document.getElementById('asset-editor-title')!;
const idInput = document.getElementById('asset-editor-id') as HTMLInputElement;
const nameInput = document.getElementById('asset-editor-name') as HTMLInputElement;
const descInput = document.getElementById('asset-editor-description') as HTMLInputElement;
const errorEl = document.getElementById('asset-editor-error')!;
errorEl.style.display = 'none';
const assets = await assetsCache.fetch();
const asset = assets.find(a => a.id === editId);
if (!asset) return;
titleEl.innerHTML = `${ICON_ASSET} ${t('asset.edit')}`;
idInput.value = asset.id;
nameInput.value = asset.name;
descInput.value = asset.description || '';
// Tags
if (_assetTagsInput) { _assetTagsInput.destroy(); _assetTagsInput = null; }
const tagsContainer = document.getElementById('asset-editor-tags-container')!;
_assetTagsInput = new TagInput(tagsContainer, { entityType: 'asset' });
_assetTagsInput.setValue(asset.tags || []);
assetEditorModal.open();
assetEditorModal.snapshot();
}
export async function saveAssetMetadata(): Promise<void> {
const id = (document.getElementById('asset-editor-id') as HTMLInputElement).value;
const name = (document.getElementById('asset-editor-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('asset-editor-description') as HTMLInputElement).value.trim();
const errorEl = document.getElementById('asset-editor-error')!;
if (!name) {
errorEl.textContent = t('asset.error.name_required');
errorEl.style.display = '';
return;
}
const tags = _assetTagsInput ? _assetTagsInput.getValue() : [];
try {
const res = await fetchWithAuth(`/assets/${id}`, {
method: 'PUT',
body: JSON.stringify({ name, description: description || null, tags }),
});
if (!res!.ok) {
const err = await res!.json();
throw new Error(err.detail);
}
showToast(t('asset.updated'), 'success');
assetsCache.invalidate();
assetEditorModal.forceClose();
await loadPictureSources();
} catch (e: any) {
if (e.isAuth) return;
errorEl.textContent = e.message;
errorEl.style.display = '';
}
}
export function closeAssetEditorModal() {
assetEditorModal.close();
}
// ── CRUD: Delete ──
export async function deleteAsset(assetId: string): Promise<void> {
const ok = await showConfirm(t('asset.confirm_delete'));
if (!ok) return;
try {
await fetchWithAuth(`/assets/${assetId}`, { method: 'DELETE' });
showToast(t('asset.deleted'), 'success');
assetsCache.invalidate();
await loadPictureSources();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message || t('asset.error.delete_failed'), 'error');
}
}
// ── Restore prebuilt ──
export async function restorePrebuiltAssets(): Promise<void> {
try {
const res = await fetchWithAuth('/assets/restore-prebuilt', { method: 'POST' });
if (!res!.ok) {
const err = await res!.json();
throw new Error(err.detail);
}
const data = await res!.json();
if (data.restored_count > 0) {
showToast(t('asset.prebuilt_restored').replace('{count}', String(data.restored_count)), 'success');
} else {
showToast(t('asset.prebuilt_none_to_restore'), 'info');
}
assetsCache.invalidate();
await loadPictureSources();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Download ──
async function _downloadAsset(assetId: string) {
const asset = _cachedAssets.find(a => a.id === assetId);
const filename = asset ? asset.filename : 'download';
try {
const res = await fetchWithAuth(`/assets/${assetId}/file`);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(blobUrl);
} catch { /* ignore */ }
}
// ── Event delegation ──
export function initAssetDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: Event) => {
const btn = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
if (!btn) return;
const card = btn.closest('[data-id]') as HTMLElement | null;
if (!card || !card.closest('#stream-tab-assets')) return;
const action = btn.dataset.action;
const id = card.getAttribute('data-id');
if (!action || !id) return;
e.stopPropagation();
if (action === 'edit') showAssetEditor(id);
else if (action === 'delete') deleteAsset(id);
else if (action === 'download') _downloadAsset(id);
else if (action === 'play') _playAssetSound(id);
});
}
// ── Expose to global scope for HTML template onclick handlers ──
window.showAssetUploadModal = showAssetUploadModal;
window.closeAssetUploadModal = closeAssetUploadModal;
window.uploadAsset = uploadAsset;
window.showAssetEditor = showAssetEditor;
window.closeAssetEditorModal = closeAssetEditorModal;
window.saveAssetMetadata = saveAssetMetadata;
window.deleteAsset = deleteAsset;
window.restorePrebuiltAssets = restorePrebuiltAssets;

View File

@@ -9,7 +9,7 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts'; import { CardSection } from '../core/card-sections.ts';
import { updateTabBadge, updateSubTabHash } from './tabs.ts'; import { updateTabBadge, updateSubTabHash } from './tabs.ts';
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB } from '../core/icons.ts'; import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts';
@@ -766,7 +766,7 @@ function addAutomationConditionRow(condition: any) {
<div class="condition-field"> <div class="condition-field">
<div class="condition-apps-header"> <div class="condition-apps-header">
<label>${t('automations.condition.application.apps')}</label> <label>${t('automations.condition.application.apps')}</label>
<button type="button" class="btn-browse-apps" title="${t('automations.condition.application.browse')}">${t('automations.condition.application.browse')}</button> <button type="button" class="btn btn-icon btn-secondary btn-browse-apps" title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
</div> </div>
<textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea> <textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
</div> </div>

View File

@@ -7,23 +7,33 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts'; import { showToast } from '../core/ui.ts';
import { import {
ICON_SEARCH, ICON_CLONE, ICON_SEARCH, ICON_CLONE, getAssetTypeIcon,
} from '../core/icons.ts'; } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts'; import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts'; import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
import { _cachedAssets, assetsCache } from '../core/state.ts';
import { getBaseOrigin } from './settings.ts'; import { getBaseOrigin } from './settings.ts';
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`; const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
/* ── Notification state ───────────────────────────────────────── */ /* ── Notification state ───────────────────────────────────────── */
let _notificationAppColors: any[] = []; // [{app: '', color: '#...'}] interface AppOverride {
app: string;
/** Return current app colors array (for dirty-check snapshot). */ color: string;
export function notificationGetRawAppColors() { sound_asset_id: string | null;
return _notificationAppColors; volume: number; // 0100
} }
let _notificationAppOverrides: AppOverride[] = [];
/** Return current overrides array (for dirty-check snapshot). */
export function notificationGetRawAppOverrides() {
return _notificationAppOverrides;
}
let _notificationEffectIconSelect: any = null; let _notificationEffectIconSelect: any = null;
let _notificationFilterModeIconSelect: any = null; let _notificationFilterModeIconSelect: any = null;
@@ -58,50 +68,162 @@ export function onNotificationFilterModeChange() {
(document.getElementById('css-editor-notification-filter-list-group') as HTMLElement).style.display = mode === 'off' ? 'none' : ''; (document.getElementById('css-editor-notification-filter-list-group') as HTMLElement).style.display = mode === 'off' ? 'none' : '';
} }
function _notificationAppColorsRenderList() { /* ── Unified per-app overrides (color + sound) ────────────────── */
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
if (!list) return;
list.innerHTML = _notificationAppColors.map((entry, i) => `
<div class="notif-app-color-row">
<input type="text" class="notif-app-name" data-idx="${i}" value="${escapeHtml(entry.app)}" placeholder="App name">
<button type="button" class="notif-app-browse" data-idx="${i}"
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<input type="color" class="notif-app-color" data-idx="${i}" value="${entry.color}">
<button type="button" class="notif-app-color-remove"
onclick="notificationRemoveAppColor(${i})">&#x2715;</button>
</div>
`).join('');
// Wire up browse buttons to open process palette let _overrideEntitySelects: EntitySelect[] = [];
list.querySelectorAll<HTMLButtonElement>('.notif-app-browse').forEach(btn => {
function _getSoundAssetItems() {
return _cachedAssets
.filter(a => a.asset_type === 'sound')
.map(a => ({ value: a.id, label: a.name, icon: getAssetTypeIcon('sound'), desc: a.filename }));
}
function _overridesSyncFromDom() {
const list = document.getElementById('notification-app-overrides-list') as HTMLElement | null;
if (!list) return;
const names = list.querySelectorAll<HTMLInputElement>('.notif-override-name');
const colors = list.querySelectorAll<HTMLInputElement>('.notif-override-color');
const sounds = list.querySelectorAll<HTMLSelectElement>('.notif-override-sound');
const volumes = list.querySelectorAll<HTMLInputElement>('.notif-override-volume');
if (names.length === _notificationAppOverrides.length) {
for (let i = 0; i < names.length; i++) {
_notificationAppOverrides[i].app = names[i].value;
_notificationAppOverrides[i].color = colors[i].value;
_notificationAppOverrides[i].sound_asset_id = sounds[i].value || null;
_notificationAppOverrides[i].volume = parseInt(volumes[i].value);
}
}
}
function _overridesRenderList() {
const list = document.getElementById('notification-app-overrides-list') as HTMLElement | null;
if (!list) return;
_overrideEntitySelects.forEach(es => es.destroy());
_overrideEntitySelects = [];
const soundAssets = _cachedAssets.filter(a => a.asset_type === 'sound');
list.innerHTML = _notificationAppOverrides.map((entry, i) => {
const soundOpts = `<option value="">${t('color_strip.notification.sound.none')}</option>` +
soundAssets.map(a =>
`<option value="${a.id}"${a.id === entry.sound_asset_id ? ' selected' : ''}>${escapeHtml(a.name)}</option>`
).join('');
const volPct = entry.volume ?? 100;
return `
<div class="notif-override-row">
<input type="text" class="notif-override-name" data-idx="${i}" value="${escapeHtml(entry.app)}"
placeholder="${t('color_strip.notification.app_overrides.app_placeholder') || 'App name'}">
<button type="button" class="btn btn-icon btn-secondary notif-override-browse" data-idx="${i}"
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<input type="color" class="notif-override-color" data-idx="${i}" value="${entry.color}">
<button type="button" class="btn btn-icon btn-secondary"
onclick="notificationRemoveAppOverride(${i})">&#x2715;</button>
<select class="notif-override-sound" data-idx="${i}">${soundOpts}</select>
<input type="range" class="notif-override-volume" data-idx="${i}" min="0" max="100" step="5" value="${volPct}"
title="${volPct}%"
oninput="this.title = this.value + '%'">
</div>`;
}).join('');
// Wire browse buttons
list.querySelectorAll<HTMLButtonElement>('.notif-override-browse').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.idx!); const idx = parseInt(btn.dataset.idx!);
const nameInput = list.querySelector<HTMLInputElement>(`.notif-app-name[data-idx="${idx}"]`); const nameInput = list.querySelector<HTMLInputElement>(`.notif-override-name[data-idx="${idx}"]`);
if (!nameInput) return; if (!nameInput) return;
const picked = await NotificationAppPalette.pick({ const picked = await NotificationAppPalette.pick({
current: nameInput.value, current: nameInput.value,
placeholder: t('color_strip.notification.search_apps') || 'Search notification apps...', placeholder: t('color_strip.notification.search_apps') || 'Search notification apps',
}); });
if (picked !== undefined) { if (picked !== undefined) {
nameInput.value = picked; nameInput.value = picked;
_notificationAppColorsSyncFromDom(); _overridesSyncFromDom();
} }
}); });
}); });
// Wire EntitySelects for sound dropdowns
list.querySelectorAll<HTMLSelectElement>('.notif-override-sound').forEach(sel => {
const items = _getSoundAssetItems();
if (items.length > 0) {
const es = new EntitySelect({
target: sel,
getItems: () => _getSoundAssetItems(),
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
});
_overrideEntitySelects.push(es);
}
});
} }
export function notificationAddAppColor() { export function notificationAddAppOverride() {
_notificationAppColorsSyncFromDom(); _overridesSyncFromDom();
_notificationAppColors.push({ app: '', color: '#ffffff' }); _notificationAppOverrides.push({ app: '', color: '#ffffff', sound_asset_id: null, volume: 100 });
_notificationAppColorsRenderList(); _overridesRenderList();
} }
export function notificationRemoveAppColor(i: number) { export function notificationRemoveAppOverride(i: number) {
_notificationAppColorsSyncFromDom(); _overridesSyncFromDom();
_notificationAppColors.splice(i, 1); _notificationAppOverrides.splice(i, 1);
_notificationAppColorsRenderList(); _overridesRenderList();
} }
/** Split overrides into app_colors dict for the API. */
export function notificationGetAppColorsDict() {
_overridesSyncFromDom();
const dict: Record<string, string> = {};
for (const entry of _notificationAppOverrides) {
if (entry.app.trim()) dict[entry.app.trim()] = entry.color;
}
return dict;
}
/** Split overrides into app_sounds dict for the API. */
export function notificationGetAppSoundsDict() {
_overridesSyncFromDom();
const dict: Record<string, any> = {};
for (const entry of _notificationAppOverrides) {
if (!entry.app.trim()) continue;
if (entry.sound_asset_id || entry.volume !== 100) {
dict[entry.app.trim()] = {
sound_asset_id: entry.sound_asset_id || null,
volume: (entry.volume ?? 100) / 100,
};
}
}
return dict;
}
/* ── Notification sound — global EntitySelect ─────────────────── */
let _notifSoundEntitySelect: EntitySelect | null = null;
function _populateSoundOptions(sel: HTMLSelectElement, selectedId?: string | null) {
const sounds = _cachedAssets.filter(a => a.asset_type === 'sound');
sel.innerHTML = `<option value="">${t('color_strip.notification.sound.none')}</option>` +
sounds.map(a =>
`<option value="${a.id}"${a.id === selectedId ? ' selected' : ''}>${escapeHtml(a.name)}</option>`
).join('');
}
export function ensureNotifSoundEntitySelect() {
const sel = document.getElementById('css-editor-notification-sound') as HTMLSelectElement | null;
if (!sel) return;
_populateSoundOptions(sel);
if (_notifSoundEntitySelect) _notifSoundEntitySelect.destroy();
const items = _getSoundAssetItems();
if (items.length > 0) {
_notifSoundEntitySelect = new EntitySelect({
target: sel,
getItems: () => _getSoundAssetItems(),
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
});
}
}
/* ── Test notification ────────────────────────────────────────── */
export async function testNotification(sourceId: string) { export async function testNotification(sourceId: string) {
try { try {
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!; const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!;
@@ -194,29 +316,24 @@ async function _loadNotificationHistory() {
} }
} }
function _notificationAppColorsSyncFromDom() { /* ── Load / Reset state ───────────────────────────────────────── */
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
if (!list) return; /**
const names = list.querySelectorAll<HTMLInputElement>('.notif-app-name'); * Merge app_colors and app_sounds dicts into unified overrides list.
const colors = list.querySelectorAll<HTMLInputElement>('.notif-app-color'); * app_colors: {app: color}
if (names.length === _notificationAppColors.length) { * app_sounds: {app: {sound_asset_id, volume}}
for (let i = 0; i < names.length; i++) { */
_notificationAppColors[i].app = names[i].value; function _mergeOverrides(appColors: Record<string, string>, appSounds: Record<string, any>): AppOverride[] {
_notificationAppColors[i].color = colors[i].value; const appNames = new Set([...Object.keys(appColors), ...Object.keys(appSounds)]);
} return [...appNames].map(app => ({
} app,
color: appColors[app] || '#ffffff',
sound_asset_id: appSounds[app]?.sound_asset_id || null,
volume: Math.round((appSounds[app]?.volume ?? 1.0) * 100),
}));
} }
export function notificationGetAppColorsDict() { export async function loadNotificationState(css: any) {
_notificationAppColorsSyncFromDom();
const dict: Record<string, any> = {};
for (const entry of _notificationAppColors) {
if (entry.app.trim()) dict[entry.app.trim()] = entry.color;
}
return dict;
}
export function loadNotificationState(css: any) {
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = !!css.os_listener; (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = !!css.os_listener;
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash'; (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash'); if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
@@ -230,15 +347,27 @@ export function loadNotificationState(css: any) {
onNotificationFilterModeChange(); onNotificationFilterModeChange();
_attachNotificationProcessPicker(); _attachNotificationProcessPicker();
// App colors dict -> list // Ensure assets are loaded before populating sound dropdowns
const ac = css.app_colors || {}; await assetsCache.fetch();
_notificationAppColors = Object.entries(ac).map(([app, color]) => ({ app, color }));
_notificationAppColorsRenderList(); // Sound (global)
const soundSel = document.getElementById('css-editor-notification-sound') as HTMLSelectElement;
_populateSoundOptions(soundSel, css.sound_asset_id);
if (soundSel) soundSel.value = css.sound_asset_id || '';
ensureNotifSoundEntitySelect();
if (_notifSoundEntitySelect && css.sound_asset_id) _notifSoundEntitySelect.setValue(css.sound_asset_id);
const volPct = Math.round((css.sound_volume ?? 1.0) * 100);
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = volPct as any;
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = `${volPct}%`;
// Unified per-app overrides (merge app_colors + app_sounds)
_notificationAppOverrides = _mergeOverrides(css.app_colors || {}, css.app_sounds || {});
_overridesRenderList();
showNotificationEndpoint(css.id); showNotificationEndpoint(css.id);
} }
export function resetNotificationState() { export async function resetNotificationState() {
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = true; (document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = true;
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash'; (document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash'); if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
@@ -250,8 +379,19 @@ export function resetNotificationState() {
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = ''; (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = '';
onNotificationFilterModeChange(); onNotificationFilterModeChange();
_attachNotificationProcessPicker(); _attachNotificationProcessPicker();
_notificationAppColors = [];
_notificationAppColorsRenderList(); // Sound reset
const soundSel = document.getElementById('css-editor-notification-sound') as HTMLSelectElement;
_populateSoundOptions(soundSel);
if (soundSel) soundSel.value = '';
ensureNotifSoundEntitySelect();
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = 100 as any;
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = '100%';
// Clear overrides
_notificationAppOverrides = [];
_overridesRenderList();
showNotificationEndpoint(null); showNotificationEndpoint(null);
} }

View File

@@ -15,7 +15,7 @@ import {
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts'; import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts'; import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
import { notificationGetAppColorsDict } from './color-strips-notification.ts'; import { notificationGetAppColorsDict, notificationGetAppSoundsDict } from './color-strips-notification.ts';
/* ── Preview config builder ───────────────────────────────────── */ /* ── Preview config builder ───────────────────────────────────── */
@@ -54,6 +54,9 @@ function _collectPreviewConfig() {
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value, app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
app_filter_list: filterList, app_filter_list: filterList,
app_colors: notificationGetAppColorsDict(), app_colors: notificationGetAppColorsDict(),
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null,
sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100,
app_sounds: notificationGetAppSoundsDict(),
}; };
} }
const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null; const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;

View File

@@ -34,17 +34,19 @@ import {
} from './color-strips-composite.ts'; } from './color-strips-composite.ts';
import { import {
ensureNotificationEffectIconSelect, ensureNotificationFilterModeIconSelect, ensureNotificationEffectIconSelect, ensureNotificationFilterModeIconSelect,
onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, onNotificationFilterModeChange,
notificationAddAppOverride, notificationRemoveAppOverride,
notificationGetAppColorsDict, notificationGetAppSoundsDict, notificationGetRawAppOverrides,
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory, testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
notificationGetAppColorsDict, loadNotificationState, resetNotificationState, showNotificationEndpoint, loadNotificationState, resetNotificationState, showNotificationEndpoint,
notificationGetRawAppColors,
} from './color-strips-notification.ts'; } from './color-strips-notification.ts';
// Re-export for app.js window global bindings // Re-export for app.js window global bindings
export { gradientInit, gradientRenderAll, gradientAddStop }; export { gradientInit, gradientRenderAll, gradientAddStop };
export { compositeAddLayer, compositeRemoveLayer }; export { compositeAddLayer, compositeRemoveLayer };
export { export {
onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, onNotificationFilterModeChange,
notificationAddAppOverride, notificationRemoveAppOverride,
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory, testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
}; };
export { _getAnimationPayload, _colorCycleGetColors }; export { _getAnimationPayload, _colorCycleGetColors };
@@ -97,7 +99,7 @@ class CSSEditorModal extends Modal {
notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value, notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value, notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value, notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value,
notification_app_colors: JSON.stringify(notificationGetRawAppColors()), notification_app_overrides: JSON.stringify(notificationGetRawAppOverrides()),
clock_id: (document.getElementById('css-editor-clock') as HTMLInputElement).value, clock_id: (document.getElementById('css-editor-clock') as HTMLInputElement).value,
daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value, daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value,
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
@@ -1473,6 +1475,9 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value, app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
app_filter_list: filterList, app_filter_list: filterList,
app_colors: notificationGetAppColorsDict(), app_colors: notificationGetAppColorsDict(),
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null,
sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100,
app_sounds: notificationGetAppSoundsDict(),
}; };
}, },
}, },

View File

@@ -19,7 +19,6 @@ import {
currentTestingTemplate, setCurrentTestingTemplate, currentTestingTemplate, setCurrentTestingTemplate,
_currentTestStreamId, set_currentTestStreamId, _currentTestStreamId, set_currentTestStreamId,
_currentTestPPTemplateId, set_currentTestPPTemplateId, _currentTestPPTemplateId, set_currentTestPPTemplateId,
_lastValidatedImageSource, set_lastValidatedImageSource,
_cachedAudioSources, _cachedAudioSources,
_cachedValueSources, _cachedValueSources,
_cachedSyncClocks, _cachedSyncClocks,
@@ -35,7 +34,7 @@ import {
_sourcesLoading, set_sourcesLoading, _sourcesLoading, set_sourcesLoading,
apiKey, apiKey,
streamsCache, ppTemplatesCache, captureTemplatesCache, streamsCache, ppTemplatesCache, captureTemplatesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, filtersCache, audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, assetsCache, _cachedAssets, filtersCache,
colorStripSourcesCache, colorStripSourcesCache,
csptCache, stripFiltersCache, csptCache, stripFiltersCache,
gradientsCache, GradientEntity, gradientsCache, GradientEntity,
@@ -51,6 +50,7 @@ import { updateSubTabHash } from './tabs.ts';
import { createValueSourceCard } from './value-sources.ts'; import { createValueSourceCard } from './value-sources.ts';
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts'; import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts'; import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
import { createAssetCard, initAssetDelegation } from './assets.ts';
import { createColorStripCard } from './color-strips.ts'; import { createColorStripCard } from './color-strips.ts';
import { initAudioSourceDelegation } from './audio-sources.ts'; import { initAudioSourceDelegation } from './audio-sources.ts';
import { import {
@@ -58,7 +58,8 @@ import {
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE, ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT, ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, ICON_ACTIVITY, ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, ICON_ACTIVITY,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE, ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE, ICON_ASSET,
getAssetTypeIcon,
} from '../core/icons.ts'; } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts'; import * as P from '../core/icon-paths.ts';
@@ -97,8 +98,61 @@ const _colorStripDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon:
const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }]; const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }];
const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }]; const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }];
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }]; const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
const _assetDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('assets', assetsCache, 'asset.deleted') }];
const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }]; const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }];
/** Resolve an asset ID to its display name. */
function _getAssetName(assetId?: string | null): string {
if (!assetId) return '—';
const asset = _cachedAssets.find((a: any) => a.id === assetId);
return asset ? asset.name : assetId;
}
/** Get EntitySelect items for a given asset type (image/video). */
function _getAssetItems(assetType: string) {
return _cachedAssets
.filter((a: any) => a.asset_type === assetType)
.map((a: any) => ({ value: a.id, label: a.name, icon: getAssetTypeIcon(assetType), desc: a.filename }));
}
let _imageAssetEntitySelect: EntitySelect | null = null;
let _videoAssetEntitySelect: EntitySelect | null = null;
function _ensureImageAssetEntitySelect() {
const sel = document.getElementById('stream-image-asset') as HTMLSelectElement | null;
if (!sel) return;
if (_imageAssetEntitySelect) _imageAssetEntitySelect.destroy();
_imageAssetEntitySelect = null;
const items = _getAssetItems('image');
sel.innerHTML = `<option value="">${t('streams.image_asset.select')}</option>` +
items.map(a => `<option value="${a.value}">${escapeHtml(a.label)}</option>`).join('');
_imageAssetEntitySelect = new EntitySelect({
target: sel,
getItems: () => _getAssetItems('image'),
placeholder: t('streams.image_asset.search') || 'Search image assets…',
});
}
function _ensureVideoAssetEntitySelect() {
const sel = document.getElementById('stream-video-asset') as HTMLSelectElement | null;
if (!sel) return;
if (_videoAssetEntitySelect) _videoAssetEntitySelect.destroy();
_videoAssetEntitySelect = null;
const items = _getAssetItems('video');
sel.innerHTML = `<option value="">${t('streams.video_asset.select')}</option>` +
items.map(a => `<option value="${a.value}">${escapeHtml(a.label)}</option>`).join('');
_videoAssetEntitySelect = new EntitySelect({
target: sel,
getItems: () => _getAssetItems('video'),
placeholder: t('streams.video_asset.search') || 'Search video assets…',
});
}
function _destroyAssetEntitySelects() {
if (_imageAssetEntitySelect) { _imageAssetEntitySelect.destroy(); _imageAssetEntitySelect = null; }
if (_videoAssetEntitySelect) { _videoAssetEntitySelect.destroy(); _videoAssetEntitySelect = null; }
}
// ── Card section instances ── // ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction }); const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction });
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates', bulkActions: _captureTemplateDeleteAction }); const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates', bulkActions: _captureTemplateDeleteAction });
@@ -114,6 +168,7 @@ const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.secti
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction }); const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction });
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction }); const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction });
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction }); const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
const csAssets = new CardSection('assets', { titleKey: 'asset.group.title', gridClass: 'templates-grid', addCardOnclick: "showAssetUploadModal()", keyAttr: 'data-id', emptyKey: 'section.empty.assets', bulkActions: _assetDeleteAction });
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction }); const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction });
const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }]; const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }];
const csGradients = new CardSection('gradients', { titleKey: 'gradient.group.title', gridClass: 'templates-grid', addCardOnclick: "showGradientModal()", keyAttr: 'data-id', emptyKey: 'section.empty.gradients', bulkActions: _gradientDeleteAction }); const csGradients = new CardSection('gradients', { titleKey: 'gradient.group.title', gridClass: 'templates-grid', addCardOnclick: "showGradientModal()", keyAttr: 'data-id', emptyKey: 'section.empty.gradients', bulkActions: _gradientDeleteAction });
@@ -137,13 +192,14 @@ class StreamEditorModal extends Modal {
targetFps: (document.getElementById('stream-target-fps') as HTMLInputElement).value, targetFps: (document.getElementById('stream-target-fps') as HTMLInputElement).value,
source: (document.getElementById('stream-source') as HTMLSelectElement).value, source: (document.getElementById('stream-source') as HTMLSelectElement).value,
ppTemplate: (document.getElementById('stream-pp-template') as HTMLSelectElement).value, ppTemplate: (document.getElementById('stream-pp-template') as HTMLSelectElement).value,
imageSource: (document.getElementById('stream-image-source') as HTMLInputElement).value, imageAsset: (document.getElementById('stream-image-asset') as HTMLSelectElement).value,
tags: JSON.stringify(_streamTagsInput ? _streamTagsInput.getValue() : []), tags: JSON.stringify(_streamTagsInput ? _streamTagsInput.getValue() : []),
}; };
} }
onForceClose() { onForceClose() {
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; } if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
_destroyAssetEntitySelects();
(document.getElementById('stream-type') as HTMLSelectElement).disabled = false; (document.getElementById('stream-type') as HTMLSelectElement).disabled = false;
set_streamNameManuallyEdited(false); set_streamNameManuallyEdited(false);
} }
@@ -226,6 +282,7 @@ export async function loadPictureSources() {
valueSourcesCache.fetch(), valueSourcesCache.fetch(),
syncClocksCache.fetch(), syncClocksCache.fetch(),
weatherSourcesCache.fetch(), weatherSourcesCache.fetch(),
assetsCache.fetch(),
audioTemplatesCache.fetch(), audioTemplatesCache.fetch(),
colorStripSourcesCache.fetch(), colorStripSourcesCache.fetch(),
csptCache.fetch(), csptCache.fetch(),
@@ -316,16 +373,15 @@ const PICTURE_SOURCE_CARD_RENDERERS: Record<string, StreamCardRenderer> = {
</div>`; </div>`;
}, },
static_image: (stream) => { static_image: (stream) => {
const src = stream.image_source || ''; const assetName = _getAssetName(stream.image_asset_id);
return `<div class="stream-card-props"> return `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">${ICON_WEB} ${escapeHtml(src)}</span> <span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(assetName)}">${ICON_ASSET} ${escapeHtml(assetName)}</span>
</div>`; </div>`;
}, },
video: (stream) => { video: (stream) => {
const url = stream.url || ''; const assetName = _getAssetName(stream.video_asset_id);
const shortUrl = url.length > 40 ? url.slice(0, 37) + '...' : url;
return `<div class="stream-card-props"> return `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(url)}">${ICON_WEB} ${escapeHtml(shortUrl)}</span> <span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(assetName)}">${ICON_ASSET} ${escapeHtml(assetName)}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span> <span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
${stream.loop !== false ? `<span class="stream-card-prop">↻</span>` : ''} ${stream.loop !== false ? `<span class="stream-card-prop">↻</span>` : ''}
${stream.playback_speed && stream.playback_speed !== 1.0 ? `<span class="stream-card-prop">${stream.playback_speed}×</span>` : ''} ${stream.playback_speed && stream.playback_speed !== 1.0 ? `<span class="stream-card-prop">${stream.playback_speed}×</span>` : ''}
@@ -510,6 +566,7 @@ function renderPictureSourcesList(streams: any) {
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length }, { key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length }, { key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length }, { key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
{ key: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length },
]; ];
// Build tree navigation structure // Build tree navigation structure
@@ -563,6 +620,7 @@ function renderPictureSourcesList(streams: any) {
{ key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length }, { key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length },
{ key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length }, { key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length },
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length }, { key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
{ key: 'assets', titleKey: 'streams.group.assets', icon: ICON_ASSET, count: _cachedAssets.length },
] ]
} }
]; ];
@@ -723,6 +781,7 @@ function renderPictureSourcesList(streams: any) {
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) }))); const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) }))); const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) }))); const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) })));
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) }))); const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
if (csRawStreams.isMounted()) { if (csRawStreams.isMounted()) {
@@ -742,6 +801,7 @@ function renderPictureSourcesList(streams: any) {
value: _cachedValueSources.length, value: _cachedValueSources.length,
sync: _cachedSyncClocks.length, sync: _cachedSyncClocks.length,
weather: _cachedWeatherSources.length, weather: _cachedWeatherSources.length,
assets: _cachedAssets.length,
}); });
csRawStreams.reconcile(rawStreamItems); csRawStreams.reconcile(rawStreamItems);
csRawTemplates.reconcile(rawTemplateItems); csRawTemplates.reconcile(rawTemplateItems);
@@ -759,6 +819,7 @@ function renderPictureSourcesList(streams: any) {
csValueSources.reconcile(valueItems); csValueSources.reconcile(valueItems);
csSyncClocks.reconcile(syncClockItems); csSyncClocks.reconcile(syncClockItems);
csWeatherSources.reconcile(weatherSourceItems); csWeatherSources.reconcile(weatherSourceItems);
csAssets.reconcile(assetItems);
} else { } else {
// First render: build full HTML // First render: build full HTML
const panels = tabs.map(tab => { const panels = tabs.map(tab => {
@@ -777,18 +838,20 @@ function renderPictureSourcesList(streams: any) {
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems); else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems); else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems); else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
else if (tab.key === 'assets') panelContent = csAssets.render(assetItems);
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems); else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
else panelContent = csStaticStreams.render(staticItems); else panelContent = csStaticStreams.render(staticItems);
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`; return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join(''); }).join('');
container.innerHTML = panels; container.innerHTML = panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources]); CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csAssets]);
// Event delegation for card actions (replaces inline onclick handlers) // Event delegation for card actions (replaces inline onclick handlers)
initSyncClockDelegation(container); initSyncClockDelegation(container);
initWeatherSourceDelegation(container); initWeatherSourceDelegation(container);
initAudioSourceDelegation(container); initAudioSourceDelegation(container);
initAssetDelegation(container);
// Render tree sidebar with expand/collapse buttons // Render tree sidebar with expand/collapse buttons
_streamsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`); _streamsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
@@ -806,6 +869,7 @@ function renderPictureSourcesList(streams: any) {
'value-sources': 'value', 'value-sources': 'value',
'sync-clocks': 'sync', 'sync-clocks': 'sync',
'weather-sources': 'weather', 'weather-sources': 'weather',
'assets': 'assets',
}); });
} }
} }
@@ -867,14 +931,8 @@ export async function showAddStreamModal(presetType: any, cloneData: any = null)
document.getElementById('stream-display-picker-label')!.textContent = t('displays.picker.select'); document.getElementById('stream-display-picker-label')!.textContent = t('displays.picker.select');
document.getElementById('stream-error')!.style.display = 'none'; document.getElementById('stream-error')!.style.display = 'none';
(document.getElementById('stream-type') as HTMLSelectElement).value = streamType; (document.getElementById('stream-type') as HTMLSelectElement).value = streamType;
set_lastValidatedImageSource(''); _ensureImageAssetEntitySelect();
const imgSrcInput = document.getElementById('stream-image-source') as HTMLInputElement; _ensureVideoAssetEntitySelect();
imgSrcInput.value = '';
document.getElementById('stream-image-preview-container')!.style.display = 'none';
document.getElementById('stream-image-validation-status')!.style.display = 'none';
imgSrcInput.onblur = () => validateStaticImage();
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
onStreamTypeChange(); onStreamTypeChange();
set_streamNameManuallyEdited(!!cloneData); set_streamNameManuallyEdited(!!cloneData);
@@ -906,10 +964,15 @@ export async function showAddStreamModal(presetType: any, cloneData: any = null)
(document.getElementById('stream-source') as HTMLSelectElement).value = cloneData.source_stream_id || ''; (document.getElementById('stream-source') as HTMLSelectElement).value = cloneData.source_stream_id || '';
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = cloneData.postprocessing_template_id || ''; (document.getElementById('stream-pp-template') as HTMLSelectElement).value = cloneData.postprocessing_template_id || '';
} else if (streamType === 'static_image') { } else if (streamType === 'static_image') {
(document.getElementById('stream-image-source') as HTMLInputElement).value = cloneData.image_source || ''; if (cloneData.image_asset_id) {
if (cloneData.image_source) validateStaticImage(); (document.getElementById('stream-image-asset') as HTMLSelectElement).value = cloneData.image_asset_id;
if (_imageAssetEntitySelect) _imageAssetEntitySelect.setValue(cloneData.image_asset_id);
}
} else if (streamType === 'video') { } else if (streamType === 'video') {
(document.getElementById('stream-video-url') as HTMLInputElement).value = cloneData.url || ''; if (cloneData.video_asset_id) {
(document.getElementById('stream-video-asset') as HTMLSelectElement).value = cloneData.video_asset_id;
if (_videoAssetEntitySelect) _videoAssetEntitySelect.setValue(cloneData.video_asset_id);
}
(document.getElementById('stream-video-loop') as HTMLInputElement).checked = cloneData.loop !== false; (document.getElementById('stream-video-loop') as HTMLInputElement).checked = cloneData.loop !== false;
(document.getElementById('stream-video-speed') as HTMLInputElement).value = cloneData.playback_speed || 1.0; (document.getElementById('stream-video-speed') as HTMLInputElement).value = cloneData.playback_speed || 1.0;
const cloneSpeedLabel = document.getElementById('stream-video-speed-value'); const cloneSpeedLabel = document.getElementById('stream-video-speed-value');
@@ -951,13 +1014,8 @@ export async function editStream(streamId: any) {
(document.getElementById('stream-description') as HTMLInputElement).value = stream.description || ''; (document.getElementById('stream-description') as HTMLInputElement).value = stream.description || '';
(document.getElementById('stream-type') as HTMLSelectElement).value = stream.stream_type; (document.getElementById('stream-type') as HTMLSelectElement).value = stream.stream_type;
set_lastValidatedImageSource(''); _ensureImageAssetEntitySelect();
const imgSrcInput = document.getElementById('stream-image-source') as HTMLInputElement; _ensureVideoAssetEntitySelect();
document.getElementById('stream-image-preview-container')!.style.display = 'none';
document.getElementById('stream-image-validation-status')!.style.display = 'none';
imgSrcInput.onblur = () => validateStaticImage();
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
onStreamTypeChange(); onStreamTypeChange();
await populateStreamModalDropdowns(); await populateStreamModalDropdowns();
@@ -976,10 +1034,15 @@ export async function editStream(streamId: any) {
(document.getElementById('stream-source') as HTMLSelectElement).value = stream.source_stream_id || ''; (document.getElementById('stream-source') as HTMLSelectElement).value = stream.source_stream_id || '';
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = stream.postprocessing_template_id || ''; (document.getElementById('stream-pp-template') as HTMLSelectElement).value = stream.postprocessing_template_id || '';
} else if (stream.stream_type === 'static_image') { } else if (stream.stream_type === 'static_image') {
(document.getElementById('stream-image-source') as HTMLInputElement).value = stream.image_source || ''; if (stream.image_asset_id) {
if (stream.image_source) validateStaticImage(); (document.getElementById('stream-image-asset') as HTMLSelectElement).value = stream.image_asset_id;
if (_imageAssetEntitySelect) _imageAssetEntitySelect.setValue(stream.image_asset_id);
}
} else if (stream.stream_type === 'video') { } else if (stream.stream_type === 'video') {
(document.getElementById('stream-video-url') as HTMLInputElement).value = stream.url || ''; if (stream.video_asset_id) {
(document.getElementById('stream-video-asset') as HTMLSelectElement).value = stream.video_asset_id;
if (_videoAssetEntitySelect) _videoAssetEntitySelect.setValue(stream.video_asset_id);
}
(document.getElementById('stream-video-loop') as HTMLInputElement).checked = stream.loop !== false; (document.getElementById('stream-video-loop') as HTMLInputElement).checked = stream.loop !== false;
(document.getElementById('stream-video-speed') as HTMLInputElement).value = stream.playback_speed || 1.0; (document.getElementById('stream-video-speed') as HTMLInputElement).value = stream.playback_speed || 1.0;
const speedLabel = document.getElementById('stream-video-speed-value'); const speedLabel = document.getElementById('stream-video-speed-value');
@@ -1164,13 +1227,13 @@ export async function saveStream() {
payload.source_stream_id = (document.getElementById('stream-source') as HTMLSelectElement).value; payload.source_stream_id = (document.getElementById('stream-source') as HTMLSelectElement).value;
payload.postprocessing_template_id = (document.getElementById('stream-pp-template') as HTMLSelectElement).value; payload.postprocessing_template_id = (document.getElementById('stream-pp-template') as HTMLSelectElement).value;
} else if (streamType === 'static_image') { } else if (streamType === 'static_image') {
const imageSource = (document.getElementById('stream-image-source') as HTMLInputElement).value.trim(); const imageAssetId = (document.getElementById('stream-image-asset') as HTMLSelectElement).value;
if (!imageSource) { showToast(t('streams.error.required'), 'error'); return; } if (!imageAssetId) { showToast(t('streams.error.required'), 'error'); return; }
payload.image_source = imageSource; payload.image_asset_id = imageAssetId;
} else if (streamType === 'video') { } else if (streamType === 'video') {
const url = (document.getElementById('stream-video-url') as HTMLInputElement).value.trim(); const videoAssetId = (document.getElementById('stream-video-asset') as HTMLSelectElement).value;
if (!url) { showToast(t('streams.error.required'), 'error'); return; } if (!videoAssetId) { showToast(t('streams.error.required'), 'error'); return; }
payload.url = url; payload.video_asset_id = videoAssetId;
payload.loop = (document.getElementById('stream-video-loop') as HTMLInputElement).checked; payload.loop = (document.getElementById('stream-video-loop') as HTMLInputElement).checked;
payload.playback_speed = parseFloat((document.getElementById('stream-video-speed') as HTMLInputElement).value) || 1.0; payload.playback_speed = parseFloat((document.getElementById('stream-video-speed') as HTMLInputElement).value) || 1.0;
payload.target_fps = parseInt((document.getElementById('stream-video-fps') as HTMLInputElement).value) || 30; payload.target_fps = parseInt((document.getElementById('stream-video-fps') as HTMLInputElement).value) || 30;
@@ -1239,55 +1302,6 @@ export async function closeStreamModal() {
await streamModal.close(); await streamModal.close();
} }
async function validateStaticImage() {
const source = (document.getElementById('stream-image-source') as HTMLInputElement).value.trim();
const previewContainer = document.getElementById('stream-image-preview-container')!;
const previewImg = document.getElementById('stream-image-preview') as HTMLImageElement;
const infoEl = document.getElementById('stream-image-info')!;
const statusEl = document.getElementById('stream-image-validation-status')!;
if (!source) {
set_lastValidatedImageSource('');
previewContainer.style.display = 'none';
statusEl.style.display = 'none';
return;
}
if (source === _lastValidatedImageSource) return;
statusEl.textContent = t('streams.validate_image.validating');
statusEl.className = 'validation-status loading';
statusEl.style.display = 'block';
previewContainer.style.display = 'none';
try {
const response = await fetchWithAuth('/picture-sources/validate-image', {
method: 'POST',
body: JSON.stringify({ image_source: source }),
});
const data = await response.json();
set_lastValidatedImageSource(source);
if (data.valid) {
previewImg.src = data.preview;
previewImg.style.cursor = 'pointer';
previewImg.onclick = () => openFullImageLightbox(source);
infoEl.textContent = `${data.width} × ${data.height} px`;
previewContainer.style.display = '';
statusEl.textContent = t('streams.validate_image.valid');
statusEl.className = 'validation-status success';
} else {
previewContainer.style.display = 'none';
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`;
statusEl.className = 'validation-status error';
}
} catch (err) {
previewContainer.style.display = 'none';
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`;
statusEl.className = 'validation-status error';
}
}
// ===== Picture Source Test ===== // ===== Picture Source Test =====
export async function showTestStreamModal(streamId: any) { export async function showTestStreamModal(streamId: any) {

View File

@@ -266,8 +266,8 @@ interface Window {
previewCSSFromEditor: (...args: any[]) => any; previewCSSFromEditor: (...args: any[]) => any;
copyEndpointUrl: (...args: any[]) => any; copyEndpointUrl: (...args: any[]) => any;
onNotificationFilterModeChange: (...args: any[]) => any; onNotificationFilterModeChange: (...args: any[]) => any;
notificationAddAppColor: (...args: any[]) => any; notificationAddAppOverride: (...args: any[]) => any;
notificationRemoveAppColor: (...args: any[]) => any; notificationRemoveAppOverride: (...args: any[]) => any;
testNotification: (...args: any[]) => any; testNotification: (...args: any[]) => any;
showNotificationHistory: (...args: any[]) => any; showNotificationHistory: (...args: any[]) => any;
closeNotificationHistory: (...args: any[]) => any; closeNotificationHistory: (...args: any[]) => any;

View File

@@ -345,10 +345,10 @@ export interface PictureSource {
postprocessing_template_id?: string; postprocessing_template_id?: string;
// Static image // Static image
image_source?: string; image_asset_id?: string;
// Video // Video
url?: string; video_asset_id?: string;
loop?: boolean; loop?: boolean;
playback_speed?: number; playback_speed?: number;
start_time?: number; start_time?: number;
@@ -413,6 +413,27 @@ export interface WeatherSourceListResponse {
count: number; count: number;
} }
// ── Asset ────────────────────────────────────────────────────
export interface Asset {
id: string;
name: string;
filename: string;
mime_type: string;
asset_type: string;
size_bytes: number;
description?: string;
tags: string[];
prebuilt: boolean;
created_at: string;
updated_at: string;
}
export interface AssetListResponse {
assets: Asset[];
count: number;
}
// ── Automation ──────────────────────────────────────────────── // ── Automation ────────────────────────────────────────────────
export type ConditionType = export type ConditionType =

View File

@@ -593,22 +593,19 @@
"streams.add.video": "Add Video Source", "streams.add.video": "Add Video Source",
"streams.edit.video": "Edit Video Source", "streams.edit.video": "Edit Video Source",
"picture_source.type.video": "Video", "picture_source.type.video": "Video",
"picture_source.type.video.desc": "Stream frames from video file, URL, or YouTube", "picture_source.type.video.desc": "Stream frames from an uploaded video asset",
"picture_source.video.url": "Video URL:",
"picture_source.video.url.hint": "Local file path, HTTP URL, or YouTube URL",
"picture_source.video.url.placeholder": "https://example.com/video.mp4",
"picture_source.video.loop": "Loop:", "picture_source.video.loop": "Loop:",
"picture_source.video.speed": "Playback Speed:", "picture_source.video.speed": "Playback Speed:",
"picture_source.video.start_time": "Start Time (s):", "picture_source.video.start_time": "Start Time (s):",
"picture_source.video.end_time": "End Time (s):", "picture_source.video.end_time": "End Time (s):",
"picture_source.video.resolution_limit": "Max Width (px):", "picture_source.video.resolution_limit": "Max Width (px):",
"picture_source.video.resolution_limit.hint": "Downscale video at decode time for performance", "picture_source.video.resolution_limit.hint": "Downscale video at decode time for performance",
"streams.image_source": "Image Source:", "streams.image_asset": "Image Asset:",
"streams.image_source.placeholder": "https://example.com/image.jpg or C:\\path\\to\\image.png", "streams.image_asset.select": "Select image asset…",
"streams.image_source.hint": "Enter a URL (http/https) or local file path to an image", "streams.image_asset.search": "Search image assets…",
"streams.validate_image.validating": "Validating...", "streams.video_asset": "Video Asset:",
"streams.validate_image.valid": "Image accessible", "streams.video_asset.select": "Select video asset…",
"streams.validate_image.invalid": "Image not accessible", "streams.video_asset.search": "Search video assets…",
"targets.title": "Targets", "targets.title": "Targets",
"targets.description": "Targets bridge color strip sources to output devices. Each target references a device and a color strip source.", "targets.description": "Targets bridge color strip sources to output devices. Each target references a device and a color strip source.",
"targets.subtab.wled": "LED", "targets.subtab.wled": "LED",
@@ -1105,6 +1102,22 @@
"color_strip.notification.app_colors.label": "Color Mappings:", "color_strip.notification.app_colors.label": "Color Mappings:",
"color_strip.notification.app_colors.hint": "Per-app color overrides. Each row maps an app name to a specific notification color.", "color_strip.notification.app_colors.hint": "Per-app color overrides. Each row maps an app name to a specific notification color.",
"color_strip.notification.app_colors.add": "+ Add Mapping", "color_strip.notification.app_colors.add": "+ Add Mapping",
"color_strip.notification.app_overrides": "Per-App Overrides",
"color_strip.notification.app_overrides.label": "App Overrides:",
"color_strip.notification.app_overrides.hint": "Per-app overrides for color and sound. Each row can set a custom color, sound asset, and volume for a specific app.",
"color_strip.notification.app_overrides.add": "+ Add Override",
"color_strip.notification.app_overrides.app_placeholder": "App name",
"color_strip.notification.sound": "Sound",
"color_strip.notification.sound.asset": "Sound Asset:",
"color_strip.notification.sound.asset.hint": "Pick a sound asset to play when a notification fires. Leave empty for silent.",
"color_strip.notification.sound.none": "None (silent)",
"color_strip.notification.sound.search": "Search sounds…",
"color_strip.notification.sound.volume": "Volume:",
"color_strip.notification.sound.volume.hint": "Global volume for notification sounds (0100%).",
"color_strip.notification.sound.app_sounds": "Per-App Sounds:",
"color_strip.notification.sound.app_sounds.hint": "Override sound and volume for specific apps. Empty sound = mute that app.",
"color_strip.notification.sound.app_sounds.add": "+ Add Override",
"color_strip.notification.sound.app_name_placeholder": "App name",
"color_strip.notification.endpoint": "Webhook Endpoint:", "color_strip.notification.endpoint": "Webhook Endpoint:",
"color_strip.notification.endpoint.hint": "Use this URL to trigger notifications from external systems. POST with optional JSON body: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.", "color_strip.notification.endpoint.hint": "Use this URL to trigger notifications from external systems. POST with optional JSON body: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
"color_strip.notification.save_first": "Save the source first to see the webhook endpoint URL.", "color_strip.notification.save_first": "Save the source first to see the webhook endpoint URL.",
@@ -1963,9 +1976,35 @@
"update.install_type.docker": "Docker", "update.install_type.docker": "Docker",
"update.install_type.dev": "Development", "update.install_type.dev": "Development",
"color_strip": { "color_strip.notification.search_apps": "Search notification apps…",
"notification": {
"search_apps": "Search notification apps…" "asset.group.title": "Assets",
} "asset.upload": "Upload Asset",
} "asset.edit": "Edit Asset",
"asset.name": "Name:",
"asset.name.hint": "Display name for this asset.",
"asset.description": "Description:",
"asset.description.hint": "Optional description for this asset.",
"asset.file": "File:",
"asset.file.hint": "Select a file to upload (sound, image, video, or other).",
"asset.drop_or_browse": "Drop file here or click to browse",
"asset.uploaded": "Asset uploaded",
"asset.updated": "Asset updated",
"asset.deleted": "Asset deleted",
"asset.confirm_delete": "Delete this asset?",
"asset.error.name_required": "Name is required",
"asset.error.no_file": "Please select a file to upload",
"asset.error.delete_failed": "Failed to delete asset",
"asset.play": "Play",
"asset.download": "Download",
"asset.prebuilt": "Prebuilt",
"asset.prebuilt_restored": "{count} prebuilt asset(s) restored",
"asset.prebuilt_none_to_restore": "All prebuilt assets are already available",
"asset.restore_prebuilt": "Restore Prebuilt Sounds",
"asset.type.sound": "Sound",
"asset.type.image": "Image",
"asset.type.video": "Video",
"asset.type.other": "Other",
"streams.group.assets": "Assets",
"section.empty.assets": "No assets yet. Click + to upload one."
} }

View File

@@ -593,22 +593,19 @@
"streams.add.video": "Добавить видеоисточник", "streams.add.video": "Добавить видеоисточник",
"streams.edit.video": "Редактировать видеоисточник", "streams.edit.video": "Редактировать видеоисточник",
"picture_source.type.video": "Видео", "picture_source.type.video": "Видео",
"picture_source.type.video.desc": "Потоковые кадры из видеофайла, URL или YouTube", "picture_source.type.video.desc": "Потоковые кадры из загруженного видео",
"picture_source.video.url": "URL видео:",
"picture_source.video.url.hint": "Локальный файл, HTTP URL или YouTube URL",
"picture_source.video.url.placeholder": "https://example.com/video.mp4",
"picture_source.video.loop": "Зацикливание:", "picture_source.video.loop": "Зацикливание:",
"picture_source.video.speed": "Скорость воспроизведения:", "picture_source.video.speed": "Скорость воспроизведения:",
"picture_source.video.start_time": "Время начала (с):", "picture_source.video.start_time": "Время начала (с):",
"picture_source.video.end_time": "Время окончания (с):", "picture_source.video.end_time": "Время окончания (с):",
"picture_source.video.resolution_limit": "Макс. ширина (px):", "picture_source.video.resolution_limit": "Макс. ширина (px):",
"picture_source.video.resolution_limit.hint": "Уменьшение видео при декодировании для производительности", "picture_source.video.resolution_limit.hint": "Уменьшение видео при декодировании для производительности",
"streams.image_source": сточник изображения:", "streams.image_asset": "Изображение:",
"streams.image_source.placeholder": "https://example.com/image.jpg или C:\\path\\to\\image.png", "streams.image_asset.select": "Выберите изображение…",
"streams.image_source.hint": "Введите URL (http/https) или локальный путь к изображению", "streams.image_asset.search": "Поиск изображений…",
"streams.validate_image.validating": "Проверка...", "streams.video_asset": "Видео:",
"streams.validate_image.valid": "Изображение доступно", "streams.video_asset.select": "Выберите видео…",
"streams.validate_image.invalid": "Изображение недоступно", "streams.video_asset.search": "Поиск видео…",
"targets.title": "Цели", "targets.title": "Цели",
"targets.description": "Цели связывают источники цветовых полос с устройствами вывода. Каждая цель ссылается на устройство и источник цветовой полосы.", "targets.description": "Цели связывают источники цветовых полос с устройствами вывода. Каждая цель ссылается на устройство и источник цветовой полосы.",
"targets.subtab.wled": "LED", "targets.subtab.wled": "LED",
@@ -1084,6 +1081,22 @@
"color_strip.notification.app_colors.label": "Назначения цветов:", "color_strip.notification.app_colors.label": "Назначения цветов:",
"color_strip.notification.app_colors.hint": "Индивидуальные цвета для приложений. Каждая строка связывает имя приложения с цветом уведомления.", "color_strip.notification.app_colors.hint": "Индивидуальные цвета для приложений. Каждая строка связывает имя приложения с цветом уведомления.",
"color_strip.notification.app_colors.add": "+ Добавить", "color_strip.notification.app_colors.add": "+ Добавить",
"color_strip.notification.app_overrides": "Настройки приложений",
"color_strip.notification.app_overrides.label": "Переопределения:",
"color_strip.notification.app_overrides.hint": "Индивидуальные настройки цвета и звука для каждого приложения.",
"color_strip.notification.app_overrides.add": "+ Добавить",
"color_strip.notification.app_overrides.app_placeholder": "Имя приложения",
"color_strip.notification.sound": "Звук",
"color_strip.notification.sound.asset": "Звуковой ассет:",
"color_strip.notification.sound.asset.hint": "Выберите звуковой ассет для воспроизведения при уведомлении. Оставьте пустым для тишины.",
"color_strip.notification.sound.none": "Нет (без звука)",
"color_strip.notification.sound.search": "Поиск звуков…",
"color_strip.notification.sound.volume": "Громкость:",
"color_strip.notification.sound.volume.hint": "Общая громкость звуков уведомлений (0100%).",
"color_strip.notification.sound.app_sounds": "Звуки приложений:",
"color_strip.notification.sound.app_sounds.hint": "Переопределение звука и громкости для конкретных приложений. Пустой звук = отключить для этого приложения.",
"color_strip.notification.sound.app_sounds.add": "+ Добавить",
"color_strip.notification.sound.app_name_placeholder": "Имя приложения",
"color_strip.notification.endpoint": "Вебхук:", "color_strip.notification.endpoint": "Вебхук:",
"color_strip.notification.endpoint.hint": "URL для запуска уведомлений из внешних систем. POST с JSON телом: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.", "color_strip.notification.endpoint.hint": "URL для запуска уведомлений из внешних систем. POST с JSON телом: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
"color_strip.notification.save_first": "Сначала сохраните источник, чтобы увидеть URL вебхука.", "color_strip.notification.save_first": "Сначала сохраните источник, чтобы увидеть URL вебхука.",
@@ -1892,9 +1905,35 @@
"update.install_type.docker": "Docker", "update.install_type.docker": "Docker",
"update.install_type.dev": "Разработка", "update.install_type.dev": "Разработка",
"color_strip": { "color_strip.notification.search_apps": "Поиск приложений…",
"notification": {
"search_apps": "Поиск приложений…" "asset.group.title": "Ресурсы",
} "asset.upload": "Загрузить ресурс",
} "asset.edit": "Редактировать ресурс",
"asset.name": "Название:",
"asset.name.hint": "Отображаемое название ресурса.",
"asset.description": "Описание:",
"asset.description.hint": "Необязательное описание ресурса.",
"asset.file": "Файл:",
"asset.file.hint": "Выберите файл для загрузки (звук, изображение, видео или другое).",
"asset.drop_or_browse": "Перетащите файл сюда или нажмите для выбора",
"asset.uploaded": "Ресурс загружен",
"asset.updated": "Ресурс обновлён",
"asset.deleted": "Ресурс удалён",
"asset.confirm_delete": "Удалить этот ресурс?",
"asset.error.name_required": "Название обязательно",
"asset.error.no_file": "Выберите файл для загрузки",
"asset.error.delete_failed": "Не удалось удалить ресурс",
"asset.play": "Воспроизвести",
"asset.download": "Скачать",
"asset.prebuilt": "Встроенный",
"asset.prebuilt_restored": "Восстановлено встроенных ресурсов: {count}",
"asset.prebuilt_none_to_restore": "Все встроенные ресурсы уже доступны",
"asset.restore_prebuilt": "Восстановить встроенные звуки",
"asset.type.sound": "Звук",
"asset.type.image": "Изображение",
"asset.type.video": "Видео",
"asset.type.other": "Другое",
"streams.group.assets": "Ресурсы",
"section.empty.assets": "Ресурсов пока нет. Нажмите +, чтобы загрузить."
} }

View File

@@ -593,22 +593,19 @@
"streams.add.video": "添加视频源", "streams.add.video": "添加视频源",
"streams.edit.video": "编辑视频源", "streams.edit.video": "编辑视频源",
"picture_source.type.video": "视频", "picture_source.type.video": "视频",
"picture_source.type.video.desc": "从视频文件、URL或YouTube流式传输帧", "picture_source.type.video.desc": "从上传的视频素材中流式传输帧",
"picture_source.video.url": "视频URL",
"picture_source.video.url.hint": "本地文件路径、HTTP URL或YouTube URL",
"picture_source.video.url.placeholder": "https://example.com/video.mp4",
"picture_source.video.loop": "循环:", "picture_source.video.loop": "循环:",
"picture_source.video.speed": "播放速度:", "picture_source.video.speed": "播放速度:",
"picture_source.video.start_time": "开始时间(秒):", "picture_source.video.start_time": "开始时间(秒):",
"picture_source.video.end_time": "结束时间(秒):", "picture_source.video.end_time": "结束时间(秒):",
"picture_source.video.resolution_limit": "最大宽度(像素):", "picture_source.video.resolution_limit": "最大宽度(像素):",
"picture_source.video.resolution_limit.hint": "解码时缩小视频以提高性能", "picture_source.video.resolution_limit.hint": "解码时缩小视频以提高性能",
"streams.image_source": "图片", "streams.image_asset": "图片素材",
"streams.image_source.placeholder": "https://example.com/image.jpg 或 C:\\path\\to\\image.png", "streams.image_asset.select": "选择图片素材…",
"streams.image_source.hint": "输入图片的 URLhttp/https或本地文件路径", "streams.image_asset.search": "搜索图片素材…",
"streams.validate_image.validating": "正在验证...", "streams.video_asset": "视频素材:",
"streams.validate_image.valid": "图片可访问", "streams.video_asset.select": "选择视频素材…",
"streams.validate_image.invalid": "图片不可访问", "streams.video_asset.search": "搜索视频素材…",
"targets.title": "目标", "targets.title": "目标",
"targets.description": "目标将色带源桥接到输出设备。每个目标引用一个设备和一个色带源。", "targets.description": "目标将色带源桥接到输出设备。每个目标引用一个设备和一个色带源。",
"targets.subtab.wled": "LED", "targets.subtab.wled": "LED",
@@ -1084,6 +1081,22 @@
"color_strip.notification.app_colors.label": "颜色映射:", "color_strip.notification.app_colors.label": "颜色映射:",
"color_strip.notification.app_colors.hint": "每个应用的自定义通知颜色。每行将一个应用名称映射到特定颜色。", "color_strip.notification.app_colors.hint": "每个应用的自定义通知颜色。每行将一个应用名称映射到特定颜色。",
"color_strip.notification.app_colors.add": "+ 添加映射", "color_strip.notification.app_colors.add": "+ 添加映射",
"color_strip.notification.app_overrides": "按应用覆盖",
"color_strip.notification.app_overrides.label": "应用覆盖:",
"color_strip.notification.app_overrides.hint": "为特定应用自定义颜色和声音。每行可设置颜色、声音资源和音量。",
"color_strip.notification.app_overrides.add": "+ 添加覆盖",
"color_strip.notification.app_overrides.app_placeholder": "应用名称",
"color_strip.notification.sound": "声音",
"color_strip.notification.sound.asset": "声音资源:",
"color_strip.notification.sound.asset.hint": "选择通知触发时播放的声音资源。留空表示静音。",
"color_strip.notification.sound.none": "无(静音)",
"color_strip.notification.sound.search": "搜索声音…",
"color_strip.notification.sound.volume": "音量:",
"color_strip.notification.sound.volume.hint": "通知声音的全局音量0100%)。",
"color_strip.notification.sound.app_sounds": "按应用声音:",
"color_strip.notification.sound.app_sounds.hint": "为特定应用覆盖声音和音量。空声音 = 静音该应用。",
"color_strip.notification.sound.app_sounds.add": "+ 添加覆盖",
"color_strip.notification.sound.app_name_placeholder": "应用名称",
"color_strip.notification.endpoint": "Webhook 端点:", "color_strip.notification.endpoint": "Webhook 端点:",
"color_strip.notification.endpoint.hint": "使用此 URL 从外部系统触发通知。POST 请求可选 JSON{\"app\": \"AppName\", \"color\": \"#FF0000\"}。", "color_strip.notification.endpoint.hint": "使用此 URL 从外部系统触发通知。POST 请求可选 JSON{\"app\": \"AppName\", \"color\": \"#FF0000\"}。",
"color_strip.notification.save_first": "请先保存源以查看 Webhook 端点 URL。", "color_strip.notification.save_first": "请先保存源以查看 Webhook 端点 URL。",
@@ -1890,9 +1903,35 @@
"update.install_type.docker": "Docker", "update.install_type.docker": "Docker",
"update.install_type.dev": "开发环境", "update.install_type.dev": "开发环境",
"color_strip": { "color_strip.notification.search_apps": "搜索通知应用…",
"notification": {
"search_apps": "搜索通知应用…" "asset.group.title": "资源",
} "asset.upload": "上传资源",
} "asset.edit": "编辑资源",
"asset.name": "名称:",
"asset.name.hint": "资源的显示名称。",
"asset.description": "描述:",
"asset.description.hint": "资源的可选描述。",
"asset.file": "文件:",
"asset.file.hint": "选择要上传的文件(声音、图片、视频或其他)。",
"asset.drop_or_browse": "拖放文件到此处或点击浏览",
"asset.uploaded": "资源已上传",
"asset.updated": "资源已更新",
"asset.deleted": "资源已删除",
"asset.confirm_delete": "删除此资源?",
"asset.error.name_required": "名称为必填项",
"asset.error.no_file": "请选择要上传的文件",
"asset.error.delete_failed": "删除资源失败",
"asset.play": "播放",
"asset.download": "下载",
"asset.prebuilt": "内置",
"asset.prebuilt_restored": "已恢复 {count} 个内置资源",
"asset.prebuilt_none_to_restore": "所有内置资源均已可用",
"asset.restore_prebuilt": "恢复内置声音",
"asset.type.sound": "声音",
"asset.type.image": "图片",
"asset.type.video": "视频",
"asset.type.other": "其他",
"streams.group.assets": "资源",
"section.empty.assets": "暂无资源。点击 + 上传一个。"
} }

View File

@@ -0,0 +1,80 @@
"""Asset data model.
An Asset represents an uploaded file (sound, image, video, or other)
stored on the server. Assets are referenced by ID from other entities
(e.g. NotificationColorStripSource uses sound assets for alert sounds).
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Optional
# Map MIME type prefixes to asset_type categories
_MIME_TO_ASSET_TYPE = {
"audio/": "sound",
"image/": "image",
"video/": "video",
}
def asset_type_from_mime(mime_type: str) -> str:
"""Derive asset_type from a MIME type string."""
for prefix, asset_type in _MIME_TO_ASSET_TYPE.items():
if mime_type.startswith(prefix):
return asset_type
return "other"
@dataclass
class Asset:
"""Persistent metadata for an uploaded file asset."""
id: str
name: str
filename: str # original upload filename
stored_filename: str # on-disk filename (uuid-based)
mime_type: str # e.g. "audio/wav", "image/png"
asset_type: str # "sound" | "image" | "video" | "other"
size_bytes: int
created_at: datetime
updated_at: datetime
description: Optional[str] = None
tags: List[str] = field(default_factory=list)
prebuilt: bool = False # True for shipped assets
deleted: bool = False # soft-delete for prebuilt assets
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"filename": self.filename,
"stored_filename": self.stored_filename,
"mime_type": self.mime_type,
"asset_type": self.asset_type,
"size_bytes": self.size_bytes,
"description": self.description,
"tags": self.tags,
"prebuilt": self.prebuilt,
"deleted": self.deleted,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@staticmethod
def from_dict(data: dict) -> "Asset":
return Asset(
id=data["id"],
name=data["name"],
filename=data.get("filename", ""),
stored_filename=data.get("stored_filename", ""),
mime_type=data.get("mime_type", "application/octet-stream"),
asset_type=data.get("asset_type", "other"),
size_bytes=int(data.get("size_bytes", 0)),
description=data.get("description"),
tags=data.get("tags", []),
prebuilt=bool(data.get("prebuilt", False)),
deleted=bool(data.get("deleted", False)),
created_at=datetime.fromisoformat(data["created_at"]),
updated_at=datetime.fromisoformat(data["updated_at"]),
)

View File

@@ -0,0 +1,219 @@
"""Asset storage with file management.
Metadata is stored in SQLite via BaseSqliteStore; actual files live
in the assets directory on disk.
"""
import mimetypes
import shutil
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from wled_controller.storage.asset import Asset, asset_type_from_mime
from wled_controller.storage.base_sqlite_store import BaseSqliteStore
from wled_controller.storage.database import Database
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class AssetStore(BaseSqliteStore[Asset]):
"""Persistent storage for uploaded file assets."""
_table_name = "assets"
_entity_name = "Asset"
def __init__(self, db: Database, assets_dir: str | Path):
self._assets_dir = Path(assets_dir)
self._assets_dir.mkdir(parents=True, exist_ok=True)
super().__init__(db, Asset.from_dict)
# -- Aliases for consistency with other stores ----------------------------
get_all_assets = BaseSqliteStore.get_all
get_asset = BaseSqliteStore.get
def get_visible_assets(self) -> List[Asset]:
"""Return all assets that are not soft-deleted."""
return [a for a in self._items.values() if not a.deleted]
def get_assets_by_type(self, asset_type: str) -> List[Asset]:
"""Return visible assets filtered by type."""
return [a for a in self._items.values()
if not a.deleted and a.asset_type == asset_type]
def get_file_path(self, asset_id: str) -> Optional[Path]:
"""Resolve the on-disk path for an asset's file. Returns None if missing."""
asset = self._items.get(asset_id)
if asset is None or asset.deleted:
return None
path = self._assets_dir / asset.stored_filename
return path if path.exists() else None
def create_asset(
self,
name: str,
filename: str,
file_data: bytes,
mime_type: Optional[str] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
prebuilt: bool = False,
) -> Asset:
"""Create a new asset from uploaded file data.
Args:
name: Display name for the asset.
filename: Original upload filename.
file_data: Raw file bytes.
mime_type: MIME type (auto-detected from filename if not provided).
description: Optional description.
tags: Optional tags.
prebuilt: Whether this is a shipped prebuilt asset.
Returns:
The created Asset.
"""
self._check_name_unique(name)
if not mime_type:
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
asset_type = asset_type_from_mime(mime_type)
# Generate unique stored filename
ext = Path(filename).suffix
stored_filename = f"{uuid.uuid4().hex}{ext}"
# Write file to disk
dest = self._assets_dir / stored_filename
dest.write_bytes(file_data)
asset_id = f"asset_{uuid.uuid4().hex[:8]}"
now = datetime.now(timezone.utc)
asset = Asset(
id=asset_id,
name=name,
filename=filename,
stored_filename=stored_filename,
mime_type=mime_type,
asset_type=asset_type,
size_bytes=len(file_data),
created_at=now,
updated_at=now,
description=description,
tags=tags or [],
prebuilt=prebuilt,
)
self._items[asset_id] = asset
self._save_item(asset_id, asset)
logger.info(f"Created asset: {name} ({asset_id}, type={asset_type}, {len(file_data)} bytes)")
return asset
def update_asset(
self,
asset_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> Asset:
"""Update asset metadata (not the file itself)."""
asset = self.get(asset_id)
if name is not None:
self._check_name_unique(name, exclude_id=asset_id)
asset.name = name
if description is not None:
asset.description = description
if tags is not None:
asset.tags = tags
asset.updated_at = datetime.now(timezone.utc)
self._save_item(asset_id, asset)
logger.info(f"Updated asset: {asset_id}")
return asset
def delete_asset(self, asset_id: str) -> None:
"""Delete an asset. Prebuilt assets are soft-deleted; others are fully removed."""
asset = self.get(asset_id)
if asset.prebuilt:
# Soft-delete: mark as deleted, remove file but keep metadata
asset.deleted = True
asset.updated_at = datetime.now(timezone.utc)
self._save_item(asset_id, asset)
file_path = self._assets_dir / asset.stored_filename
if file_path.exists():
file_path.unlink()
logger.info(f"Soft-deleted prebuilt asset: {asset_id}")
else:
# Hard delete: remove file and metadata
file_path = self._assets_dir / asset.stored_filename
if file_path.exists():
file_path.unlink()
del self._items[asset_id]
self._delete_item(asset_id)
logger.info(f"Deleted asset: {asset_id}")
def restore_prebuilt(self, prebuilt_dir: Path) -> List[Asset]:
"""Re-import any soft-deleted prebuilt assets from the prebuilt directory.
Returns list of restored assets.
"""
restored = []
for asset in list(self._items.values()):
if asset.prebuilt and asset.deleted:
# Find original file in prebuilt dir
src = prebuilt_dir / asset.filename
if src.exists():
dest = self._assets_dir / asset.stored_filename
shutil.copy2(src, dest)
asset.deleted = False
asset.size_bytes = dest.stat().st_size
asset.updated_at = datetime.now(timezone.utc)
self._save_item(asset.id, asset)
restored.append(asset)
logger.info(f"Restored prebuilt asset: {asset.name} ({asset.id})")
return restored
def import_prebuilt_sounds(self, prebuilt_dir: Path) -> List[Asset]:
"""Import prebuilt sound files that don't already exist as assets.
Called on startup. Skips files that are already imported (by original
filename match with prebuilt=True), including soft-deleted ones.
Returns list of newly imported assets.
"""
if not prebuilt_dir.exists():
return []
# Build set of known prebuilt filenames (including deleted ones)
known_filenames = {
a.filename for a in self._items.values() if a.prebuilt
}
imported = []
for src in sorted(prebuilt_dir.iterdir()):
if not src.is_file():
continue
if src.name in known_filenames:
continue
file_data = src.read_bytes()
# Derive a friendly name from filename: "chime.wav" -> "Chime"
friendly_name = src.stem.replace("_", " ").replace("-", " ").title()
asset = self.create_asset(
name=friendly_name,
filename=src.name,
file_data=file_data,
prebuilt=True,
)
imported.append(asset)
logger.info(f"Imported prebuilt sound: {src.name} -> {asset.id}")
return imported

View File

@@ -1,9 +1,12 @@
"""Automation and Condition data models.""" """Automation and Condition data models."""
import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, List, Optional, Type from typing import Dict, List, Optional, Type
logger = logging.getLogger(__name__)
@dataclass @dataclass
class Condition: class Condition:
@@ -226,7 +229,8 @@ class Automation:
for c_data in data.get("conditions", []): for c_data in data.get("conditions", []):
try: try:
conditions.append(Condition.from_dict(c_data)) conditions.append(Condition.from_dict(c_data))
except ValueError: except ValueError as e:
logger.warning("Skipping unknown condition type on load: %s", e)
pass # skip unknown condition types on load pass # skip unknown condition types on load
return cls( return cls(

View File

@@ -838,6 +838,9 @@ class NotificationColorStripSource(ColorStripSource):
app_filter_mode: str = "off" # off | whitelist | blacklist app_filter_mode: str = "off" # off | whitelist | blacklist
app_filter_list: list = field(default_factory=list) # app names for filter app_filter_list: list = field(default_factory=list) # app names for filter
os_listener: bool = False # whether to listen for OS notifications os_listener: bool = False # whether to listen for OS notifications
sound_asset_id: Optional[str] = None # global notification sound (asset ID)
sound_volume: float = 1.0 # global volume 0.0-1.0
app_sounds: dict = field(default_factory=dict) # app name -> {"sound_asset_id": str|None, "volume": float|None}
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
@@ -848,6 +851,9 @@ class NotificationColorStripSource(ColorStripSource):
d["app_filter_mode"] = self.app_filter_mode d["app_filter_mode"] = self.app_filter_mode
d["app_filter_list"] = list(self.app_filter_list) d["app_filter_list"] = list(self.app_filter_list)
d["os_listener"] = self.os_listener d["os_listener"] = self.os_listener
d["sound_asset_id"] = self.sound_asset_id
d["sound_volume"] = self.sound_volume
d["app_sounds"] = dict(self.app_sounds)
return d return d
@classmethod @classmethod
@@ -855,6 +861,7 @@ class NotificationColorStripSource(ColorStripSource):
common = _parse_css_common(data) common = _parse_css_common(data)
raw_app_colors = data.get("app_colors") raw_app_colors = data.get("app_colors")
raw_app_filter_list = data.get("app_filter_list") raw_app_filter_list = data.get("app_filter_list")
raw_app_sounds = data.get("app_sounds")
return cls( return cls(
**common, source_type="notification", **common, source_type="notification",
notification_effect=data.get("notification_effect") or "flash", notification_effect=data.get("notification_effect") or "flash",
@@ -864,6 +871,9 @@ class NotificationColorStripSource(ColorStripSource):
app_filter_mode=data.get("app_filter_mode") or "off", app_filter_mode=data.get("app_filter_mode") or "off",
app_filter_list=raw_app_filter_list if isinstance(raw_app_filter_list, list) else [], app_filter_list=raw_app_filter_list if isinstance(raw_app_filter_list, list) else [],
os_listener=bool(data.get("os_listener", False)), os_listener=bool(data.get("os_listener", False)),
sound_asset_id=data.get("sound_asset_id"),
sound_volume=float(data.get("sound_volume", 1.0)),
app_sounds=raw_app_sounds if isinstance(raw_app_sounds, dict) else {},
) )
@classmethod @classmethod
@@ -873,7 +883,9 @@ class NotificationColorStripSource(ColorStripSource):
notification_effect=None, duration_ms=None, notification_effect=None, duration_ms=None,
default_color=None, app_colors=None, default_color=None, app_colors=None,
app_filter_mode=None, app_filter_list=None, app_filter_mode=None, app_filter_list=None,
os_listener=None, **_kwargs): os_listener=None, sound_asset_id=None,
sound_volume=None, app_sounds=None,
**_kwargs):
return cls( return cls(
id=id, name=name, source_type="notification", id=id, name=name, source_type="notification",
created_at=created_at, updated_at=updated_at, created_at=created_at, updated_at=updated_at,
@@ -885,6 +897,9 @@ class NotificationColorStripSource(ColorStripSource):
app_filter_mode=app_filter_mode or "off", app_filter_mode=app_filter_mode or "off",
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [], app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
os_listener=bool(os_listener) if os_listener is not None else False, os_listener=bool(os_listener) if os_listener is not None else False,
sound_asset_id=sound_asset_id,
sound_volume=float(sound_volume) if sound_volume is not None else 1.0,
app_sounds=app_sounds if isinstance(app_sounds, dict) else {},
) )
def apply_update(self, **kwargs) -> None: def apply_update(self, **kwargs) -> None:
@@ -904,6 +919,13 @@ class NotificationColorStripSource(ColorStripSource):
self.app_filter_list = app_filter_list self.app_filter_list = app_filter_list
if kwargs.get("os_listener") is not None: if kwargs.get("os_listener") is not None:
self.os_listener = bool(kwargs["os_listener"]) self.os_listener = bool(kwargs["os_listener"])
if "sound_asset_id" in kwargs:
self.sound_asset_id = kwargs["sound_asset_id"]
if kwargs.get("sound_volume") is not None:
self.sound_volume = float(kwargs["sound_volume"])
app_sounds = kwargs.get("app_sounds")
if app_sounds is not None and isinstance(app_sounds, dict):
self.app_sounds = app_sounds
@dataclass @dataclass

View File

@@ -55,9 +55,19 @@ _ENTITY_TABLES = [
"color_strip_processing_templates", "color_strip_processing_templates",
"gradients", "gradients",
"weather_sources", "weather_sources",
"assets",
] ]
_VALID_TABLES = frozenset(_ENTITY_TABLES) | {"settings", "schema_version"}
def _check_table(table: str) -> None:
"""Raise ValueError if *table* is not a known entity table."""
if table not in _VALID_TABLES:
raise ValueError(f"Invalid table name: {table!r}")
class Database: class Database:
"""Thread-safe SQLite connection wrapper with WAL mode. """Thread-safe SQLite connection wrapper with WAL mode.
@@ -169,6 +179,7 @@ class Database:
Returns list of dicts parsed from the ``data`` JSON column. Returns list of dicts parsed from the ``data`` JSON column.
""" """
_check_table(table)
with self._lock: with self._lock:
rows = self._conn.execute( rows = self._conn.execute(
f"SELECT id, data FROM [{table}]" f"SELECT id, data FROM [{table}]"
@@ -187,6 +198,7 @@ class Database:
Skipped silently when writes are frozen. Skipped silently when writes are frozen.
""" """
_check_table(table)
if _writes_frozen: if _writes_frozen:
return return
json_data = json.dumps(data, ensure_ascii=False) json_data = json.dumps(data, ensure_ascii=False)
@@ -202,6 +214,7 @@ class Database:
Skipped silently when writes are frozen. Skipped silently when writes are frozen.
""" """
_check_table(table)
if _writes_frozen: if _writes_frozen:
return return
with self._lock: with self._lock:
@@ -215,6 +228,7 @@ class Database:
Skipped silently when writes are frozen. Skipped silently when writes are frozen.
""" """
_check_table(table)
if _writes_frozen: if _writes_frozen:
return return
with self._lock: with self._lock:
@@ -226,6 +240,7 @@ class Database:
Skipped silently when writes are frozen. Skipped silently when writes are frozen.
""" """
_check_table(table)
if _writes_frozen: if _writes_frozen:
return return
with self._lock: with self._lock:
@@ -237,6 +252,7 @@ class Database:
def count(self, table: str) -> int: def count(self, table: str) -> int:
"""Count rows in an entity table.""" """Count rows in an entity table."""
_check_table(table)
with self._lock: with self._lock:
row = self._conn.execute( row = self._conn.execute(
f"SELECT COUNT(*) as cnt FROM [{table}]" f"SELECT COUNT(*) as cnt FROM [{table}]"
@@ -245,13 +261,15 @@ class Database:
def table_exists_with_data(self, table: str) -> bool: def table_exists_with_data(self, table: str) -> bool:
"""Check if a table exists and has at least one row.""" """Check if a table exists and has at least one row."""
_check_table(table)
with self._lock: with self._lock:
try: try:
row = self._conn.execute( row = self._conn.execute(
f"SELECT COUNT(*) as cnt FROM [{table}]" f"SELECT COUNT(*) as cnt FROM [{table}]"
).fetchone() ).fetchone()
return row["cnt"] > 0 return row["cnt"] > 0
except sqlite3.OperationalError: except sqlite3.OperationalError as e:
logger.debug("Table %s does not exist or is inaccessible: %s", table, e)
return False return False
# -- Settings (key-value) ------------------------------------------------ # -- Settings (key-value) ------------------------------------------------
@@ -266,7 +284,8 @@ class Database:
return None return None
try: try:
return json.loads(row["value"]) return json.loads(row["value"])
except json.JSONDecodeError: except json.JSONDecodeError as e:
logger.warning("Corrupt JSON in setting '%s': %s", key, e)
return None return None
def set_setting(self, key: str, value: dict) -> None: def set_setting(self, key: str, value: dict) -> None:

View File

@@ -12,8 +12,8 @@ class PictureSource:
A picture source is either: A picture source is either:
- "raw": captures from a display using a capture engine template at a target FPS - "raw": captures from a display using a capture engine template at a target FPS
- "processed": applies postprocessing to another picture source - "processed": applies postprocessing to another picture source
- "static_image": returns a static frame from a URL or local file path - "static_image": returns a static frame from an uploaded asset
- "video": decodes frames from a video file, URL, or YouTube link - "video": decodes frames from an uploaded video asset
""" """
id: str id: str
@@ -40,9 +40,9 @@ class PictureSource:
"target_fps": None, "target_fps": None,
"source_stream_id": None, "source_stream_id": None,
"postprocessing_template_id": None, "postprocessing_template_id": None,
"image_source": None, "image_asset_id": None,
# Video fields # Video fields
"url": None, "video_asset_id": None,
"loop": None, "loop": None,
"playback_speed": None, "playback_speed": None,
"start_time": None, "start_time": None,
@@ -138,13 +138,13 @@ class ProcessedPictureSource(PictureSource):
@dataclass @dataclass
class StaticImagePictureSource(PictureSource): class StaticImagePictureSource(PictureSource):
"""A static image stream from a URL or file path.""" """A static image stream from an uploaded asset."""
image_source: str = "" image_asset_id: Optional[str] = None
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["image_source"] = self.image_source d["image_asset_id"] = self.image_asset_id
return d return d
@classmethod @classmethod
@@ -153,15 +153,15 @@ class StaticImagePictureSource(PictureSource):
return cls( return cls(
**common, **common,
stream_type="static_image", stream_type="static_image",
image_source=data.get("image_source") or "", image_asset_id=data.get("image_asset_id") or data.get("image_source") or None,
) )
@dataclass @dataclass
class VideoCaptureSource(PictureSource): class VideoCaptureSource(PictureSource):
"""A video stream from a file, HTTP URL, or YouTube link.""" """A video stream from an uploaded video asset."""
url: str = "" video_asset_id: Optional[str] = None
loop: bool = True loop: bool = True
playback_speed: float = 1.0 playback_speed: float = 1.0
start_time: Optional[float] = None start_time: Optional[float] = None
@@ -172,7 +172,7 @@ class VideoCaptureSource(PictureSource):
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["url"] = self.url d["video_asset_id"] = self.video_asset_id
d["loop"] = self.loop d["loop"] = self.loop
d["playback_speed"] = self.playback_speed d["playback_speed"] = self.playback_speed
d["start_time"] = self.start_time d["start_time"] = self.start_time
@@ -188,7 +188,7 @@ class VideoCaptureSource(PictureSource):
return cls( return cls(
**common, **common,
stream_type="video", stream_type="video",
url=data.get("url") or "", video_asset_id=data.get("video_asset_id") or data.get("url") or None,
loop=data.get("loop", True), loop=data.get("loop", True),
playback_speed=data.get("playback_speed", 1.0), playback_speed=data.get("playback_speed", 1.0),
start_time=data.get("start_time"), start_time=data.get("start_time"),

View File

@@ -82,11 +82,11 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
target_fps: Optional[int] = None, target_fps: Optional[int] = None,
source_stream_id: Optional[str] = None, source_stream_id: Optional[str] = None,
postprocessing_template_id: Optional[str] = None, postprocessing_template_id: Optional[str] = None,
image_source: Optional[str] = None, image_asset_id: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
# Video fields # Video fields
url: Optional[str] = None, video_asset_id: Optional[str] = None,
loop: bool = True, loop: bool = True,
playback_speed: float = 1.0, playback_speed: float = 1.0,
start_time: Optional[float] = None, start_time: Optional[float] = None,
@@ -121,11 +121,11 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
if self._detect_cycle(source_stream_id): if self._detect_cycle(source_stream_id):
raise ValueError("Cycle detected in stream chain") raise ValueError("Cycle detected in stream chain")
elif stream_type == "static_image": elif stream_type == "static_image":
if not image_source: if not image_asset_id:
raise ValueError("Static image streams require image_source") raise ValueError("Static image streams require image_asset_id")
elif stream_type == "video": elif stream_type == "video":
if not url: if not video_asset_id:
raise ValueError("Video streams require url") raise ValueError("Video streams require video_asset_id")
# Check for duplicate name # Check for duplicate name
self._check_name_unique(name) self._check_name_unique(name)
@@ -156,7 +156,7 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
elif stream_type == "video": elif stream_type == "video":
stream = VideoCaptureSource( stream = VideoCaptureSource(
**common, **common,
url=url, # type: ignore[arg-type] video_asset_id=video_asset_id,
loop=loop, loop=loop,
playback_speed=playback_speed, playback_speed=playback_speed,
start_time=start_time, start_time=start_time,
@@ -168,7 +168,7 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
else: else:
stream = StaticImagePictureSource( stream = StaticImagePictureSource(
**common, **common,
image_source=image_source, # type: ignore[arg-type] image_asset_id=image_asset_id,
) )
self._items[stream_id] = stream self._items[stream_id] = stream
@@ -186,11 +186,11 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
target_fps: Optional[int] = None, target_fps: Optional[int] = None,
source_stream_id: Optional[str] = None, source_stream_id: Optional[str] = None,
postprocessing_template_id: Optional[str] = None, postprocessing_template_id: Optional[str] = None,
image_source: Optional[str] = None, image_asset_id: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
# Video fields # Video fields
url: Optional[str] = None, video_asset_id: Optional[str] = None,
loop: Optional[bool] = None, loop: Optional[bool] = None,
playback_speed: Optional[float] = None, playback_speed: Optional[float] = None,
start_time: Optional[float] = None, start_time: Optional[float] = None,
@@ -234,11 +234,11 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
if postprocessing_template_id is not None: if postprocessing_template_id is not None:
stream.postprocessing_template_id = resolve_ref(postprocessing_template_id, stream.postprocessing_template_id) stream.postprocessing_template_id = resolve_ref(postprocessing_template_id, stream.postprocessing_template_id)
elif isinstance(stream, StaticImagePictureSource): elif isinstance(stream, StaticImagePictureSource):
if image_source is not None: if image_asset_id is not None:
stream.image_source = image_source stream.image_asset_id = resolve_ref(image_asset_id, stream.image_asset_id)
elif isinstance(stream, VideoCaptureSource): elif isinstance(stream, VideoCaptureSource):
if url is not None: if video_asset_id is not None:
stream.url = url stream.video_asset_id = resolve_ref(video_asset_id, stream.video_asset_id)
if loop is not None: if loop is not None:
stream.loop = loop stream.loop = loop
if playback_speed is not None: if playback_speed is not None:

View File

@@ -212,6 +212,8 @@
{% include 'modals/test-value-source.html' %} {% include 'modals/test-value-source.html' %}
{% include 'modals/sync-clock-editor.html' %} {% include 'modals/sync-clock-editor.html' %}
{% include 'modals/weather-source-editor.html' %} {% include 'modals/weather-source-editor.html' %}
{% include 'modals/asset-upload.html' %}
{% include 'modals/asset-editor.html' %}
{% include 'modals/settings.html' %} {% include 'modals/settings.html' %}
{% include 'partials/tutorial-overlay.html' %} {% include 'partials/tutorial-overlay.html' %}

View File

@@ -0,0 +1,35 @@
<div id="asset-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="asset-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="asset-editor-title" data-i18n="asset.edit">Edit Asset</h2>
<button class="modal-close-btn" onclick="closeAssetEditorModal()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="asset-editor-id">
<div id="asset-editor-error" class="modal-error" style="display:none"></div>
<div class="form-group">
<div class="label-row">
<label for="asset-editor-name" data-i18n="asset.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Display name for this asset.</small>
<input type="text" id="asset-editor-name" required maxlength="100">
<div id="asset-editor-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="asset-editor-description" data-i18n="asset.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small>
<input type="text" id="asset-editor-description" maxlength="500">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeAssetEditorModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveAssetMetadata()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,62 @@
<div id="asset-upload-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="asset-upload-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="asset-upload-title" data-i18n="asset.upload">Upload Asset</h2>
<button class="modal-close-btn" onclick="closeAssetUploadModal()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="modal-body">
<div id="asset-upload-error" class="modal-error" style="display:none"></div>
<div class="form-group">
<div class="label-row">
<label for="asset-upload-name" data-i18n="asset.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Optional display name. If blank, derived from filename.</small>
<input type="text" id="asset-upload-name" maxlength="100" placeholder="">
<div id="asset-upload-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="asset-upload-description" data-i18n="asset.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small>
<input type="text" id="asset-upload-description" maxlength="500">
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="asset.file">File:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.file.hint">Select a file to upload (sound, image, video, or other).</small>
<input type="file" id="asset-upload-file" required hidden>
<div id="asset-upload-dropzone" class="file-dropzone" tabindex="0" role="button"
aria-label="Choose file or drag and drop">
<div class="file-dropzone-icon">
<svg class="icon" viewBox="0 0 24 24" style="width:32px;height:32px">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
<path d="M14 2v4a2 2 0 0 0 2 2h4"/>
<path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>
</svg>
</div>
<div class="file-dropzone-text">
<span class="file-dropzone-label" data-i18n="asset.drop_or_browse">Drop file here or click to browse</span>
</div>
<div id="asset-upload-file-info" class="file-dropzone-info" style="display:none">
<span id="asset-upload-file-name" class="file-dropzone-filename"></span>
<span id="asset-upload-file-size" class="file-dropzone-filesize"></span>
<button type="button" class="file-dropzone-remove" id="asset-upload-file-remove"
title="Remove" data-i18n-title="common.remove">&times;</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeAssetUploadModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="uploadAsset()" title="Upload" data-i18n-title="asset.upload" data-i18n-aria-label="asset.upload">&#x2713;</button>
</div>
</div>
</div>

View File

@@ -458,23 +458,52 @@
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.filter_list.hint">One app name per line. Use Browse to pick from running processes.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.notification.filter_list.hint">One app name per line. Use Browse to pick from running processes.</small>
<div class="condition-field" id="css-editor-notification-filter-picker-container"> <div class="condition-field" id="css-editor-notification-filter-picker-container">
<div class="condition-apps-header"> <div class="condition-apps-header">
<button type="button" class="btn-browse-apps" data-i18n="automations.condition.application.browse">Browse</button> <button type="button" class="btn btn-icon btn-secondary btn-browse-apps" data-i18n-title="automations.condition.application.browse" title="Browse"><svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg></button>
</div> </div>
<textarea id="css-editor-notification-filter-list" class="condition-apps" rows="3" data-i18n-placeholder="color_strip.notification.filter_list.placeholder" placeholder="Discord&#10;Slack&#10;Telegram"></textarea> <textarea id="css-editor-notification-filter-list" class="condition-apps" rows="3" data-i18n-placeholder="color_strip.notification.filter_list.placeholder" placeholder="Discord&#10;Slack&#10;Telegram"></textarea>
</div> </div>
</div> </div>
<details class="form-collapse"> <details class="form-collapse">
<summary data-i18n="color_strip.notification.app_colors">App Colors</summary> <summary data-i18n="color_strip.notification.sound">Sound</summary>
<div class="form-collapse-body"> <div class="form-collapse-body">
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label data-i18n="color_strip.notification.app_colors.label">Color Mappings:</label> <label for="css-editor-notification-sound" data-i18n="color_strip.notification.sound.asset">Sound Asset:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.app_colors.hint">Per-app color overrides. Each row maps an app name to a specific color.</small> <small class="input-hint" style="display:none" data-i18n="color_strip.notification.sound.asset.hint">Pick a sound asset to play when a notification fires. Leave empty for silent.</small>
<div id="notification-app-colors-list"></div> <select id="css-editor-notification-sound">
<button type="button" class="btn btn-secondary" onclick="notificationAddAppColor()" data-i18n="color_strip.notification.app_colors.add">+ Add Mapping</button> <option value="" data-i18n="color_strip.notification.sound.none">None (silent)</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-notification-volume">
<span data-i18n="color_strip.notification.sound.volume">Volume:</span>
<span id="css-editor-notification-volume-val">100%</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.sound.volume.hint">Global volume for notification sounds (0100%).</small>
<input type="range" id="css-editor-notification-volume" min="0" max="100" step="5" value="100"
oninput="document.getElementById('css-editor-notification-volume-val').textContent = this.value + '%'">
</div>
</div>
</details>
<details class="form-collapse">
<summary data-i18n="color_strip.notification.app_overrides">Per-App Overrides</summary>
<div class="form-collapse-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.notification.app_overrides.label">App Overrides:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.app_overrides.hint">Per-app overrides for color and sound. Each row can set a custom color, sound asset, and volume for a specific app.</small>
<div id="notification-app-overrides-list"></div>
<button type="button" class="btn btn-secondary" onclick="notificationAddAppOverride()" data-i18n="color_strip.notification.app_overrides.add">+ Add Override</button>
</div> </div>
</div> </div>
</details> </details>

View File

@@ -6,7 +6,7 @@
<button class="modal-close-btn" onclick="closeNotificationHistory()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button> <button class="modal-close-btn" onclick="closeNotificationHistory()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="form-hint" data-i18n="color_strip.notification.history.hint">Recent OS notifications captured by the listener (newest first). Up to 50 entries.</p> <p class="form-hint" style="margin-bottom:0.75rem" data-i18n="color_strip.notification.history.hint">Recent OS notifications captured by the listener (newest first). Up to 50 entries.</p>
<div id="notification-history-status" style="display:none;color:var(--text-muted);font-size:0.85rem;margin-bottom:0.5rem"></div> <div id="notification-history-status" style="display:none;color:var(--text-muted);font-size:0.85rem;margin-bottom:0.5rem"></div>
<div id="notification-history-list" style="max-height:340px;overflow-y:auto;border:1px solid var(--border-color);border-radius:4px;padding:0.25rem 0"></div> <div id="notification-history-list" style="max-height:340px;overflow-y:auto;border:1px solid var(--border-color);border-radius:4px;padding:0.25rem 0"></div>
</div> </div>

View File

@@ -78,28 +78,15 @@
<!-- Static image fields --> <!-- Static image fields -->
<div id="stream-static-image-fields" style="display: none;"> <div id="stream-static-image-fields" style="display: none;">
<div class="form-group"> <div class="form-group">
<div class="label-row"> <label for="stream-image-asset" data-i18n="streams.image_asset">Image Asset:</label>
<label for="stream-image-source" data-i18n="streams.image_source">Image Source:</label> <select id="stream-image-asset"></select>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.image_source.hint">Enter a URL (http/https) or local file path to an image</small>
<input type="text" id="stream-image-source" data-i18n-placeholder="streams.image_source.placeholder" placeholder="https://example.com/image.jpg or C:\path\to\image.png">
</div> </div>
<div id="stream-image-preview-container" class="image-preview-container" style="display: none;">
<img id="stream-image-preview" class="stream-image-preview" src="" alt="Preview">
<div id="stream-image-info" class="stream-image-info"></div>
</div>
<div id="stream-image-validation-status" class="validation-status" style="display: none;"></div>
</div> </div>
<div id="stream-video-fields" style="display: none;"> <div id="stream-video-fields" style="display: none;">
<div class="form-group"> <div class="form-group">
<div class="label-row"> <label for="stream-video-asset" data-i18n="streams.video_asset">Video Asset:</label>
<label for="stream-video-url" data-i18n="picture_source.video.url">Video URL:</label> <select id="stream-video-asset"></select>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="picture_source.video.url.hint">Local file path, HTTP URL, or YouTube URL</small>
<input type="text" id="stream-video-url" data-i18n-placeholder="picture_source.video.url.placeholder" placeholder="https://example.com/video.mp4">
</div> </div>
<div class="form-group settings-toggle-group"> <div class="form-group settings-toggle-group">
<label data-i18n="picture_source.video.loop">Loop:</label> <label data-i18n="picture_source.video.loop">Loop:</label>

View File

@@ -1,10 +1,13 @@
"""Atomic file write utilities.""" """Atomic file write utilities."""
import json import json
import logging
import os import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__)
def atomic_write_json(file_path: Path, data: dict, indent: int = 2) -> None: def atomic_write_json(file_path: Path, data: dict, indent: int = 2) -> None:
"""Write JSON data to file atomically via temp file + rename. """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 # Clean up temp file on any error
try: try:
os.unlink(tmp_path) os.unlink(tmp_path)
except OSError: except OSError as e:
logger.debug("Failed to clean up temp file %s: %s", tmp_path, e)
pass pass
raise raise

View File

@@ -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

View File

@@ -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

View File

@@ -1,18 +1,47 @@
"""Shared fixtures for end-to-end API tests. """Shared fixtures for end-to-end API tests.
Uses the real FastAPI app with a module-scoped TestClient to avoid Uses the real FastAPI app with a session-scoped TestClient.
repeated lifespan startup/shutdown issues. Each test function gets All e2e tests run against an ISOLATED temporary database and assets
fresh, empty stores via the _clear_stores helper. directory — never the production data.
""" """
import shutil
import tempfile
from pathlib import Path
import pytest import pytest
from wled_controller.config import get_config # ---------------------------------------------------------------------------
# Isolate e2e tests from production data.
#
# We must set the config singleton BEFORE wled_controller.main is imported,
# because main.py reads get_config() at module level to create the DB and
# all stores. By forcing the singleton here we guarantee the app opens a
# throwaway SQLite file in a temp directory.
# ---------------------------------------------------------------------------
_e2e_tmp = Path(tempfile.mkdtemp(prefix="wled_e2e_"))
_test_db_path = str(_e2e_tmp / "test_ledgrab.db")
_test_assets_dir = str(_e2e_tmp / "test_assets")
# Resolve the API key from the real config (same key used in production tests) import wled_controller.config as _config_mod # noqa: E402
_config = get_config()
API_KEY = next(iter(_config.auth.api_keys.values()), "") # Build a Config that mirrors production settings but with isolated paths.
_original_config = _config_mod.Config.load()
_test_config = _original_config.model_copy(
update={
"storage": _config_mod.StorageConfig(database_file=_test_db_path),
"assets": _config_mod.AssetsConfig(
assets_dir=_test_assets_dir,
max_file_size_mb=_original_config.assets.max_file_size_mb,
),
},
)
# Install as the global singleton so all subsequent get_config() calls
# (including main.py module-level code) use isolated paths.
_config_mod.config = _test_config
API_KEY = next(iter(_test_config.auth.api_keys.values()), "")
AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"} AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"}
@@ -22,7 +51,7 @@ def _test_client():
The app's lifespan (MQTT, automation engine, health monitoring, etc.) The app's lifespan (MQTT, automation engine, health monitoring, etc.)
starts once for the entire e2e test session and shuts down after all starts once for the entire e2e test session and shuts down after all
tests complete. tests complete. The app uses the isolated test database set above.
""" """
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from wled_controller.main import app from wled_controller.main import app
@@ -30,6 +59,9 @@ def _test_client():
with TestClient(app, raise_server_exceptions=False) as c: with TestClient(app, raise_server_exceptions=False) as c:
yield c yield c
# Clean up temp directory after all e2e tests finish
shutil.rmtree(_e2e_tmp, ignore_errors=True)
@pytest.fixture @pytest.fixture
def client(_test_client): def client(_test_client):
@@ -63,6 +95,7 @@ def _clear_stores():
(deps.get_sync_clock_store, "get_all", "delete"), (deps.get_sync_clock_store, "get_all", "delete"),
(deps.get_automation_store, "get_all", "delete"), (deps.get_automation_store, "get_all", "delete"),
(deps.get_scene_preset_store, "get_all", "delete"), (deps.get_scene_preset_store, "get_all", "delete"),
(deps.get_asset_store, "get_all_assets", "delete_asset"),
] ]
for getter, list_method, delete_method in store_clearers: for getter, list_method, delete_method in store_clearers:
try: try:

View File

@@ -1,9 +1,11 @@
"""E2E: Backup and restore flow. """E2E: Backup and restore flow.
Tests creating entities, backing up (SQLite .db file), deleting, then restoring. Tests creating entities, backing up (ZIP containing SQLite .db + asset files),
deleting, then restoring.
""" """
import io import io
import zipfile
class TestBackupRestoreFlow: class TestBackupRestoreFlow:
@@ -40,12 +42,17 @@ class TestBackupRestoreFlow:
resp = client.get("/api/v1/color-strip-sources") resp = client.get("/api/v1/color-strip-sources")
assert resp.json()["count"] == 1 assert resp.json()["count"] == 1
# 2. Create a backup (GET returns a SQLite .db file) # 2. Create a backup (GET returns a ZIP containing ledgrab.db + assets)
resp = client.get("/api/v1/system/backup") resp = client.get("/api/v1/system/backup")
assert resp.status_code == 200 assert resp.status_code == 200
backup_bytes = resp.content backup_bytes = resp.content
# SQLite files start with this magic header # Backup is a ZIP file (PK magic bytes)
assert backup_bytes[:16].startswith(b"SQLite format 3") assert backup_bytes[:4] == b"PK\x03\x04"
# ZIP should contain ledgrab.db
with zipfile.ZipFile(io.BytesIO(backup_bytes)) as zf:
assert "ledgrab.db" in zf.namelist()
db_data = zf.read("ledgrab.db")
assert db_data[:16].startswith(b"SQLite format 3")
# 3. Delete all created entities # 3. Delete all created entities
resp = client.delete(f"/api/v1/color-strip-sources/{css_id}") resp = client.delete(f"/api/v1/color-strip-sources/{css_id}")
@@ -59,23 +66,27 @@ class TestBackupRestoreFlow:
resp = client.get("/api/v1/color-strip-sources") resp = client.get("/api/v1/color-strip-sources")
assert resp.json()["count"] == 0 assert resp.json()["count"] == 0
# 4. Restore from backup (POST with the .db file upload) # 4. Restore from backup (POST with the .zip file upload)
resp = client.post( resp = client.post(
"/api/v1/system/restore", "/api/v1/system/restore",
files={"file": ("backup.db", io.BytesIO(backup_bytes), "application/octet-stream")}, files={"file": ("backup.zip", io.BytesIO(backup_bytes), "application/zip")},
) )
assert resp.status_code == 200, f"Restore failed: {resp.text}" assert resp.status_code == 200, f"Restore failed: {resp.text}"
restore_result = resp.json() restore_result = resp.json()
assert restore_result["status"] == "restored" assert restore_result["status"] == "restored"
assert restore_result["restart_scheduled"] is True assert restore_result["restart_scheduled"] is True
def test_backup_is_valid_sqlite(self, client): def test_backup_is_valid_zip(self, client):
"""Backup response is a valid SQLite database file.""" """Backup response is a valid ZIP containing a SQLite database."""
resp = client.get("/api/v1/system/backup") resp = client.get("/api/v1/system/backup")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.content[:16].startswith(b"SQLite format 3") assert resp.content[:4] == b"PK\x03\x04"
# Should have Content-Disposition header for download # Should have Content-Disposition header for download
assert "attachment" in resp.headers.get("content-disposition", "") assert "attachment" in resp.headers.get("content-disposition", "")
# ZIP should contain ledgrab.db with valid SQLite header
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
assert "ledgrab.db" in zf.namelist()
assert zf.read("ledgrab.db")[:16].startswith(b"SQLite format 3")
def test_restore_rejects_invalid_format(self, client): def test_restore_rejects_invalid_format(self, client):
"""Uploading a non-SQLite file should fail validation.""" """Uploading a non-SQLite file should fail validation."""