feat: asset-based image/video sources, notification sounds, UI improvements
Lint & Test / test (push) Has been cancelled
Lint & Test / test (push) Has been cancelled
- Replace URL-based image_source/url fields with image_asset_id/video_asset_id on StaticImagePictureSource and VideoCaptureSource (clean break, no migration) - Resolve asset IDs to file paths at runtime via AssetStore.get_file_path() - Add EntitySelect asset pickers for image/video in stream editor modal - Add notification sound configuration (global sound + per-app overrides) - Unify per-app color and sound overrides into single "Per-App Overrides" section - Persist notification history between server restarts - Add asset management system (upload, edit, delete, soft-delete) - Replace emoji buttons with SVG icons throughout UI - Various backend improvements: SQLite stores, auth, backup, MQTT, webhooks
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user