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,9 +1,11 @@
"""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 zipfile
class TestBackupRestoreFlow:
@@ -40,12 +42,17 @@ class TestBackupRestoreFlow:
resp = client.get("/api/v1/color-strip-sources")
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")
assert resp.status_code == 200
backup_bytes = resp.content
# SQLite files start with this magic header
assert backup_bytes[:16].startswith(b"SQLite format 3")
# Backup is a ZIP file (PK magic bytes)
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
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")
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(
"/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}"
restore_result = resp.json()
assert restore_result["status"] == "restored"
assert restore_result["restart_scheduled"] is True
def test_backup_is_valid_sqlite(self, client):
"""Backup response is a valid SQLite database file."""
def test_backup_is_valid_zip(self, client):
"""Backup response is a valid ZIP containing a SQLite database."""
resp = client.get("/api/v1/system/backup")
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
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):
"""Uploading a non-SQLite file should fail validation."""