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,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:
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)`
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`
- 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
@@ -266,11 +278,11 @@ See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, bro
### 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
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

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/` |
| **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