Files
ledgrab/server/src/wled_controller/storage/asset_store.py
T
alexei.dolgolyov e2e1107df7
Lint & Test / test (push) Has been cancelled
feat: asset-based image/video sources, notification sounds, UI improvements
- 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
2026-03-26 20:40:25 +03:00

220 lines
7.6 KiB
Python

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