diff --git a/docs/API.md b/docs/API.md index ebf69ef..d89c378 100644 --- a/docs/API.md +++ b/docs/API.md @@ -42,6 +42,7 @@ Complete REST + WebSocket API reference for the LedGrab server. - [Weather sources](#weather-sources) - [Automations](#automations) - [Scene presets](#scene-presets) + - [Scene playlists](#scene-playlists) - [Sync clocks](#sync-clocks) - [Webhooks](#webhooks) - [HTTP endpoints](#http-endpoints) @@ -518,6 +519,25 @@ Captured snapshots of target state that can be restored. | POST | `/api/v1/scene-presets/{preset_id}/recapture` | Re-capture current state into the preset. | | POST | `/api/v1/scene-presets/{preset_id}/activate` | Activate the preset (restore captured state). | +## Scene playlists + +Ordered, timed sequences of scene presets that auto-cycle. The engine drives +**one** playlist at a time — starting a playlist stops any other. Each item +references a scene preset and holds it for its `duration_seconds` (min 1s) +before advancing; `loop` repeats from the start and `shuffle` randomises the +order each cycle. + +| Method | Path | Description | +| ------ | ---- | ----------- | +| POST | `/api/v1/scene-playlists` | Create a playlist (items reference scene presets). | +| GET | `/api/v1/scene-playlists` | List all playlists plus the current cycling `state`. | +| GET | `/api/v1/scene-playlists/state` | Get the current cycling state (idle if nothing runs). | +| GET | `/api/v1/scene-playlists/{playlist_id}` | Get a playlist by ID. | +| PUT | `/api/v1/scene-playlists/{playlist_id}` | Update metadata, items, and `loop`/`shuffle`. | +| DELETE | `/api/v1/scene-playlists/{playlist_id}` | Delete a playlist (stops it first if running). | +| POST | `/api/v1/scene-playlists/{playlist_id}/start` | Start cycling (stops any other playlist first). | +| POST | `/api/v1/scene-playlists/stop` | Stop the active playlist (leaves the last scene applied). | + ## Sync clocks Shared clocks that drive linked animations with configurable speed. diff --git a/server/src/ledgrab/api/__init__.py b/server/src/ledgrab/api/__init__.py index d7c4ed8..a1e710f 100644 --- a/server/src/ledgrab/api/__init__.py +++ b/server/src/ledgrab/api/__init__.py @@ -18,6 +18,7 @@ from .routes.audio_templates import router as audio_templates_router from .routes.value_sources import router as value_sources_router from .routes.automations import router as automations_router from .routes.scene_presets import router as scene_presets_router +from .routes.scene_playlists import router as scene_playlists_router from .routes.webhooks import router as webhooks_router from .routes.sync_clocks import router as sync_clocks_router from .routes.color_strip_processing import router as cspt_router @@ -53,6 +54,7 @@ router.include_router(output_targets_router) router.include_router(output_targets_control_router) router.include_router(automations_router) router.include_router(scene_presets_router) +router.include_router(scene_playlists_router) router.include_router(webhooks_router) router.include_router(sync_clocks_router) router.include_router(cspt_router) diff --git a/server/src/ledgrab/api/dependencies.py b/server/src/ledgrab/api/dependencies.py index 1718632..0a1f15d 100644 --- a/server/src/ledgrab/api/dependencies.py +++ b/server/src/ledgrab/api/dependencies.py @@ -19,6 +19,7 @@ from ledgrab.storage.audio_template_store import AudioTemplateStore from ledgrab.storage.value_source_store import ValueSourceStore from ledgrab.storage.automation_store import AutomationStore from ledgrab.storage.scene_preset_store import ScenePresetStore +from ledgrab.storage.scene_playlist_store import ScenePlaylistStore from ledgrab.storage.sync_clock_store import SyncClockStore from ledgrab.storage.color_strip_processing_template_store import ( ColorStripProcessingTemplateStore, @@ -27,6 +28,7 @@ from ledgrab.storage.gradient_store import GradientStore from ledgrab.storage.weather_source_store import WeatherSourceStore from ledgrab.storage.asset_store import AssetStore from ledgrab.core.automations.automation_engine import AutomationEngine +from ledgrab.core.scenes.playlist_engine import PlaylistEngine from ledgrab.core.weather.weather_manager import WeatherManager from ledgrab.core.backup.auto_backup import AutoBackupEngine from ledgrab.core.processing.sync_clock_manager import SyncClockManager @@ -110,6 +112,14 @@ def get_automation_engine() -> AutomationEngine: return _get("automation_engine", "Automation engine") +def get_scene_playlist_store() -> ScenePlaylistStore: + return _get("scene_playlist_store", "Scene playlist store") + + +def get_playlist_engine() -> PlaylistEngine: + return _get("playlist_engine", "Playlist engine") + + def get_auto_backup_engine() -> AutoBackupEngine: return _get("auto_backup_engine", "Auto-backup engine") @@ -226,7 +236,9 @@ def init_dependencies( value_source_store: ValueSourceStore | None = None, automation_store: AutomationStore | None = None, scene_preset_store: ScenePresetStore | None = None, + scene_playlist_store: ScenePlaylistStore | None = None, automation_engine: AutomationEngine | None = None, + playlist_engine: PlaylistEngine | None = None, auto_backup_engine: AutoBackupEngine | None = None, sync_clock_store: SyncClockStore | None = None, sync_clock_manager: SyncClockManager | None = None, @@ -262,7 +274,9 @@ def init_dependencies( "value_source_store": value_source_store, "automation_store": automation_store, "scene_preset_store": scene_preset_store, + "scene_playlist_store": scene_playlist_store, "automation_engine": automation_engine, + "playlist_engine": playlist_engine, "auto_backup_engine": auto_backup_engine, "sync_clock_store": sync_clock_store, "sync_clock_manager": sync_clock_manager, diff --git a/server/src/ledgrab/api/routes/scene_playlists.py b/server/src/ledgrab/api/routes/scene_playlists.py new file mode 100644 index 0000000..651bb55 --- /dev/null +++ b/server/src/ledgrab/api/routes/scene_playlists.py @@ -0,0 +1,275 @@ +"""Scene playlist API routes — CRUD plus start/stop/state cycling control.""" + +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException + +from ledgrab.api.auth import AuthRequired +from ledgrab.api.dependencies import ( + fire_entity_event, + get_playlist_engine, + get_scene_playlist_store, + get_scene_preset_store, +) +from ledgrab.api.schemas.scene_playlists import ( + PlaylistRuntimeStateSchema, + ScenePlaylistCreate, + ScenePlaylistListResponse, + ScenePlaylistResponse, + ScenePlaylistUpdate, +) +from ledgrab.core.scenes.playlist_engine import PlaylistEngine, PlaylistError +from ledgrab.storage.base_store import EntityNotFoundError +from ledgrab.storage.scene_playlist import PlaylistItem, ScenePlaylist +from ledgrab.storage.scene_playlist_store import ScenePlaylistStore +from ledgrab.storage.scene_preset_store import ScenePresetStore +from ledgrab.utils import get_logger + +logger = get_logger(__name__) +router = APIRouter() + + +def _playlist_to_response(playlist: ScenePlaylist, engine: PlaylistEngine) -> ScenePlaylistResponse: + return ScenePlaylistResponse( + id=playlist.id, + name=playlist.name, + description=playlist.description, + items=[ + {"scene_preset_id": i.scene_preset_id, "duration_seconds": i.duration_seconds} + for i in playlist.items + ], + loop=playlist.loop, + shuffle=playlist.shuffle, + order=playlist.order, + tags=playlist.tags, + icon=getattr(playlist, "icon", "") or "", + icon_color=getattr(playlist, "icon_color", "") or "", + is_running=engine.get_running_playlist_id() == playlist.id, + created_at=playlist.created_at, + updated_at=playlist.updated_at, + ) + + +def _items_from_schema(items) -> list[PlaylistItem]: + return [ + PlaylistItem(scene_preset_id=i.scene_preset_id, duration_seconds=i.duration_seconds) + for i in items + ] + + +def _validate_preset_refs(items, preset_store: ScenePresetStore) -> None: + """Reject playlist items that reference a non-existent scene preset.""" + for item in items: + try: + preset_store.get_preset(item.scene_preset_id) + except (ValueError, EntityNotFoundError): + raise HTTPException( + status_code=400, + detail=f"Scene preset not found: {item.scene_preset_id}", + ) + + +# ===== CRUD ===== + + +@router.post( + "/api/v1/scene-playlists", + response_model=ScenePlaylistResponse, + tags=["Scene Playlists"], + status_code=201, +) +async def create_scene_playlist( + data: ScenePlaylistCreate, + _auth: AuthRequired, + store: ScenePlaylistStore = Depends(get_scene_playlist_store), + preset_store: ScenePresetStore = Depends(get_scene_preset_store), + engine: PlaylistEngine = Depends(get_playlist_engine), +): + """Create a new scene playlist.""" + _validate_preset_refs(data.items, preset_store) + + now = datetime.now(timezone.utc) + playlist = ScenePlaylist( + id=f"playlist_{uuid.uuid4().hex[:8]}", + name=data.name, + description=data.description, + items=_items_from_schema(data.items), + loop=data.loop, + shuffle=data.shuffle, + order=store.count(), + tags=data.tags if data.tags is not None else [], + icon=data.icon or "", + icon_color=data.icon_color or "", + created_at=now, + updated_at=now, + ) + + try: + playlist = store.create_playlist(playlist) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + fire_entity_event("scene_playlist", "created", playlist.id) + return _playlist_to_response(playlist, engine) + + +@router.get( + "/api/v1/scene-playlists", + response_model=ScenePlaylistListResponse, + tags=["Scene Playlists"], +) +async def list_scene_playlists( + _auth: AuthRequired, + store: ScenePlaylistStore = Depends(get_scene_playlist_store), + engine: PlaylistEngine = Depends(get_playlist_engine), +): + """List all scene playlists plus the current cycling state.""" + playlists = store.get_all_playlists() + return ScenePlaylistListResponse( + playlists=[_playlist_to_response(p, engine) for p in playlists], + count=len(playlists), + state=PlaylistRuntimeStateSchema(**engine.get_state()), + ) + + +# NOTE: the static ``/state`` path is declared before ``/{playlist_id}`` so it +# is matched first and not swallowed by the path parameter. +@router.get( + "/api/v1/scene-playlists/state", + response_model=PlaylistRuntimeStateSchema, + tags=["Scene Playlists"], +) +async def get_playlist_state( + _auth: AuthRequired, + engine: PlaylistEngine = Depends(get_playlist_engine), +): + """Get the current playlist cycling state (idle if nothing is running).""" + return PlaylistRuntimeStateSchema(**engine.get_state()) + + +@router.get( + "/api/v1/scene-playlists/{playlist_id}", + response_model=ScenePlaylistResponse, + tags=["Scene Playlists"], +) +async def get_scene_playlist( + playlist_id: str, + _auth: AuthRequired, + store: ScenePlaylistStore = Depends(get_scene_playlist_store), + engine: PlaylistEngine = Depends(get_playlist_engine), +): + """Get a single scene playlist.""" + try: + playlist = store.get_playlist(playlist_id) + except (ValueError, EntityNotFoundError) as e: + raise HTTPException(status_code=404, detail=str(e)) + return _playlist_to_response(playlist, engine) + + +@router.put( + "/api/v1/scene-playlists/{playlist_id}", + response_model=ScenePlaylistResponse, + tags=["Scene Playlists"], +) +async def update_scene_playlist( + playlist_id: str, + data: ScenePlaylistUpdate, + _auth: AuthRequired, + store: ScenePlaylistStore = Depends(get_scene_playlist_store), + preset_store: ScenePresetStore = Depends(get_scene_preset_store), + engine: PlaylistEngine = Depends(get_playlist_engine), +): + """Update a scene playlist's metadata, items, and playback flags.""" + new_items = None + if data.items is not None: + _validate_preset_refs(data.items, preset_store) + new_items = _items_from_schema(data.items) + + try: + playlist = store.update_playlist( + playlist_id, + name=data.name, + description=data.description, + items=new_items, + loop=data.loop, + shuffle=data.shuffle, + order=data.order, + tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, + ) + except (ValueError, EntityNotFoundError) as e: + raise HTTPException( + status_code=404 if "not found" in str(e).lower() else 400, detail=str(e) + ) + + fire_entity_event("scene_playlist", "updated", playlist_id) + return _playlist_to_response(playlist, engine) + + +@router.delete( + "/api/v1/scene-playlists/{playlist_id}", + status_code=204, + tags=["Scene Playlists"], +) +async def delete_scene_playlist( + playlist_id: str, + _auth: AuthRequired, + store: ScenePlaylistStore = Depends(get_scene_playlist_store), + engine: PlaylistEngine = Depends(get_playlist_engine), +): + """Delete a scene playlist (stops it first if it is currently cycling).""" + try: + store.delete_playlist(playlist_id) + except (ValueError, EntityNotFoundError) as e: + raise HTTPException(status_code=404, detail=str(e)) + + await engine.stop_if_running(playlist_id) + fire_entity_event("scene_playlist", "deleted", playlist_id) + + +# ===== Cycling control ===== + + +@router.post( + "/api/v1/scene-playlists/{playlist_id}/start", + response_model=PlaylistRuntimeStateSchema, + tags=["Scene Playlists"], +) +async def start_scene_playlist( + playlist_id: str, + _auth: AuthRequired, + store: ScenePlaylistStore = Depends(get_scene_playlist_store), + engine: PlaylistEngine = Depends(get_playlist_engine), +): + """Start cycling a playlist (stops any currently-running playlist first).""" + try: + store.get_playlist(playlist_id) + except (ValueError, EntityNotFoundError) as e: + raise HTTPException(status_code=404, detail=str(e)) + + try: + await engine.start_playlist(playlist_id) + except PlaylistError as e: + raise HTTPException(status_code=400, detail=str(e)) + + fire_entity_event("scene_playlist", "updated", playlist_id) + return PlaylistRuntimeStateSchema(**engine.get_state()) + + +@router.post( + "/api/v1/scene-playlists/stop", + response_model=PlaylistRuntimeStateSchema, + tags=["Scene Playlists"], +) +async def stop_scene_playlist( + _auth: AuthRequired, + engine: PlaylistEngine = Depends(get_playlist_engine), +): + """Stop the active playlist (leaves the last applied scene in place).""" + stopped_id = engine.get_running_playlist_id() + await engine.stop() + if stopped_id: + fire_entity_event("scene_playlist", "updated", stopped_id) + return PlaylistRuntimeStateSchema(**engine.get_state()) diff --git a/server/src/ledgrab/api/schemas/scene_playlists.py b/server/src/ledgrab/api/schemas/scene_playlists.py new file mode 100644 index 0000000..6668a72 --- /dev/null +++ b/server/src/ledgrab/api/schemas/scene_playlists.py @@ -0,0 +1,94 @@ +"""Scene playlist API schemas.""" + +from datetime import datetime +from typing import List + +from pydantic import BaseModel, Field + +from ledgrab.storage.scene_playlist import ( + MAX_DURATION_SECONDS, + MIN_DURATION_SECONDS, +) + + +class PlaylistItemSchema(BaseModel): + scene_preset_id: str = Field(min_length=1, description="Referenced scene preset id") + duration_seconds: float = Field( + default=30.0, + ge=MIN_DURATION_SECONDS, + le=MAX_DURATION_SECONDS, + description="How long to hold this scene before advancing", + ) + + +class ScenePlaylistCreate(BaseModel): + """Create a scene playlist.""" + + name: str = Field(description="Playlist name", min_length=1, max_length=100) + description: str = Field(default="", max_length=500) + items: List[PlaylistItemSchema] = Field( + default_factory=list, description="Ordered playlist items" + ) + loop: bool = Field(default=True, description="Restart from the first item after the last") + shuffle: bool = Field(default=False, description="Randomise item order each cycle") + tags: List[str] = Field(default_factory=list, description="User-defined tags") + icon: str | None = Field( + None, + max_length=64, + description="Icon id from the curated icon library. Pass empty string to clear.", + ) + icon_color: str | None = Field( + None, + max_length=32, + description="Optional CSS color override for the icon.", + ) + + +class ScenePlaylistUpdate(BaseModel): + """Update scene playlist metadata, items, and playback flags.""" + + name: str | None = Field(None, min_length=1, max_length=100) + description: str | None = Field(None, max_length=500) + items: List[PlaylistItemSchema] | None = Field(None, description="Replace the item list") + loop: bool | None = None + shuffle: bool | None = None + order: int | None = None + tags: List[str] | None = None + icon: str | None = Field(None, max_length=64) + icon_color: str | None = Field(None, max_length=32) + + +class PlaylistRuntimeStateSchema(BaseModel): + is_running: bool = False + playlist_id: str | None = None + playlist_name: str | None = None + current_index: int = 0 + item_count: int = 0 + current_preset_id: str | None = None + started_at: datetime | None = None + step_started_at: datetime | None = None + step_duration: float = 0.0 + + +class ScenePlaylistResponse(BaseModel): + """Scene playlist with items and runtime running flag.""" + + id: str + name: str + description: str + items: List[PlaylistItemSchema] + loop: bool + shuffle: bool + order: int + tags: List[str] = Field(default_factory=list) + icon: str | None = Field(None, max_length=64) + icon_color: str | None = Field(None, max_length=32) + is_running: bool = Field(default=False, description="True if this playlist is cycling now") + created_at: datetime + updated_at: datetime + + +class ScenePlaylistListResponse(BaseModel): + playlists: List[ScenePlaylistResponse] + count: int + state: PlaylistRuntimeStateSchema diff --git a/server/src/ledgrab/core/scenes/playlist_engine.py b/server/src/ledgrab/core/scenes/playlist_engine.py new file mode 100644 index 0000000..71a0763 --- /dev/null +++ b/server/src/ledgrab/core/scenes/playlist_engine.py @@ -0,0 +1,280 @@ +"""Playlist engine — background loop that auto-cycles a scene playlist. + +A playlist is an ordered, timed sequence of scene presets. The engine drives +**at most one** playlist at a time: starting a new playlist transparently stops +any currently-running one. Each cycle re-reads the playlist from the store, so +edits (and deletion) take effect at the next cycle boundary without a restart. + +The actual state application reuses ``scene_activator.apply_scene_state`` — the +same code path the scene-presets API and the automation engine use — so a +playlist step behaves exactly like manually activating that preset. +""" + +import asyncio +import random +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import List + +from ledgrab.storage.scene_playlist import ScenePlaylist, clamp_duration +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + + +@dataclass +class PlaylistRuntimeState: + """Volatile runtime state of the (single) active playlist. Not persisted.""" + + playlist_id: str + playlist_name: str + current_index: int + item_count: int + current_preset_id: str | None + started_at: datetime + step_started_at: datetime + step_duration: float + + def to_dict(self) -> dict: + return { + "is_running": True, + "playlist_id": self.playlist_id, + "playlist_name": self.playlist_name, + "current_index": self.current_index, + "item_count": self.item_count, + "current_preset_id": self.current_preset_id, + "started_at": self.started_at.isoformat(), + "step_started_at": self.step_started_at.isoformat(), + "step_duration": self.step_duration, + } + + +_IDLE_STATE = { + "is_running": False, + "playlist_id": None, + "playlist_name": None, + "current_index": 0, + "item_count": 0, + "current_preset_id": None, + "started_at": None, + "step_started_at": None, + "step_duration": 0.0, +} + + +class PlaylistError(Exception): + """Raised when a playlist cannot be started (empty / not found).""" + + +class PlaylistEngine: + """Cycles a scene playlist's presets on a timer, one playlist at a time.""" + + def __init__( + self, + playlist_store, + scene_preset_store, + target_store, + processor_manager, + ): + self._playlist_store = playlist_store + self._scene_preset_store = scene_preset_store + self._target_store = target_store + self._manager = processor_manager + + self._task: asyncio.Task | None = None + self._state: PlaylistRuntimeState | None = None + # Serialises start/stop so overlapping API calls can't leave two + # cycling tasks alive at once. + self._lifecycle_lock = asyncio.Lock() + + # ===== Public control API ===== + + async def start_playlist(self, playlist_id: str) -> PlaylistRuntimeState: + """Start cycling ``playlist_id``, stopping any current playlist first. + + Raises ``PlaylistError`` if the playlist is unknown or has no items. + """ + try: + playlist = self._playlist_store.get_playlist(playlist_id) + except Exception as exc: # EntityNotFoundError / ValueError + raise PlaylistError(f"Playlist not found: {playlist_id}") from exc + + if not playlist.items: + raise PlaylistError(f"Playlist '{playlist.name}' has no items") + + async with self._lifecycle_lock: + await self._cancel_task() + + now = datetime.now(timezone.utc) + first_item = playlist.items[0] + self._state = PlaylistRuntimeState( + playlist_id=playlist.id, + playlist_name=playlist.name, + current_index=0, + item_count=len(playlist.items), + current_preset_id=first_item.scene_preset_id, + started_at=now, + step_started_at=now, + step_duration=clamp_duration(first_item.duration_seconds), + ) + self._task = asyncio.create_task(self._run(playlist.id)) + + self._fire_event("started") + logger.info("Playlist '%s' started (%d items)", playlist.name, len(playlist.items)) + return self._state + + async def stop(self) -> None: + """Stop the active playlist (if any). Leaves the last scene applied.""" + async with self._lifecycle_lock: + was_running = self._task is not None + await self._cancel_task() + stopped_id = self._state.playlist_id if self._state else None + self._state = None + if was_running: + self._fire_event("stopped", playlist_id=stopped_id) + logger.info("Playlist stopped") + + async def stop_if_running(self, playlist_id: str) -> None: + """Stop the playlist only if ``playlist_id`` is the one running. + + Used when a playlist is deleted or edited so a stale snapshot can't keep + cycling. + """ + if self._state is not None and self._state.playlist_id == playlist_id: + await self.stop() + + # ===== Query API (used by routes) ===== + + def is_running(self) -> bool: + return self._task is not None and not self._task.done() + + def get_running_playlist_id(self) -> str | None: + return self._state.playlist_id if self._state else None + + def get_state(self) -> dict: + if self._state is not None and self.is_running(): + return self._state.to_dict() + return dict(_IDLE_STATE) + + # ===== Internal ===== + + async def _cancel_task(self) -> None: + """Cancel and await the cycling task. Caller holds the lifecycle lock.""" + task = self._task + self._task = None + if task is None: + return + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception as exc: # pragma: no cover - defensive + logger.error("Playlist task raised on cancel: %s", exc, exc_info=True) + + async def _run(self, playlist_id: str) -> None: + """Cycle the playlist until cancelled, the playlist ends, or it errors.""" + try: + while True: + # Re-read each cycle so edits/deletes apply at the boundary. + try: + playlist = self._playlist_store.get_playlist(playlist_id) + except Exception: + logger.info("Playlist %s removed while running; stopping", playlist_id) + break + + if not playlist.items: + logger.info("Playlist '%s' has no items; stopping", playlist.name) + break + + applied_any = await self._run_cycle(playlist) + + if not playlist.loop: + break + if not applied_any: + # Every item referenced a missing preset — a looping + # playlist would otherwise spin with no dwell. Bail out. + logger.warning( + "Playlist '%s' applied no valid presets this cycle; stopping", + playlist.name, + ) + break + + # Natural end (non-loop or guard). Clear state without recursing + # through stop() (which would try to cancel this very task). Guard + # against a concurrent start_playlist having already replaced us: + # only clear if we are still the engine's current task. + if self._task is asyncio.current_task(): + self._task = None + ended_id = self._state.playlist_id if self._state else None + self._state = None + self._fire_event("stopped", playlist_id=ended_id) + logger.info("Playlist '%s' finished", playlist_id) + except asyncio.CancelledError: + raise + except Exception as exc: # pragma: no cover - defensive + logger.error("Playlist run loop error: %s", exc, exc_info=True) + + async def _run_cycle(self, playlist: ScenePlaylist) -> bool: + """Run one pass over the playlist's items. Returns True if any applied.""" + order = self._resolve_order(playlist) + applied_any = False + + for index, item in enumerate(order): + duration = clamp_duration(item.duration_seconds) + if self._state is not None: + self._state.current_index = index + self._state.current_preset_id = item.scene_preset_id + self._state.step_started_at = datetime.now(timezone.utc) + self._state.step_duration = duration + + applied = await self._apply_item(item.scene_preset_id) + if applied: + applied_any = True + self._fire_event("advanced", index=index, preset_id=item.scene_preset_id) + # Only dwell on scenes we actually applied; skip missing ones + # immediately so the cycle doesn't stall on a dead reference. + await asyncio.sleep(duration) + + return applied_any + + def _resolve_order(self, playlist: ScenePlaylist) -> List: + if playlist.shuffle and len(playlist.items) > 1: + shuffled = list(playlist.items) + random.shuffle(shuffled) # noqa: S311 - cosmetic ordering, not security + return shuffled + return list(playlist.items) + + async def _apply_item(self, preset_id: str) -> bool: + """Apply one scene preset. Returns False if it could not be applied.""" + if not self._scene_preset_store or not self._target_store or not self._manager: + logger.warning("Playlist engine missing stores; cannot apply %s", preset_id) + return False + try: + preset = self._scene_preset_store.get_preset(preset_id) + except Exception: + logger.warning("Playlist references missing scene preset %s (skipped)", preset_id) + return False + + from ledgrab.core.scenes.scene_activator import apply_scene_state + + _status, errors = await apply_scene_state(preset, self._target_store, self._manager) + if errors: + logger.warning("Playlist step '%s' applied with errors: %s", preset.name, errors) + return True + + def _fire_event(self, action: str, **extra) -> None: + if self._manager is None: + return + try: + self._manager.fire_event( + { + "type": "playlist_state_changed", + "action": action, + "playlist_id": extra.get("playlist_id") + or (self._state.playlist_id if self._state else None), + **{k: v for k, v in extra.items() if k != "playlist_id"}, + } + ) + except Exception as exc: + logger.error("Playlist event fire failed: %s", exc, exc_info=True) diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index 00a2ac5..a28402b 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -35,6 +35,7 @@ import ledgrab.core.audio # noqa: F401 — trigger engine auto-registration from ledgrab.storage.value_source_store import ValueSourceStore from ledgrab.storage.automation_store import AutomationStore from ledgrab.storage.scene_preset_store import ScenePresetStore +from ledgrab.storage.scene_playlist_store import ScenePlaylistStore from ledgrab.storage.sync_clock_store import SyncClockStore from ledgrab.storage.color_strip_processing_template_store import ( ColorStripProcessingTemplateStore, @@ -47,6 +48,7 @@ from ledgrab.core.weather.weather_manager import WeatherManager from ledgrab.storage.home_assistant_store import HomeAssistantStore from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager from ledgrab.core.automations.automation_engine import AutomationEngine +from ledgrab.core.scenes.playlist_engine import PlaylistEngine from ledgrab.storage.game_integration_store import GameIntegrationStore from ledgrab.core.game_integration.event_bus import GameEventBus import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters @@ -157,6 +159,7 @@ audio_template_store = AudioTemplateStore(db) value_source_store = ValueSourceStore(db) automation_store = AutomationStore(db) scene_preset_store = ScenePresetStore(db) +scene_playlist_store = ScenePlaylistStore(db) sync_clock_store = SyncClockStore(db) cspt_store = ColorStripProcessingTemplateStore(db) gradient_store = GradientStore(db) @@ -278,6 +281,15 @@ async def lifespan(app: FastAPI): value_source_store=value_source_store, ) + # Create playlist engine — auto-cycles scene presets, one playlist at a + # time. Idle (no background task) until a playlist is started via the API. + playlist_engine = PlaylistEngine( + playlist_store=scene_playlist_store, + scene_preset_store=scene_preset_store, + target_store=output_target_store, + processor_manager=processor_manager, + ) + # Create auto-backup engine — derive paths from database location so that # demo mode auto-backups go to data/demo/ instead of data/. _data_dir = Path(config.storage.database_file).parent @@ -314,7 +326,9 @@ async def lifespan(app: FastAPI): value_source_store=value_source_store, automation_store=automation_store, scene_preset_store=scene_preset_store, + scene_playlist_store=scene_playlist_store, automation_engine=automation_engine, + playlist_engine=playlist_engine, auto_backup_engine=auto_backup_engine, sync_clock_store=sync_clock_store, sync_clock_manager=sync_clock_manager, @@ -436,6 +450,9 @@ async def lifespan(app: FastAPI): # would talk to processors mid-shutdown. await _bounded("automation_engine.stop", automation_engine.stop(), timeout=1.5) + # Stop the playlist engine so its cycling task can't apply scenes mid-shutdown. + await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0) + # Stop discovery watcher and OS notification listener so they stop # firing events into a shutting-down processor manager. if discovery_watcher is not None: diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index 21afe0d..35e1a99 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -1134,6 +1134,175 @@ textarea:focus-visible { cursor: not-allowed; } +/* ── Scene playlist items — ordered, timed channel rows ────────── + Mirrors .scene-target-* (cyan patch-bay) but adds a per-item dwell + duration field and reorder controls. Slot index via CSS counter so + DOM reorders need no JS renumbering. Paired with + .ds-section[data-ch="cyan"] in scene-playlist-editor.html. */ +.playlist-item-list { + --st-ch: var(--ch-cyan, var(--info-color, #00d8ff)); + counter-reset: st-slot; + display: flex; + flex-direction: column; + gap: 4px; + padding: 0; +} +.playlist-item-list:empty::before { + content: attr(data-empty); + display: block; + padding: 14px 12px; + font-size: 0.78rem; + color: var(--lux-ink-dim, var(--text-secondary)); + border: 1px dashed color-mix(in srgb, var(--st-ch) 40%, var(--lux-line, var(--border-color))); + border-radius: var(--lux-r-md, 6px); + background: + repeating-linear-gradient(135deg, + color-mix(in srgb, var(--st-ch) 4%, transparent) 0 6px, + transparent 6px 12px); + text-align: center; + letter-spacing: 0.04em; +} +.playlist-item { + counter-increment: st-slot; + position: relative; + display: grid; + grid-template-columns: 26px 32px minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + padding: 6px 8px 6px 6px; + border: 1px solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-md, 6px); + background: + linear-gradient(180deg, + color-mix(in srgb, var(--st-ch) 3%, var(--lux-bg-2, var(--bg-secondary))) 0%, + color-mix(in srgb, var(--lux-bg-1, var(--card-bg)) 70%, transparent) 100%); + font-size: 0.85rem; + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; +} +.playlist-item:hover { + border-color: color-mix(in srgb, var(--st-ch) 55%, var(--lux-line, var(--border-color))); + box-shadow: inset 2px 0 0 color-mix(in srgb, var(--st-ch) 80%, transparent); +} +.playlist-item::before { + content: counter(st-slot, decimal-leading-zero); + grid-column: 1; + justify-self: center; + font-family: var(--font-mono); + font-size: 0.65rem; + font-weight: 600; + color: color-mix(in srgb, var(--st-ch) 75%, var(--lux-ink-dim, var(--text-secondary))); + opacity: 0.85; +} +.playlist-item-icon { + grid-column: 2; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + color: var(--st-ch); + background: color-mix(in srgb, var(--st-ch) 9%, transparent); + border: 1px solid color-mix(in srgb, var(--st-ch) 22%, transparent); + border-radius: 5px; + flex-shrink: 0; +} +.playlist-item-icon svg, +.playlist-item-icon .icon { width: 18px; height: 18px; } +.playlist-item-icon .scene-color-dot { width: 12px; height: 12px; border-radius: 50%; } +.playlist-item-id { + grid-column: 3; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.playlist-item-name { + font-weight: 600; + color: var(--lux-ink, var(--text-color)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.25; +} +.playlist-item-type { + align-self: flex-start; + font-family: var(--font-mono); + font-size: 0.6rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: color-mix(in srgb, var(--st-ch) 70%, var(--lux-ink-dim, var(--text-secondary))); + padding: 1px 5px; + border: 1px solid color-mix(in srgb, var(--st-ch) 28%, transparent); + border-radius: 2px; + line-height: 1.2; +} +.playlist-item-type.playlist-item--missing { + color: var(--ch-coral, var(--danger-color, #ff5e5e)); + border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 40%, transparent); +} +.playlist-item-duration-wrap { + grid-column: 4; + display: inline-flex; + align-items: center; + gap: 3px; + color: var(--lux-ink-dim, var(--text-secondary)); +} +.playlist-item-duration-wrap svg, +.playlist-item-duration-wrap .icon { width: 13px; height: 13px; opacity: 0.7; } +.playlist-item-duration { + width: 52px; + padding: 3px 5px; + text-align: right; + font-family: var(--font-mono); + font-size: 0.8rem; + border: 1px solid var(--lux-line, var(--border-color)); + border-radius: 4px; + background: var(--bg-color, transparent); + color: var(--lux-ink, var(--text-color)); +} +.playlist-item-unit { + font-family: var(--font-mono); + font-size: 0.72rem; + opacity: 0.7; +} +.playlist-item-actions { + grid-column: 5; + display: inline-flex; + gap: 2px; +} +.playlist-item-btn { + background: none; + border: 1px solid transparent; + color: var(--lux-ink-dim, var(--text-secondary)); + width: 24px; + height: 26px; + border-radius: 5px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.75; + font-size: 0.9rem; + transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease, border-color 0.15s ease; +} +.playlist-item-btn:hover, +.playlist-item-btn:focus-visible { + opacity: 1; + color: var(--st-ch); + background: color-mix(in srgb, var(--st-ch) 10%, transparent); + border-color: color-mix(in srgb, var(--st-ch) 30%, transparent); + outline: none; +} +.playlist-item-btn.playlist-item-remove:hover, +.playlist-item-btn.playlist-item-remove:focus-visible { + color: var(--ch-coral, var(--danger-color, #ff5e5e)); + background: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 10%, transparent); + border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 35%, transparent); +} +.playlist-item-btn .icon, +.playlist-item-btn svg { width: 14px; height: 14px; } + /* ── Icon Select (reusable type picker) ──────────────────────── */ .icon-select-trigger { diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index cd267dd..97e518b 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -116,6 +116,11 @@ import { activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset, addSceneTarget, } from './features/scene-presets.ts'; +import { + openPlaylistEditor, editPlaylist, savePlaylist, closePlaylistEditor, + clonePlaylist, deletePlaylist, addPlaylistItem, + startScenePlaylist, stopScenePlaylist, +} from './features/scene-playlists.ts'; // Layer 5: device-discovery, targets import { @@ -463,6 +468,17 @@ Object.assign(window, { recaptureScenePreset, addSceneTarget, + // scene playlists — modal buttons + mod-card inline handlers + openPlaylistEditor, + editPlaylist, + savePlaylist, + closePlaylistEditor, + clonePlaylist, + deletePlaylist, + addPlaylistItem, + startScenePlaylist, + stopScenePlaylist, + // integrations loadIntegrations, switchIntegrationTab, diff --git a/server/src/ledgrab/static/js/core/entity-events.ts b/server/src/ledgrab/static/js/core/entity-events.ts index e0223f5..0840d59 100644 --- a/server/src/ledgrab/static/js/core/entity-events.ts +++ b/server/src/ledgrab/static/js/core/entity-events.ts @@ -8,7 +8,7 @@ import { devicesCache, outputTargetsCache, colorStripSourcesCache, streamsCache, audioSourcesCache, valueSourcesCache, - syncClocksCache, automationsCacheObj, scenePresetsCache, + syncClocksCache, automationsCacheObj, scenePresetsCache, scenePlaylistsCache, captureTemplatesCache, audioTemplatesCache, ppTemplatesCache, patternTemplatesCache, weatherSourcesCache, haSourcesCache, mqttSourcesCache, @@ -26,6 +26,7 @@ const ENTITY_CACHE_MAP = { sync_clock: syncClocksCache, automation: automationsCacheObj, scene_preset: scenePresetsCache, + scene_playlist: scenePlaylistsCache, capture_template: captureTemplatesCache, audio_template: audioTemplatesCache, pp_template: ppTemplatesCache, @@ -51,6 +52,7 @@ const ENTITY_LOADER_MAP = { pp_template: 'loadPictureSources', automation: 'loadAutomations', scene_preset: 'loadAutomations', + scene_playlist: 'loadAutomations', weather_source: 'loadIntegrations', home_assistant_source: 'loadIntegrations', mqtt_source: 'loadIntegrations', diff --git a/server/src/ledgrab/static/js/core/events-ws.ts b/server/src/ledgrab/static/js/core/events-ws.ts index 339170b..507b8f3 100644 --- a/server/src/ledgrab/static/js/core/events-ws.ts +++ b/server/src/ledgrab/static/js/core/events-ws.ts @@ -40,6 +40,7 @@ const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet = new Set([ 'server_restarting', 'state_change', 'automation_state_changed', + 'playlist_state_changed', 'entity_changed', 'device_health_changed', 'update_available', diff --git a/server/src/ledgrab/static/js/core/state.ts b/server/src/ledgrab/static/js/core/state.ts index 1fac506..ac700f0 100644 --- a/server/src/ledgrab/static/js/core/state.ts +++ b/server/src/ledgrab/static/js/core/state.ts @@ -15,7 +15,7 @@ import { DataCache } from './cache.ts'; import type { Device, OutputTarget, ColorStripSource, PatternTemplate, - ValueSource, AudioSource, PictureSource, ScenePreset, + ValueSource, AudioSource, PictureSource, ScenePreset, ScenePlaylist, SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo, CaptureTemplate, PostprocessingTemplate, AudioTemplate, ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle, @@ -436,6 +436,11 @@ export const scenePresetsCache = new DataCache({ extractData: json => json.presets || [], }); +export const scenePlaylistsCache = new DataCache({ + endpoint: '/scene-playlists', + extractData: json => json.playlists || [], +}); + export interface GradientEntity { id: string; name: string; diff --git a/server/src/ledgrab/static/js/features/automations.ts b/server/src/ledgrab/static/js/features/automations.ts index 4443898..157eb05 100644 --- a/server/src/ledgrab/static/js/features/automations.ts +++ b/server/src/ledgrab/static/js/features/automations.ts @@ -4,7 +4,7 @@ import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, - scenePresetsCache, _cachedHASources, haSourcesCache, + scenePresetsCache, scenePlaylistsCache, _cachedHASources, haSourcesCache, _cachedValueSources, valueSourcesCache, getHAEntityFriendlyName, setHAEntityNames, } from '../core/state.ts'; @@ -18,7 +18,7 @@ import { Modal } from '../core/modal.ts'; import { CardSection } from '../core/card-sections.ts'; import { updateTabBadge, updateSubTabHash } from './tabs.ts'; import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts'; -import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE } from '../core/icons.ts'; +import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE, ICON_LIST_CHECKS } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts'; @@ -32,6 +32,7 @@ import { enhanceMiniSelects } from '../core/mini-select.ts'; import { attachProcessPicker, attachAppPicker } from '../core/process-picker.ts'; import { TreeNav } from '../core/tree-nav.ts'; import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts'; +import { csPlaylists, createPlaylistCard, initPlaylistDelegation } from './scene-playlists.ts'; import type { Automation, RuleType } from '../types.ts'; registerIconEntityType('automation', makeSimpleIconAdapter({ @@ -252,6 +253,7 @@ export async function loadAutomations() { const [automations, scenes] = await Promise.all([ automationsCacheObj.fetch(), scenePresetsCache.fetch(), + scenePlaylistsCache.fetch(), haSourcesCache.fetch(), valueSourcesCache.fetch(), ]); @@ -291,38 +293,45 @@ function renderAutomations(automations: any, sceneMap: any) { const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) }))); const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) }))); + const playlistItems = csPlaylists.applySortOrder(scenePlaylistsCache.data.map(p => ({ key: p.id, html: createPlaylistCard(p) }))); const activeTab = getActiveSubTab('automations')!; const treeItems = [ { key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length }, { key: 'scenes', icon: ICON_SCENE, titleKey: 'scenes.title', count: scenePresetsCache.data.length }, + { key: 'playlists', icon: ICON_LIST_CHECKS, titleKey: 'playlists.title', count: scenePlaylistsCache.data.length }, ]; if (csAutomations.isMounted()) { _automationsTree.updateCounts({ automations: automations.length, scenes: scenePresetsCache.data.length, + playlists: scenePlaylistsCache.data.length, }); csAutomations.reconcile(autoItems); csScenes.reconcile(sceneItems); + csPlaylists.reconcile(playlistItems); } else { const panels = [ { key: 'automations', html: csAutomations.render(autoItems) }, { key: 'scenes', html: csScenes.render(sceneItems) }, + { key: 'playlists', html: csPlaylists.render(playlistItems) }, ].map(p => `
${p.html}
`).join(''); container!.innerHTML = panels; - CardSection.bindAll([csAutomations, csScenes]); + CardSection.bindAll([csAutomations, csScenes, csPlaylists]); - // Event delegation for scene preset card actions + // Event delegation for scene preset + playlist card actions initScenePresetDelegation(container!); + initPlaylistDelegation(container!); _automationsTree.setExtraHtml(``); _automationsTree.update(treeItems, activeTab); _automationsTree.observeSections('automations-content', { 'automations': 'automations', 'scenes': 'scenes', + 'playlists': 'playlists', }); } } diff --git a/server/src/ledgrab/static/js/features/scene-playlists.ts b/server/src/ledgrab/static/js/features/scene-playlists.ts new file mode 100644 index 0000000..e67e825 --- /dev/null +++ b/server/src/ledgrab/static/js/features/scene-playlists.ts @@ -0,0 +1,531 @@ +/** + * Scene Playlists — ordered, timed sequences of scene presets that auto-cycle. + * Rendered as a CardSection inside the Automations tab (third sub-tab). + * + * A playlist activates each referenced scene preset in turn and holds it for + * that item's dwell duration, then advances. Only one playlist cycles at a + * time; starting one stops any other. + */ + +import { escapeHtml } from '../core/api.ts'; +import { apiPost, apiPut, apiDelete } from '../core/api-client.ts'; +import { t } from '../core/i18n.ts'; +import { showToast, showConfirm } from '../core/ui.ts'; +import { Modal } from '../core/modal.ts'; +import { CardSection } from '../core/card-sections.ts'; +import { + ICON_START, ICON_PAUSE, ICON_EDIT, ICON_TRASH, ICON_LINK, ICON_REFRESH, ICON_CLOCK, +} from '../core/icons.ts'; +import { renderDeviceIconSvg } from '../core/device-icons.ts'; +import { scenePlaylistsCache, scenePresetsCache } from '../core/state.ts'; +import { TagInput, renderTagChips } from '../core/tag-input.ts'; +import { wrapCard } from '../core/card-colors.ts'; +import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts'; +import { EntityPalette } from '../core/entity-palette.ts'; +import { navigateToCard } from '../core/navigation.ts'; +import { isActiveTab } from '../core/tab-registry.ts'; +import { makeCardIconFields } from '../core/card-icon.ts'; +import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts'; +import type { ScenePlaylist, ScenePreset } from '../types.ts'; + +const DEFAULT_ITEM_DURATION = 30; +const MIN_ITEM_DURATION = 1; +const SCENE_DOT = ''; + +registerIconEntityType('scene_playlist', makeSimpleIconAdapter({ + cache: scenePlaylistsCache, + endpointPrefix: '/scene-playlists', + reload: async () => { + scenePlaylistsCache.invalidate(); + if (typeof window.loadAutomations === 'function') { + await window.loadAutomations(); + } + }, + typeLabelKey: 'device.icon.entity.scene_playlist', + typeLabelFallback: 'Playlist', + cardSelectors: (id) => [`[data-playlist-id="${CSS.escape(id)}"]`], +})); + +let _editingId: string | null = null; +let _playlistTagsInput: TagInput | null = null; +let _presetMap: Record = {}; + +// ── Scene-preset lookup helpers ── + +async function _primePresets(): Promise { + const presets = await scenePresetsCache.fetch().catch((): ScenePreset[] => []); + _presetMap = {}; + for (const p of presets) _presetMap[p.id] = p; +} + +function _presetName(presetId: string): string { + return _presetMap[presetId]?.name || presetId; +} + +function _presetIconSvg(preset: ScenePreset | undefined): string { + const svg = preset?.icon ? renderDeviceIconSvg(preset.icon, { size: 18 }) : ''; + return svg || SCENE_DOT; +} + +// ── Item row rendering (editor) ── + +function _renderItemRowHtml(presetId: string, duration: number): string { + const preset = _presetMap[presetId]; + const removeLabel = t('common.remove') || 'Remove'; + const upLabel = t('playlists.item.move_up') || 'Move up'; + const downLabel = t('playlists.item.move_down') || 'Move down'; + const missing = preset ? '' : ' playlist-item--missing'; + return ` + +
+ ${escapeHtml(_presetName(presetId))} + ${preset ? 'SCN' : (t('playlists.item.missing') || 'MISSING')} +
+
+ ${ICON_CLOCK} + + s +
+
+ + + +
+ `; +} + +function _appendItemRow(presetId: string, duration: number, listEl: HTMLElement): void { + const item = document.createElement('div'); + item.className = 'playlist-item'; + item.dataset.presetId = presetId; + item.dataset.duration = String(duration); + item.innerHTML = _renderItemRowHtml(presetId, duration); + listEl.appendChild(item); +} + +function _readEditorItems(): Array<{ scene_preset_id: string; duration_seconds: number }> { + return [...document.querySelectorAll('#playlist-item-list .playlist-item')].map(el => { + const row = el as HTMLElement; + const input = row.querySelector('.playlist-item-duration') as HTMLInputElement | null; + const raw = input ? parseFloat(input.value) : DEFAULT_ITEM_DURATION; + const duration = Number.isFinite(raw) && raw >= MIN_ITEM_DURATION ? raw : MIN_ITEM_DURATION; + return { scene_preset_id: row.dataset.presetId || '', duration_seconds: duration }; + }).filter(i => i.scene_preset_id); +} + +function _setItemListEmptyHint(listEl: HTMLElement): void { + listEl.dataset.empty = t('playlists.items.empty') || 'No scenes yet — add some below'; +} + +// ── Auto-name ── + +let _plNameManuallyEdited = false; + +function _autoGeneratePlaylistName(): void { + if (_plNameManuallyEdited) return; + if ((document.getElementById('playlist-editor-id') as HTMLInputElement).value) return; + const count = document.querySelectorAll('#playlist-item-list .playlist-item').length; + const label = count > 0 + ? `${t('playlists.title')} · ${count} ${count === 1 ? (t('playlists.scene_one') || 'scene') : (t('playlists.scene_many') || 'scenes')}` + : t('playlists.title'); + (document.getElementById('playlist-editor-name') as HTMLInputElement).value = label; +} + +class PlaylistEditorModal extends Modal { + constructor() { super('playlist-editor-modal'); } + onForceClose() { + if (_playlistTagsInput) { _playlistTagsInput.destroy(); _playlistTagsInput = null; } + } + snapshotValues() { + const items = _readEditorItems().map(i => `${i.scene_preset_id}:${i.duration_seconds}`).join(','); + return { + name: (document.getElementById('playlist-editor-name') as HTMLInputElement).value, + description: (document.getElementById('playlist-editor-description') as HTMLInputElement).value, + loop: (document.getElementById('playlist-editor-loop') as HTMLInputElement).checked.toString(), + shuffle: (document.getElementById('playlist-editor-shuffle') as HTMLInputElement).checked.toString(), + items, + tags: JSON.stringify(_playlistTagsInput ? _playlistTagsInput.getValue() : []), + }; + } +} +const playlistModal = new PlaylistEditorModal(); + +export const csPlaylists = new CardSection('playlists', { + titleKey: 'playlists.title', + gridClass: 'devices-grid', + addCardOnclick: 'openPlaylistEditor()', + keyAttr: 'data-playlist-id', + emptyKey: 'section.empty.playlists', + bulkActions: [{ + key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', + handler: async (ids) => { + const results = await Promise.allSettled(ids.map(id => apiDelete(`/scene-playlists/${id}`))); + const failed = results.filter(r => r.status === 'rejected').length; + if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); + else showToast(t('playlists.deleted'), 'success'); + scenePlaylistsCache.invalidate(); + if (window.loadAutomations) window.loadAutomations(); + }, + }], +}); + +export function createPlaylistCard(playlist: ScenePlaylist): string { + const itemCount = (playlist.items || []).length; + const running = playlist.is_running === true; + const updated = playlist.updated_at ? new Date(playlist.updated_at).toLocaleString() : ''; + const shortId = (playlist.id || '').replace(/^playlist_/i, '').slice(-2).toUpperCase() || 'NA'; + + const metaParts: string[] = []; + if (itemCount > 0) metaParts.push(`${itemCount} ${t('playlists.scenes_count')}`); + if (updated) metaParts.push(updated); + const metaHtml = metaParts.length ? metaParts.map(escapeHtml).join(' · ') : undefined; + + const chips: ModChipOpts[] = []; + if (playlist.loop) chips.push({ icon: ICON_REFRESH, text: t('playlists.chip.loop'), variant: 'tag' }); + if (playlist.shuffle) chips.push({ icon: ICON_LINK, text: t('playlists.chip.shuffle'), variant: 'tag' }); + + const leds: LedState[] = [running ? 'on' : 'off']; + + const primaryAction = running + ? { + label: t('playlists.action.stop'), + icon: ICON_PAUSE, + onclick: `stopScenePlaylist()`, + title: t('playlists.stop'), + variant: 'stop' as const, + } + : { + label: t('playlists.action.start'), + icon: ICON_START, + onclick: `startScenePlaylist('${playlist.id}')`, + title: t('playlists.start'), + variant: 'go' as const, + }; + + const mod: ModCardOpts = { + head: { + badge: { text: `PL · ${shortId}` }, + name: playlist.name, + metaHtml, + leds, + ...makeCardIconFields('scene_playlist', playlist.id, playlist), + menu: { + duplicateOnclick: `clonePlaylist('${playlist.id}')`, + hideOnclick: `toggleCardHidden('playlists','${playlist.id}')`, + deleteOnclick: `deletePlaylist('${playlist.id}')`, + }, + }, + body: { + desc: playlist.description || undefined, + chips: chips.length ? chips : undefined, + }, + foot: { + patchState: running ? 'live' : 'idle', + patchLabel: running ? t('playlists.status.playing') : t('playlists.status.stopped'), + primaryAction, + iconActions: [{ + icon: ICON_EDIT, + onclick: `editPlaylist('${playlist.id}')`, + title: t('playlists.edit'), + }], + }, + }; + + const cardHtml = wrapCard({ dataAttr: 'data-playlist-id', id: playlist.id, mod }); + const tagsHtml = renderTagChips(playlist.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}`) : cardHtml; +} + +export async function loadPlaylists(): Promise { + return scenePlaylistsCache.fetch(); +} + +// ===== Create / Edit / Clone ===== + +function _resetEditorChrome(titleKey: string): void { + (document.getElementById('playlist-editor-error') as HTMLElement).style.display = 'none'; + const titleEl = document.querySelector('#playlist-editor-title span[data-i18n]'); + if (titleEl) { titleEl.setAttribute('data-i18n', titleKey); titleEl.textContent = t(titleKey); } +} + +function _wireAutoName(): void { + _plNameManuallyEdited = false; + (document.getElementById('playlist-editor-name') as HTMLElement).oninput = () => { _plNameManuallyEdited = true; }; +} + +function _initTags(values: string[]): void { + if (_playlistTagsInput) { _playlistTagsInput.destroy(); _playlistTagsInput = null; } + _playlistTagsInput = new TagInput(document.getElementById('playlist-tags-container'), { placeholder: t('tags.placeholder') }); + _playlistTagsInput.setValue(values); +} + +async function _openEditorWith(opts: { + editingId: string | null; + name: string; + description: string; + loop: boolean; + shuffle: boolean; + items: Array<{ scene_preset_id: string; duration_seconds: number }>; + tags: string[]; + titleKey: string; +}): Promise { + _editingId = opts.editingId; + (document.getElementById('playlist-editor-id') as HTMLInputElement).value = opts.editingId || ''; + (document.getElementById('playlist-editor-name') as HTMLInputElement).value = opts.name; + (document.getElementById('playlist-editor-description') as HTMLInputElement).value = opts.description; + (document.getElementById('playlist-editor-loop') as HTMLInputElement).checked = opts.loop; + (document.getElementById('playlist-editor-shuffle') as HTMLInputElement).checked = opts.shuffle; + _resetEditorChrome(opts.titleKey); + + const list = document.getElementById('playlist-item-list'); + if (list) { + list.innerHTML = ''; + _setItemListEmptyHint(list); + await _primePresets(); + for (const it of opts.items) _appendItemRow(it.scene_preset_id, it.duration_seconds, list); + } + + _initTags(opts.tags); + _wireAutoName(); + if (!opts.editingId) _autoGeneratePlaylistName(); + + playlistModal.open(); + playlistModal.snapshot(); +} + +export async function openPlaylistEditor(): Promise { + await _openEditorWith({ + editingId: null, name: '', description: '', loop: true, shuffle: false, + items: [], tags: [], titleKey: 'playlists.add', + }); +} + +export async function editPlaylist(playlistId: string): Promise { + const playlist = scenePlaylistsCache.data.find(p => p.id === playlistId); + if (!playlist) return; + await _openEditorWith({ + editingId: playlistId, + name: playlist.name, + description: playlist.description || '', + loop: playlist.loop !== false, + shuffle: playlist.shuffle === true, + items: (playlist.items || []).map(i => ({ scene_preset_id: i.scene_preset_id, duration_seconds: i.duration_seconds })), + tags: playlist.tags || [], + titleKey: 'playlists.edit', + }); +} + +export async function clonePlaylist(playlistId: string): Promise { + const playlist = scenePlaylistsCache.data.find(p => p.id === playlistId); + if (!playlist) return; + await _openEditorWith({ + editingId: null, + name: `${playlist.name || ''} (Copy)`, + description: playlist.description || '', + loop: playlist.loop !== false, + shuffle: playlist.shuffle === true, + items: (playlist.items || []).map(i => ({ scene_preset_id: i.scene_preset_id, duration_seconds: i.duration_seconds })), + tags: playlist.tags || [], + titleKey: 'playlists.add', + }); +} + +export async function savePlaylist(): Promise { + if (playlistModal.closeIfPristine(_editingId)) return; + + const name = (document.getElementById('playlist-editor-name') as HTMLInputElement).value.trim(); + const description = (document.getElementById('playlist-editor-description') as HTMLInputElement).value.trim(); + const loop = (document.getElementById('playlist-editor-loop') as HTMLInputElement).checked; + const shuffle = (document.getElementById('playlist-editor-shuffle') as HTMLInputElement).checked; + const errorEl = document.getElementById('playlist-editor-error')!; + + if (!name) { + errorEl.textContent = t('playlists.error.name_required'); + errorEl.style.display = 'block'; + return; + } + + const items = _readEditorItems(); + const tags = _playlistTagsInput ? _playlistTagsInput.getValue() : []; + const body = { name, description, loop, shuffle, items, tags }; + + try { + if (_editingId) { + await apiPut(`/scene-playlists/${_editingId}`, body, { errorMessage: t('playlists.error.save_failed') }); + } else { + await apiPost('/scene-playlists', body, { errorMessage: t('playlists.error.save_failed') }); + } + playlistModal.forceClose(); + showToast(_editingId ? t('playlists.updated') : t('playlists.created'), 'success'); + scenePlaylistsCache.invalidate(); + _reloadPlaylistsTab(); + } catch (error: any) { + if (error.isAuth) return; + errorEl.textContent = error.message || t('playlists.error.save_failed'); + errorEl.style.display = 'block'; + } +} + +export async function closePlaylistEditor(): Promise { + await playlistModal.close(); +} + +// ===== Item selector ===== + +export async function addPlaylistItem(): Promise { + await _primePresets(); + const presets = Object.values(_presetMap); + if (presets.length === 0) { + showToast(t('playlists.error.no_presets') || 'Create a scene preset first', 'warning'); + return; + } + + const items = presets.map(p => ({ + value: p.id, + label: p.name, + icon: _presetIconSvg(p), + })); + + const picked = await EntityPalette.pick({ + items, + placeholder: t('playlists.items.search_placeholder'), + }); + if (!picked) return; + + const list = document.getElementById('playlist-item-list'); + if (list) { + _appendItemRow(String(picked), DEFAULT_ITEM_DURATION, list); + _autoGeneratePlaylistName(); + } +} + +// ===== Start / Stop ===== + +export async function startScenePlaylist(playlistId: string): Promise { + try { + await apiPost(`/scene-playlists/${playlistId}/start`, undefined, { errorMessage: t('playlists.error.start_failed') }); + showToast(t('playlists.started'), 'success'); + scenePlaylistsCache.invalidate(); + _reloadPlaylistsTab(); + } catch (error: any) { + if (error.isAuth) return; + showToast(error.message || t('playlists.error.start_failed'), 'error'); + } +} + +export async function stopScenePlaylist(): Promise { + try { + await apiPost('/scene-playlists/stop', undefined, { errorMessage: t('playlists.error.stop_failed') }); + showToast(t('playlists.stopped'), 'success'); + scenePlaylistsCache.invalidate(); + _reloadPlaylistsTab(); + } catch (error: any) { + if (error.isAuth) return; + showToast(error.message || t('playlists.error.stop_failed'), 'error'); + } +} + +// ===== Delete ===== + +export async function deletePlaylist(playlistId: string): Promise { + const playlist = scenePlaylistsCache.data.find(p => p.id === playlistId); + const name = playlist ? playlist.name : playlistId; + const confirmed = await showConfirm(t('playlists.delete_confirm', { name })); + if (!confirmed) return; + + try { + await apiDelete(`/scene-playlists/${playlistId}`, { errorMessage: t('playlists.error.delete_failed') }); + showToast(t('playlists.deleted'), 'success'); + scenePlaylistsCache.invalidate(); + _reloadPlaylistsTab(); + } catch (error: any) { + if (error.isAuth) return; + showToast(error.message || t('playlists.error.delete_failed'), 'error'); + } +} + +// ===== Event delegation ===== + +const _playlistCardActions: Record void> = { + 'delete-playlist': deletePlaylist, + 'clone-playlist': clonePlaylist, + 'edit-playlist': editPlaylist, + 'start-playlist': startScenePlaylist, +}; + +export function initPlaylistDelegation(container: HTMLElement): void { + container.addEventListener('click', (e: MouseEvent) => { + const btn = (e.target as HTMLElement).closest('[data-action]'); + if (!btn) return; + + const action = btn.dataset.action; + const id = btn.dataset.id; + if (!action) return; + + if (action === 'add-playlist') { + e.stopPropagation(); + openPlaylistEditor(); + return; + } + if (action === 'stop-playlist') { + e.stopPropagation(); + stopScenePlaylist(); + return; + } + if (action === 'navigate-playlist') { + if ((e.target as HTMLElement).closest('button')) return; + navigateToCard('automations', 'playlists', 'playlists', 'data-playlist-id', id!); + return; + } + + if (!id) return; + const handler = _playlistCardActions[action]; + if (handler) { + e.stopPropagation(); + handler(id); + } + }); +} + +// ===== Helpers ===== + +function _reloadPlaylistsTab(): void { + if (isActiveTab('automations') && typeof window.loadAutomations === 'function') { + window.loadAutomations(); + } + if (typeof window.loadDashboard === 'function') window.loadDashboard(true); +} + +// ===== Editor modal item-list delegation (reorder / remove / duration) ===== + +const _playlistEditorModal = document.getElementById('playlist-editor-modal'); +if (_playlistEditorModal) { + _playlistEditorModal.addEventListener('click', (e: MouseEvent) => { + const btn = (e.target as HTMLElement).closest('[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + const row = btn.closest('.playlist-item') as HTMLElement | null; + if (!row) return; + + if (action === 'playlist-item-remove') { + row.remove(); + _autoGeneratePlaylistName(); + } else if (action === 'playlist-item-up') { + const prev = row.previousElementSibling; + if (prev) row.parentElement!.insertBefore(row, prev); + } else if (action === 'playlist-item-down') { + const next = row.nextElementSibling; + if (next) row.parentElement!.insertBefore(next, row); + } + }); +} + +// Live-refresh playlist cards when the engine reports a state change +// (start / advance / natural-completion stop) so the running indicator and +// Start/Stop button stay accurate across clients and after a playlist ends. +document.addEventListener('server:playlist_state_changed', () => { + scenePlaylistsCache.invalidate(); + _reloadPlaylistsTab(); +}); diff --git a/server/src/ledgrab/static/js/types.ts b/server/src/ledgrab/static/js/types.ts index 5b2fb37..e512638 100644 --- a/server/src/ledgrab/static/js/types.ts +++ b/server/src/ledgrab/static/js/types.ts @@ -108,6 +108,12 @@ export type { ScenePreset, ScenePresetListResponse, } from './types/scene-preset.ts'; +export type { + PlaylistItem, + ScenePlaylist, + PlaylistRuntimeState, + ScenePlaylistListResponse, +} from './types/scene-playlist.ts'; // ── Sync Clock ──────────────────────────────────────────────── export type { SyncClock, SyncClockListResponse } from './types/sync-clock.ts'; diff --git a/server/src/ledgrab/static/js/types/scene-playlist.ts b/server/src/ledgrab/static/js/types/scene-playlist.ts new file mode 100644 index 0000000..55a8dca --- /dev/null +++ b/server/src/ledgrab/static/js/types/scene-playlist.ts @@ -0,0 +1,43 @@ +/** + * Scene playlist shapes — an ordered, timed sequence of scene presets that + * auto-cycles, activating each preset and holding it for its dwell duration. + */ + +export interface PlaylistItem { + scene_preset_id: string; + duration_seconds: number; +} + +export interface ScenePlaylist { + id: string; + name: string; + description: string; + items: PlaylistItem[]; + loop: boolean; + shuffle: boolean; + order: number; + tags: string[]; + icon?: string; + icon_color?: string; + is_running?: boolean; + created_at: string; + updated_at: string; +} + +export interface PlaylistRuntimeState { + is_running: boolean; + playlist_id: string | null; + playlist_name: string | null; + current_index: number; + item_count: number; + current_preset_id: string | null; + started_at: string | null; + step_started_at: string | null; + step_duration: number; +} + +export interface ScenePlaylistListResponse { + playlists: ScenePlaylist[]; + count: number; + state: PlaylistRuntimeState; +} diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 07bf665..5378db2 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -790,6 +790,7 @@ "device.icon.entity.ha_source": "Home Assistant source", "device.icon.entity.automation": "Automation", "device.icon.entity.scene_preset": "Scene preset", + "device.icon.entity.scene_playlist": "Playlist", "device.icon.entity.sync_clock": "Sync clock", "device.icon.entity.game_integration": "Game integration", "device.icon.entity.audio_processing_template": "Audio processing template", @@ -1351,6 +1352,50 @@ "scenes.error.delete_failed": "Failed to delete scene", "scenes.cloned": "Scene cloned", "scenes.error.clone_failed": "Failed to clone scene", + "playlists.title": "Playlists", + "playlists.add": "New Playlist", + "playlists.edit": "Edit Playlist", + "playlists.name": "Name:", + "playlists.name.placeholder": "My Playlist", + "playlists.description": "Description:", + "playlists.description.hint": "Optional description of what this playlist does", + "playlists.section.playback": "Playback", + "playlists.loop": "Loop:", + "playlists.loop.hint": "Restart from the first scene after the last one; off plays through once and stops", + "playlists.shuffle": "Shuffle:", + "playlists.shuffle.hint": "Randomise the scene order at the start of every cycle", + "playlists.scenes": "Scenes:", + "playlists.scenes.hint": "The scene presets this playlist cycles through, each held for its own duration", + "playlists.scenes.add": "Add Scene", + "playlists.scenes_count": "scenes", + "playlists.scene_one": "scene", + "playlists.scene_many": "scenes", + "playlists.items.empty": "No scenes yet — add some below", + "playlists.items.search_placeholder": "Search scenes...", + "playlists.item.duration": "Seconds", + "playlists.item.move_up": "Move up", + "playlists.item.move_down": "Move down", + "playlists.item.missing": "Missing", + "playlists.chip.loop": "Loop", + "playlists.chip.shuffle": "Shuffle", + "playlists.action.start": "Start", + "playlists.action.stop": "Stop", + "playlists.start": "Start playlist", + "playlists.stop": "Stop playlist", + "playlists.status.playing": "Playing", + "playlists.status.stopped": "Stopped", + "playlists.started": "Playlist started", + "playlists.stopped": "Playlist stopped", + "playlists.created": "Playlist created", + "playlists.updated": "Playlist updated", + "playlists.deleted": "Playlist deleted", + "playlists.delete_confirm": "Delete playlist \"{name}\"?", + "playlists.error.name_required": "Name is required", + "playlists.error.save_failed": "Failed to save playlist", + "playlists.error.start_failed": "Failed to start playlist", + "playlists.error.stop_failed": "Failed to stop playlist", + "playlists.error.delete_failed": "Failed to delete playlist", + "playlists.error.no_presets": "Create a scene preset first", "dashboard.type.led": "LED", "dashboard.type.kc": "Key Colors", "aria.close": "Close", @@ -2690,6 +2735,7 @@ "section.empty.cspt": "No CSS processing templates yet. Click + to add one.", "section.empty.automations": "No automations yet. Click + to add one.", "section.empty.scenes": "No scene presets yet. Click + to add one.", + "section.empty.playlists": "No playlists yet. Click + to add one.", "bulk.select": "Select", "bulk.cancel": "Cancel", "bulk.selected_count.one": "{count} selected", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 8c19af5..fbe5d68 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -847,6 +847,7 @@ "device.icon.entity.ha_source": "Источник Home Assistant", "device.icon.entity.automation": "Автоматизация", "device.icon.entity.scene_preset": "Сцена", + "device.icon.entity.scene_playlist": "Плейлист", "device.icon.entity.sync_clock": "Часы синхронизации", "device.icon.entity.game_integration": "Игровая интеграция", "device.icon.entity.audio_processing_template": "Шаблон обработки аудио", @@ -1385,6 +1386,50 @@ "scenes.error.delete_failed": "Не удалось удалить сцену", "scenes.cloned": "Сцена клонирована", "scenes.error.clone_failed": "Не удалось клонировать сцену", + "playlists.title": "Плейлисты", + "playlists.add": "Новый плейлист", + "playlists.edit": "Изменить плейлист", + "playlists.name": "Название:", + "playlists.name.placeholder": "Мой плейлист", + "playlists.description": "Описание:", + "playlists.description.hint": "Необязательное описание плейлиста", + "playlists.section.playback": "Воспроизведение", + "playlists.loop": "Зацикливание:", + "playlists.loop.hint": "Начинать заново с первой сцены после последней; если выключено — проиграть один раз и остановиться", + "playlists.shuffle": "Перемешивание:", + "playlists.shuffle.hint": "Случайный порядок сцен в начале каждого цикла", + "playlists.scenes": "Сцены:", + "playlists.scenes.hint": "Пресеты сцен, которые перебирает плейлист, каждая удерживается своё время", + "playlists.scenes.add": "Добавить сцену", + "playlists.scenes_count": "сцен", + "playlists.scene_one": "сцена", + "playlists.scene_many": "сцен", + "playlists.items.empty": "Сцен пока нет — добавьте ниже", + "playlists.items.search_placeholder": "Поиск сцен...", + "playlists.item.duration": "Секунды", + "playlists.item.move_up": "Вверх", + "playlists.item.move_down": "Вниз", + "playlists.item.missing": "Отсутствует", + "playlists.chip.loop": "Цикл", + "playlists.chip.shuffle": "Перемешать", + "playlists.action.start": "Запустить", + "playlists.action.stop": "Остановить", + "playlists.start": "Запустить плейлист", + "playlists.stop": "Остановить плейлист", + "playlists.status.playing": "Воспроизводится", + "playlists.status.stopped": "Остановлен", + "playlists.started": "Плейлист запущен", + "playlists.stopped": "Плейлист остановлен", + "playlists.created": "Плейлист создан", + "playlists.updated": "Плейлист обновлён", + "playlists.deleted": "Плейлист удалён", + "playlists.delete_confirm": "Удалить плейлист «{name}»?", + "playlists.error.name_required": "Требуется название", + "playlists.error.save_failed": "Не удалось сохранить плейлист", + "playlists.error.start_failed": "Не удалось запустить плейлист", + "playlists.error.stop_failed": "Не удалось остановить плейлист", + "playlists.error.delete_failed": "Не удалось удалить плейлист", + "playlists.error.no_presets": "Сначала создайте пресет сцены", "dashboard.type.led": "LED", "dashboard.type.kc": "Цвета клавиш", "aria.close": "Закрыть", @@ -2371,6 +2416,7 @@ "section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.", "section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.", "section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.", + "section.empty.playlists": "Плейлистов пока нет. Нажмите + для добавления.", "bulk.select": "Выбрать", "bulk.cancel": "Отмена", "bulk.selected_count.one": "{count} выбран", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index e7ee323..0072f8e 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -843,6 +843,7 @@ "device.icon.entity.ha_source": "Home Assistant 源", "device.icon.entity.automation": "自动化", "device.icon.entity.scene_preset": "场景预设", + "device.icon.entity.scene_playlist": "播放列表", "device.icon.entity.sync_clock": "同步时钟", "device.icon.entity.game_integration": "游戏集成", "device.icon.entity.audio_processing_template": "音频处理模板", @@ -1381,6 +1382,50 @@ "scenes.error.delete_failed": "删除场景失败", "scenes.cloned": "场景已克隆", "scenes.error.clone_failed": "克隆场景失败", + "playlists.title": "播放列表", + "playlists.add": "新建播放列表", + "playlists.edit": "编辑播放列表", + "playlists.name": "名称:", + "playlists.name.placeholder": "我的播放列表", + "playlists.description": "描述:", + "playlists.description.hint": "此播放列表的可选描述", + "playlists.section.playback": "播放", + "playlists.loop": "循环:", + "playlists.loop.hint": "最后一个场景结束后从第一个重新开始;关闭则播放一遍后停止", + "playlists.shuffle": "随机:", + "playlists.shuffle.hint": "每个循环开始时随机打乱场景顺序", + "playlists.scenes": "场景:", + "playlists.scenes.hint": "此播放列表循环的场景预设,每个按各自的时长保持", + "playlists.scenes.add": "添加场景", + "playlists.scenes_count": "个场景", + "playlists.scene_one": "个场景", + "playlists.scene_many": "个场景", + "playlists.items.empty": "还没有场景 — 在下方添加", + "playlists.items.search_placeholder": "搜索场景...", + "playlists.item.duration": "秒", + "playlists.item.move_up": "上移", + "playlists.item.move_down": "下移", + "playlists.item.missing": "缺失", + "playlists.chip.loop": "循环", + "playlists.chip.shuffle": "随机", + "playlists.action.start": "开始", + "playlists.action.stop": "停止", + "playlists.start": "开始播放列表", + "playlists.stop": "停止播放列表", + "playlists.status.playing": "播放中", + "playlists.status.stopped": "已停止", + "playlists.started": "播放列表已开始", + "playlists.stopped": "播放列表已停止", + "playlists.created": "播放列表已创建", + "playlists.updated": "播放列表已更新", + "playlists.deleted": "播放列表已删除", + "playlists.delete_confirm": "删除播放列表“{name}”?", + "playlists.error.name_required": "需要名称", + "playlists.error.save_failed": "保存播放列表失败", + "playlists.error.start_failed": "启动播放列表失败", + "playlists.error.stop_failed": "停止播放列表失败", + "playlists.error.delete_failed": "删除播放列表失败", + "playlists.error.no_presets": "请先创建场景预设", "dashboard.type.led": "LED", "dashboard.type.kc": "关键颜色", "aria.close": "关闭", @@ -2367,6 +2412,7 @@ "section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。", "section.empty.automations": "暂无自动化。点击 + 添加。", "section.empty.scenes": "暂无场景预设。点击 + 添加。", + "section.empty.playlists": "暂无播放列表。点击 + 添加。", "bulk.select": "选择", "bulk.cancel": "取消", "bulk.selected_count.one": "已选 {count} 项", diff --git a/server/src/ledgrab/storage/database.py b/server/src/ledgrab/storage/database.py index f353682..ad9517f 100644 --- a/server/src/ledgrab/storage/database.py +++ b/server/src/ledgrab/storage/database.py @@ -50,6 +50,7 @@ _ENTITY_TABLES = [ "value_sources", "automations", "scene_presets", + "scene_playlists", "sync_clocks", "color_strip_processing_templates", "gradients", diff --git a/server/src/ledgrab/storage/scene_playlist.py b/server/src/ledgrab/storage/scene_playlist.py new file mode 100644 index 0000000..c958d19 --- /dev/null +++ b/server/src/ledgrab/storage/scene_playlist.py @@ -0,0 +1,118 @@ +"""Scene playlist data models — an ordered, timed sequence of scene presets. + +A playlist auto-cycles through its items, activating each referenced scene +preset and holding it for the item's dwell duration before advancing. The +runtime that drives the cycling lives in +``core/scenes/playlist_engine.py``; this module only describes the persisted +shape. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import List + +# Dwell-duration guard rails (seconds). A floor avoids a busy-loop when a +# user fat-fingers ``0``; the ceiling is just a sane upper bound for a UI. +MIN_DURATION_SECONDS = 1.0 +MAX_DURATION_SECONDS = 86_400.0 # 24h +DEFAULT_DURATION_SECONDS = 30.0 + + +def clamp_duration(value: float) -> float: + """Clamp a dwell duration into ``[MIN, MAX]`` seconds. + + Coerces non-numeric/None input to the default rather than raising, so a + malformed persisted value can never wedge the cycling loop. + """ + try: + seconds = float(value) + except (TypeError, ValueError): + return DEFAULT_DURATION_SECONDS + if seconds < MIN_DURATION_SECONDS: + return MIN_DURATION_SECONDS + if seconds > MAX_DURATION_SECONDS: + return MAX_DURATION_SECONDS + return seconds + + +@dataclass +class PlaylistItem: + """One step in a playlist: a scene preset held for ``duration_seconds``.""" + + scene_preset_id: str + duration_seconds: float = DEFAULT_DURATION_SECONDS + + def to_dict(self) -> dict: + return { + "scene_preset_id": self.scene_preset_id, + "duration_seconds": self.duration_seconds, + } + + @classmethod + def from_dict(cls, data: dict) -> "PlaylistItem": + return cls( + scene_preset_id=data["scene_preset_id"], + duration_seconds=clamp_duration(data.get("duration_seconds", DEFAULT_DURATION_SECONDS)), + ) + + +@dataclass +class ScenePlaylist: + """A named, ordered sequence of scene presets that auto-cycles.""" + + id: str + name: str + description: str = "" + items: List[PlaylistItem] = field(default_factory=list) + # When True, restart from the first item after the last one finishes. + # When False, the playlist stops (and leaves the last scene applied). + loop: bool = True + # When True, the item order is shuffled at the start of every cycle. + shuffle: bool = False + tags: List[str] = field(default_factory=list) + # Custom card icon (frontend display only) + icon: str = "" + icon_color: str = "" + order: int = 0 + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict: + d = { + "id": self.id, + "name": self.name, + "description": self.description, + "items": [i.to_dict() for i in self.items], + "loop": self.loop, + "shuffle": self.shuffle, + "tags": self.tags, + "order": self.order, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + if self.icon: + d["icon"] = self.icon + if self.icon_color: + d["icon_color"] = self.icon_color + return d + + @classmethod + def from_dict(cls, data: dict) -> "ScenePlaylist": + return cls( + id=data["id"], + name=data["name"], + description=data.get("description", ""), + items=[PlaylistItem.from_dict(i) for i in data.get("items", [])], + loop=data.get("loop", True), + shuffle=data.get("shuffle", False), + tags=data.get("tags", []), + icon=data.get("icon", ""), + icon_color=data.get("icon_color", ""), + order=data.get("order", 0), + created_at=datetime.fromisoformat( + data.get("created_at", datetime.now(timezone.utc).isoformat()) + ), + updated_at=datetime.fromisoformat( + data.get("updated_at", datetime.now(timezone.utc).isoformat()) + ), + ) diff --git a/server/src/ledgrab/storage/scene_playlist_store.py b/server/src/ledgrab/storage/scene_playlist_store.py new file mode 100644 index 0000000..fcefdeb --- /dev/null +++ b/server/src/ledgrab/storage/scene_playlist_store.py @@ -0,0 +1,82 @@ +"""Scene playlist storage using SQLite.""" + +from datetime import datetime, timezone +from typing import List + +from ledgrab.storage.base_sqlite_store import BaseSqliteStore +from ledgrab.storage.database import Database +from ledgrab.storage.scene_playlist import PlaylistItem, ScenePlaylist +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + + +class ScenePlaylistStore(BaseSqliteStore[ScenePlaylist]): + """Persistent storage for scene playlists.""" + + _table_name = "scene_playlists" + _entity_name = "Scene playlist" + _cloneable = True + + def __init__(self, db: Database): + super().__init__(db, ScenePlaylist.from_dict) + + # Backward-compatible aliases + get_playlist = BaseSqliteStore.get + delete_playlist = BaseSqliteStore.delete + + def get_all_playlists(self) -> List[ScenePlaylist]: + """Get all playlists sorted by order field.""" + return sorted(self._items.values(), key=lambda p: p.order) + + # Override get_all to also sort by order for consistency + def get_all(self) -> List[ScenePlaylist]: + return self.get_all_playlists() + + def create_playlist(self, playlist: ScenePlaylist) -> ScenePlaylist: + self._check_name_unique(playlist.name) + + self._items[playlist.id] = playlist + self._save_item(playlist.id, playlist) + logger.info(f"Created scene playlist: {playlist.name} ({playlist.id})") + return playlist + + def update_playlist( + self, + playlist_id: str, + name: str | None = None, + description: str | None = None, + items: List[PlaylistItem] | None = None, + loop: bool | None = None, + shuffle: bool | None = None, + order: int | None = None, + tags: List[str] | None = None, + icon: str | None = None, + icon_color: str | None = None, + ) -> ScenePlaylist: + playlist = self.get(playlist_id) + + if name is not None: + self._check_name_unique(name, exclude_id=playlist_id) + playlist.name = name + if description is not None: + playlist.description = description + if items is not None: + playlist.items = items + if loop is not None: + playlist.loop = loop + if shuffle is not None: + playlist.shuffle = shuffle + if order is not None: + playlist.order = order + if tags is not None: + playlist.tags = tags + if icon is not None: + playlist.icon = icon or "" + if icon_color is not None: + playlist.icon_color = icon_color or "" + + playlist.updated_at = datetime.now(timezone.utc) + self._save_item(playlist_id, playlist) + logger.info(f"Updated scene playlist: {playlist_id}") + return playlist diff --git a/server/src/ledgrab/templates/index.html b/server/src/ledgrab/templates/index.html index 2b30a47..25e1f45 100644 --- a/server/src/ledgrab/templates/index.html +++ b/server/src/ledgrab/templates/index.html @@ -246,6 +246,7 @@ {% include 'modals/cspt-modal.html' %} {% include 'modals/automation-editor.html' %} {% include 'modals/scene-preset-editor.html' %} + {% include 'modals/scene-playlist-editor.html' %} {% include 'modals/audio-source-editor.html' %} {% include 'modals/test-audio-source.html' %} {% include 'modals/audio-template.html' %} diff --git a/server/src/ledgrab/templates/modals/scene-playlist-editor.html b/server/src/ledgrab/templates/modals/scene-playlist-editor.html new file mode 100644 index 0000000..acc9bc0 --- /dev/null +++ b/server/src/ledgrab/templates/modals/scene-playlist-editor.html @@ -0,0 +1,107 @@ + + diff --git a/server/tests/api/routes/test_scene_playlists_routes.py b/server/tests/api/routes/test_scene_playlists_routes.py new file mode 100644 index 0000000..3fc5c04 --- /dev/null +++ b/server/tests/api/routes/test_scene_playlists_routes.py @@ -0,0 +1,296 @@ +"""Tests for scene-playlist CRUD + cycling-control routes. + +The PlaylistEngine is replaced with a lightweight fake so the route layer is +tested without driving the real asyncio cycling loop. +""" + +from datetime import datetime, timezone + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from ledgrab.api import dependencies as deps +from ledgrab.api.routes.scene_playlists import router +from ledgrab.core.scenes.playlist_engine import PlaylistError +from ledgrab.storage.scene_playlist_store import ScenePlaylistStore +from ledgrab.storage.scene_preset import ScenePreset +from ledgrab.storage.scene_preset_store import ScenePresetStore + + +class FakePlaylistEngine: + """Minimal stand-in matching the methods the routes call.""" + + def __init__(self, store): + self._store = store + self._running_id = None + self.start_calls = [] + self.stop_calls = 0 + + def get_running_playlist_id(self): + return self._running_id + + def get_state(self): + if self._running_id is None: + return { + "is_running": False, + "playlist_id": None, + "playlist_name": None, + "current_index": 0, + "item_count": 0, + "current_preset_id": None, + "started_at": None, + "step_started_at": None, + "step_duration": 0.0, + } + return { + "is_running": True, + "playlist_id": self._running_id, + "playlist_name": "running", + "current_index": 0, + "item_count": 1, + "current_preset_id": "scene_a", + "started_at": datetime.now(timezone.utc).isoformat(), + "step_started_at": datetime.now(timezone.utc).isoformat(), + "step_duration": 30.0, + } + + async def start_playlist(self, playlist_id): + self.start_calls.append(playlist_id) + pl = self._store.get_playlist(playlist_id) + if not pl.items: + raise PlaylistError("empty") + self._running_id = playlist_id + + async def stop(self): + self.stop_calls += 1 + self._running_id = None + + async def stop_if_running(self, playlist_id): + if self._running_id == playlist_id: + self._running_id = None + + +@pytest.fixture +def _route_db(tmp_path): + from ledgrab.storage.database import Database + + db = Database(tmp_path / "test.db") + yield db + db.close() + + +@pytest.fixture +def preset_store(_route_db) -> ScenePresetStore: + store = ScenePresetStore(_route_db) + now = datetime.now(timezone.utc) + for sid, name in [("scene_a", "A"), ("scene_b", "B")]: + store.create_preset( + ScenePreset(id=sid, name=name, targets=[], created_at=now, updated_at=now) + ) + return store + + +@pytest.fixture +def playlist_store(_route_db) -> ScenePlaylistStore: + return ScenePlaylistStore(_route_db) + + +@pytest.fixture +def fake_engine(playlist_store): + return FakePlaylistEngine(playlist_store) + + +@pytest.fixture +def client(playlist_store, preset_store, fake_engine): + app = FastAPI() + app.include_router(router) + + from ledgrab.api.auth import verify_api_key + + app.dependency_overrides[verify_api_key] = lambda: "test-user" + app.dependency_overrides[deps.get_scene_playlist_store] = lambda: playlist_store + app.dependency_overrides[deps.get_scene_preset_store] = lambda: preset_store + app.dependency_overrides[deps.get_playlist_engine] = lambda: fake_engine + # Routes fire entity events through the processor manager; give it a stub. + deps._deps["processor_manager"] = None + + return TestClient(app, raise_server_exceptions=False) + + +def _create(client, name="P1", items=None, **extra): + body = {"name": name, "items": items if items is not None else [], **extra} + return client.post("/api/v1/scene-playlists", json=body) + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + + +class TestCreate: + def test_create_minimal(self, client): + resp = _create(client, "Empty OK") + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Empty OK" + assert data["loop"] is True + assert data["is_running"] is False + assert data["id"].startswith("playlist_") + + def test_create_with_items(self, client): + resp = _create( + client, + "With items", + items=[ + {"scene_preset_id": "scene_a", "duration_seconds": 15}, + {"scene_preset_id": "scene_b", "duration_seconds": 45}, + ], + loop=False, + shuffle=True, + ) + assert resp.status_code == 201 + data = resp.json() + assert len(data["items"]) == 2 + assert data["loop"] is False + assert data["shuffle"] is True + + def test_create_rejects_unknown_preset(self, client): + resp = _create( + client, "Bad ref", items=[{"scene_preset_id": "ghost", "duration_seconds": 10}] + ) + assert resp.status_code == 400 + assert "ghost" in resp.json()["detail"] + + def test_create_rejects_below_min_duration(self, client): + resp = _create( + client, "Too fast", items=[{"scene_preset_id": "scene_a", "duration_seconds": 0}] + ) + # Pydantic ge=MIN validation → 422 + assert resp.status_code == 422 + + def test_create_duplicate_name(self, client): + _create(client, "Dup") + resp = _create(client, "Dup") + assert resp.status_code == 400 + + +class TestList: + def test_list_empty_includes_idle_state(self, client): + resp = client.get("/api/v1/scene-playlists") + assert resp.status_code == 200 + data = resp.json() + assert data["count"] == 0 + assert data["state"]["is_running"] is False + + def test_list_marks_running(self, client, fake_engine): + pid = _create( + client, "Runner", items=[{"scene_preset_id": "scene_a", "duration_seconds": 10}] + ).json()["id"] + fake_engine._running_id = pid + data = client.get("/api/v1/scene-playlists").json() + assert data["count"] == 1 + assert data["playlists"][0]["is_running"] is True + assert data["state"]["is_running"] is True + + +class TestGet: + def test_get_existing(self, client): + pid = _create(client, "Find me").json()["id"] + resp = client.get(f"/api/v1/scene-playlists/{pid}") + assert resp.status_code == 200 + assert resp.json()["name"] == "Find me" + + def test_get_missing(self, client): + resp = client.get("/api/v1/scene-playlists/nope") + assert resp.status_code == 404 + + def test_state_route_not_shadowed_by_id(self, client): + # The literal /state path must resolve to the state endpoint. + resp = client.get("/api/v1/scene-playlists/state") + assert resp.status_code == 200 + assert "is_running" in resp.json() + + +class TestUpdate: + def test_update_fields(self, client): + pid = _create(client, "Edit").json()["id"] + resp = client.put( + f"/api/v1/scene-playlists/{pid}", + json={ + "name": "Edited", + "loop": False, + "items": [{"scene_preset_id": "scene_b", "duration_seconds": 20}], + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "Edited" + assert data["loop"] is False + assert data["items"][0]["scene_preset_id"] == "scene_b" + + def test_update_rejects_unknown_preset(self, client): + pid = _create(client, "Edit2").json()["id"] + resp = client.put( + f"/api/v1/scene-playlists/{pid}", + json={"items": [{"scene_preset_id": "ghost", "duration_seconds": 10}]}, + ) + assert resp.status_code == 400 + + def test_update_missing(self, client): + resp = client.put("/api/v1/scene-playlists/nope", json={"name": "x"}) + assert resp.status_code == 404 + + +class TestDelete: + def test_delete(self, client): + pid = _create(client, "Goner").json()["id"] + resp = client.delete(f"/api/v1/scene-playlists/{pid}") + assert resp.status_code == 204 + assert client.get(f"/api/v1/scene-playlists/{pid}").status_code == 404 + + def test_delete_stops_if_running(self, client, fake_engine): + pid = _create( + client, "Running goner", items=[{"scene_preset_id": "scene_a", "duration_seconds": 10}] + ).json()["id"] + fake_engine._running_id = pid + client.delete(f"/api/v1/scene-playlists/{pid}") + assert fake_engine.get_running_playlist_id() is None + + def test_delete_missing(self, client): + assert client.delete("/api/v1/scene-playlists/nope").status_code == 404 + + +# --------------------------------------------------------------------------- +# Cycling control +# --------------------------------------------------------------------------- + + +class TestControl: + def test_start(self, client, fake_engine): + pid = _create( + client, "Go", items=[{"scene_preset_id": "scene_a", "duration_seconds": 10}] + ).json()["id"] + resp = client.post(f"/api/v1/scene-playlists/{pid}/start") + assert resp.status_code == 200 + assert resp.json()["is_running"] is True + assert fake_engine.start_calls == [pid] + + def test_start_missing_playlist(self, client): + resp = client.post("/api/v1/scene-playlists/nope/start") + assert resp.status_code == 404 + + def test_start_empty_playlist_400(self, client): + pid = _create(client, "EmptyGo").json()["id"] + resp = client.post(f"/api/v1/scene-playlists/{pid}/start") + assert resp.status_code == 400 + + def test_stop(self, client, fake_engine): + pid = _create( + client, "Stoppable", items=[{"scene_preset_id": "scene_a", "duration_seconds": 10}] + ).json()["id"] + fake_engine._running_id = pid + resp = client.post("/api/v1/scene-playlists/stop") + assert resp.status_code == 200 + assert resp.json()["is_running"] is False + assert fake_engine.stop_calls == 1 diff --git a/server/tests/core/test_playlist_engine.py b/server/tests/core/test_playlist_engine.py new file mode 100644 index 0000000..6c9c039 --- /dev/null +++ b/server/tests/core/test_playlist_engine.py @@ -0,0 +1,317 @@ +"""Tests for PlaylistEngine — the scene-playlist auto-cycling runtime. + +The engine dwells on each scene for ``MIN_DURATION_SECONDS`` (1s) at minimum, +so these tests patch ``asyncio.sleep`` to a tiny real delay (``fast_sleep``) +to keep the cycling deterministic and fast. A captured ``_REAL_SLEEP`` is used +for the test's own real-time waits so they aren't shortened by the patch. +""" + +import asyncio +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from ledgrab.core.scenes.playlist_engine import PlaylistEngine, PlaylistError +from ledgrab.storage.scene_playlist import PlaylistItem, ScenePlaylist +from ledgrab.storage.scene_playlist_store import ScenePlaylistStore +from ledgrab.storage.scene_preset import ScenePreset +from ledgrab.storage.scene_preset_store import ScenePresetStore + +_REAL_SLEEP = asyncio.sleep + + +@pytest.fixture +def fast_sleep(): + """Patch the engine's dwell sleep to a tiny real delay.""" + + async def _fast(_duration): + await _REAL_SLEEP(0.005) + + with patch("asyncio.sleep", _fast): + yield + + +@pytest.fixture +def preset_store(tmp_db) -> ScenePresetStore: + store = ScenePresetStore(tmp_db) + for sid, name in [("scene_a", "A"), ("scene_b", "B"), ("scene_c", "C")]: + now = datetime.now(timezone.utc) + store.create_preset( + ScenePreset(id=sid, name=name, targets=[], created_at=now, updated_at=now) + ) + return store + + +@pytest.fixture +def playlist_store(tmp_db) -> ScenePlaylistStore: + return ScenePlaylistStore(tmp_db) + + +@pytest.fixture +def applied(): + """A list that records the preset ids the engine applies, in order.""" + return [] + + +@pytest.fixture +def engine(playlist_store, preset_store, applied): + manager = MagicMock() + manager.fire_event = MagicMock() + target_store = MagicMock() + + eng = PlaylistEngine( + playlist_store=playlist_store, + scene_preset_store=preset_store, + target_store=target_store, + processor_manager=manager, + ) + + async def _fake_apply(preset, _ts, _mgr): + applied.append(preset.id) + return ("activated", []) + + # apply_scene_state is imported lazily inside the engine, so patch it at + # its definition site with a plain async function (unambiguous awaiting). + patcher = patch( + "ledgrab.core.scenes.scene_activator.apply_scene_state", + new=_fake_apply, + ) + patcher.start() + eng._apply_patcher = patcher # keep a handle for teardown + yield eng + patcher.stop() + + +def _make_playlist(store, name, item_specs, **kwargs) -> ScenePlaylist: + import uuid + + items = [PlaylistItem(sid, dur) for sid, dur in item_specs] + pl = ScenePlaylist( + id=f"playlist_{uuid.uuid4().hex[:8]}", + name=name, + items=items, + order=store.count(), + **kwargs, + ) + return store.create_playlist(pl) + + +async def _drain(engine, timeout=2.0): + """Await the engine's current cycling task (for non-loop playlists).""" + task = engine._task + if task is not None: + await asyncio.wait_for(asyncio.shield(task), timeout=timeout) + + +# --------------------------------------------------------------------------- +# Start validation +# --------------------------------------------------------------------------- + + +class TestStartValidation: + async def test_start_unknown_raises(self, engine): + with pytest.raises(PlaylistError): + await engine.start_playlist("missing") + + async def test_start_empty_playlist_raises(self, engine, playlist_store): + pl = _make_playlist(playlist_store, "Empty", []) + with pytest.raises(PlaylistError): + await engine.start_playlist(pl.id) + assert engine.is_running() is False + + async def test_initial_state_set_on_start(self, engine, playlist_store, fast_sleep): + pl = _make_playlist(playlist_store, "Loopy", [("scene_a", 50), ("scene_b", 50)], loop=True) + state = await engine.start_playlist(pl.id) + try: + assert state.current_index == 0 + assert state.current_preset_id == "scene_a" + s = engine.get_state() + assert s["is_running"] is True + assert s["playlist_id"] == pl.id + assert s["item_count"] == 2 + finally: + await engine.stop() + + +# --------------------------------------------------------------------------- +# Cycling behaviour +# --------------------------------------------------------------------------- + + +class TestCycling: + async def test_non_loop_applies_all_in_order_then_idle( + self, engine, playlist_store, applied, fast_sleep + ): + pl = _make_playlist( + playlist_store, + "Once", + [("scene_a", 50), ("scene_b", 50), ("scene_c", 50)], + loop=False, + ) + await engine.start_playlist(pl.id) + await _drain(engine) + assert applied == ["scene_a", "scene_b", "scene_c"] + assert engine.is_running() is False + assert engine.get_state()["is_running"] is False + + async def test_loop_keeps_cycling_until_stopped( + self, engine, playlist_store, applied, fast_sleep + ): + pl = _make_playlist( + playlist_store, "Forever", [("scene_a", 50), ("scene_b", 50)], loop=True + ) + await engine.start_playlist(pl.id) + await _REAL_SLEEP(0.05) + assert engine.is_running() is True + await engine.stop() + assert engine.is_running() is False + # Looped at least past the first pass. + assert len(applied) >= 3 + assert applied[0] == "scene_a" + + async def test_missing_preset_is_skipped(self, engine, playlist_store, applied, fast_sleep): + pl = _make_playlist( + playlist_store, + "Mixed", + [("scene_a", 50), ("ghost", 50), ("scene_b", 50)], + loop=False, + ) + await engine.start_playlist(pl.id) + await _drain(engine) + assert applied == ["scene_a", "scene_b"] + + async def test_all_missing_with_loop_stops(self, engine, playlist_store, applied): + pl = _make_playlist(playlist_store, "Dead", [("ghost1", 50), ("ghost2", 50)], loop=True) + await engine.start_playlist(pl.id) + await _drain(engine) # guard should break the loop immediately + assert applied == [] + assert engine.is_running() is False + + async def test_shuffle_uses_random_order(self, engine, playlist_store, applied, fast_sleep): + pl = _make_playlist( + playlist_store, + "Shuffled", + [("scene_a", 50), ("scene_b", 50), ("scene_c", 50)], + loop=False, + shuffle=True, + ) + + def _reverse(seq): + seq.reverse() + + with patch("ledgrab.core.scenes.playlist_engine.random.shuffle", side_effect=_reverse): + await engine.start_playlist(pl.id) + await _drain(engine) + assert applied == ["scene_c", "scene_b", "scene_a"] + + +# --------------------------------------------------------------------------- +# Single-playlist exclusivity + stop helpers +# --------------------------------------------------------------------------- + + +class TestExclusivityAndStop: + async def test_starting_second_playlist_replaces_first( + self, engine, playlist_store, fast_sleep + ): + a = _make_playlist(playlist_store, "A", [("scene_a", 50)], loop=True) + b = _make_playlist(playlist_store, "B", [("scene_b", 50)], loop=True) + await engine.start_playlist(a.id) + await engine.start_playlist(b.id) + try: + assert engine.get_running_playlist_id() == b.id + finally: + await engine.stop() + + async def test_stop_when_idle_is_noop(self, engine): + await engine.stop() # should not raise + assert engine.is_running() is False + + async def test_stop_if_running_only_matching(self, engine, playlist_store, fast_sleep): + a = _make_playlist(playlist_store, "A", [("scene_a", 50)], loop=True) + await engine.start_playlist(a.id) + await engine.stop_if_running("some-other-id") + assert engine.is_running() is True + await engine.stop_if_running(a.id) + assert engine.is_running() is False + + async def test_get_state_idle_shape(self, engine): + s = engine.get_state() + assert s["is_running"] is False + assert s["playlist_id"] is None + assert s["current_index"] == 0 + + +# --------------------------------------------------------------------------- +# Event firing — the playlist_state_changed contract the frontend WS layer +# (events-ws.ts allowlist + scene-playlists.ts listener) depends on. A dropped +# event here would silently freeze the UI's running indicator, yet the +# is_running()/ordering assertions above would stay green — so assert the +# fire_event payloads directly. +# --------------------------------------------------------------------------- + + +def _fired_events(engine) -> list: + """All payloads passed to processor_manager.fire_event, in order.""" + return [c.args[0] for c in engine._manager.fire_event.call_args_list] + + +def _fired_actions(engine) -> list: + return [e.get("action") for e in _fired_events(engine)] + + +class TestEvents: + async def test_start_fires_started_event(self, engine, playlist_store, fast_sleep): + pl = _make_playlist(playlist_store, "Started", [("scene_a", 50)], loop=True) + await engine.start_playlist(pl.id) + try: + started = [e for e in _fired_events(engine) if e.get("action") == "started"] + assert len(started) == 1 + assert started[0]["type"] == "playlist_state_changed" + assert started[0]["playlist_id"] == pl.id + finally: + await engine.stop() + + async def test_non_loop_completion_fires_final_stopped( + self, engine, playlist_store, fast_sleep + ): + pl = _make_playlist( + playlist_store, "Finishes", [("scene_a", 50), ("scene_b", 50)], loop=False + ) + await engine.start_playlist(pl.id) + await _drain(engine) + + actions = _fired_actions(engine) + assert actions[0] == "started" + assert actions[-1] == "stopped" + # The natural-completion 'stopped' must carry the playlist id even + # though _run clears _state to None before firing (ended_id capture). + stopped = [e for e in _fired_events(engine) if e.get("action") == "stopped"] + assert stopped and stopped[-1]["playlist_id"] == pl.id + + async def test_advanced_fires_per_applied_item_only(self, engine, playlist_store, fast_sleep): + pl = _make_playlist( + playlist_store, + "Mixed", + [("scene_a", 50), ("ghost", 50), ("scene_b", 50)], + loop=False, + ) + await engine.start_playlist(pl.id) + await _drain(engine) + + advanced = [e for e in _fired_events(engine) if e.get("action") == "advanced"] + # Only the two real presets advance; the missing one fires nothing. + assert [e["preset_id"] for e in advanced] == ["scene_a", "scene_b"] + assert [e["index"] for e in advanced] == [0, 2] + + async def test_explicit_stop_fires_stopped(self, engine, playlist_store, fast_sleep): + pl = _make_playlist(playlist_store, "Stopper", [("scene_a", 50)], loop=True) + await engine.start_playlist(pl.id) + await engine.stop() + stopped = [e for e in _fired_events(engine) if e.get("action") == "stopped"] + assert stopped and stopped[-1]["playlist_id"] == pl.id + + async def test_stop_when_idle_fires_nothing(self, engine): + await engine.stop() + assert _fired_actions(engine) == [] diff --git a/server/tests/storage/test_scene_playlist_store.py b/server/tests/storage/test_scene_playlist_store.py new file mode 100644 index 0000000..19238d6 --- /dev/null +++ b/server/tests/storage/test_scene_playlist_store.py @@ -0,0 +1,189 @@ +"""Tests for ScenePlaylist model + ScenePlaylistStore.""" + +import pytest + +from ledgrab.storage.base_store import EntityNotFoundError +from ledgrab.storage.scene_playlist import ( + DEFAULT_DURATION_SECONDS, + MAX_DURATION_SECONDS, + MIN_DURATION_SECONDS, + PlaylistItem, + ScenePlaylist, + clamp_duration, +) +from ledgrab.storage.scene_playlist_store import ScenePlaylistStore + + +@pytest.fixture +def store(tmp_db) -> ScenePlaylistStore: + return ScenePlaylistStore(tmp_db) + + +def _make_playlist(store: ScenePlaylistStore, name="My Playlist", **kwargs) -> ScenePlaylist: + import uuid + + items = kwargs.pop("items", [PlaylistItem("scene_aaa", 10.0)]) + pl = ScenePlaylist( + id=f"playlist_{uuid.uuid4().hex[:8]}", + name=name, + items=items, + order=store.count(), + **kwargs, + ) + return store.create_playlist(pl) + + +# --------------------------------------------------------------------------- +# clamp_duration +# --------------------------------------------------------------------------- + + +class TestClampDuration: + def test_within_range_unchanged(self): + assert clamp_duration(42.5) == 42.5 + + def test_below_floor_clamped(self): + assert clamp_duration(0) == MIN_DURATION_SECONDS + assert clamp_duration(-5) == MIN_DURATION_SECONDS + + def test_above_ceiling_clamped(self): + assert clamp_duration(MAX_DURATION_SECONDS + 1000) == MAX_DURATION_SECONDS + + def test_non_numeric_returns_default(self): + assert clamp_duration("oops") == DEFAULT_DURATION_SECONDS + assert clamp_duration(None) == DEFAULT_DURATION_SECONDS + + +# --------------------------------------------------------------------------- +# Model serialization round-trip +# --------------------------------------------------------------------------- + + +class TestModelSerialization: + def test_round_trip_preserves_fields(self): + pl = ScenePlaylist( + id="playlist_1", + name="Cycle", + description="desc", + items=[PlaylistItem("a", 5.0), PlaylistItem("b", 15.0)], + loop=False, + shuffle=True, + tags=["movie"], + icon="play", + icon_color="#fff", + order=3, + ) + restored = ScenePlaylist.from_dict(pl.to_dict()) + assert restored.id == "playlist_1" + assert restored.name == "Cycle" + assert restored.description == "desc" + assert restored.loop is False + assert restored.shuffle is True + assert restored.tags == ["movie"] + assert restored.icon == "play" + assert restored.icon_color == "#fff" + assert restored.order == 3 + assert [(i.scene_preset_id, i.duration_seconds) for i in restored.items] == [ + ("a", 5.0), + ("b", 15.0), + ] + + def test_from_dict_clamps_bad_duration(self): + data = { + "id": "playlist_x", + "name": "X", + "items": [{"scene_preset_id": "a", "duration_seconds": 0}], + } + restored = ScenePlaylist.from_dict(data) + assert restored.items[0].duration_seconds == MIN_DURATION_SECONDS + + def test_to_dict_omits_empty_icon(self): + pl = ScenePlaylist(id="p", name="n") + d = pl.to_dict() + assert "icon" not in d + assert "icon_color" not in d + assert d["loop"] is True + assert d["shuffle"] is False + + +# --------------------------------------------------------------------------- +# Store CRUD +# --------------------------------------------------------------------------- + + +class TestStoreCrud: + def test_create_and_get(self, store): + pl = _make_playlist(store, "First") + fetched = store.get_playlist(pl.id) + assert fetched.name == "First" + + def test_create_duplicate_name_rejected(self, store): + _make_playlist(store, "Dup") + with pytest.raises(ValueError): + _make_playlist(store, "Dup") + + def test_create_empty_name_rejected(self, store): + with pytest.raises(ValueError): + _make_playlist(store, " ") + + def test_get_missing_raises(self, store): + with pytest.raises(EntityNotFoundError): + store.get_playlist("nope") + + def test_get_all_sorted_by_order(self, store): + _make_playlist(store, "A") # order 0 + _make_playlist(store, "B") # order 1 + _make_playlist(store, "C") # order 2 + names = [p.name for p in store.get_all_playlists()] + assert names == ["A", "B", "C"] + + def test_update_fields(self, store): + pl = _make_playlist(store, "Edit me") + updated = store.update_playlist( + pl.id, + name="Edited", + loop=False, + shuffle=True, + items=[PlaylistItem("x", 20.0)], + tags=["t"], + ) + assert updated.name == "Edited" + assert updated.loop is False + assert updated.shuffle is True + assert updated.items[0].scene_preset_id == "x" + assert updated.tags == ["t"] + + def test_update_duplicate_name_rejected(self, store): + _make_playlist(store, "Taken") + pl = _make_playlist(store, "Other") + with pytest.raises(ValueError): + store.update_playlist(pl.id, name="Taken") + + def test_update_missing_raises(self, store): + with pytest.raises(EntityNotFoundError): + store.update_playlist("nope", name="x") + + def test_delete(self, store): + pl = _make_playlist(store, "Goner") + store.delete_playlist(pl.id) + assert store.count() == 0 + with pytest.raises(EntityNotFoundError): + store.get_playlist(pl.id) + + def test_persistence_across_reload(self, tmp_db): + store1 = ScenePlaylistStore(tmp_db) + _make_playlist(store1, "Persisted", items=[PlaylistItem("s1", 12.0)]) + # New store instance over the same DB reloads from SQLite. + store2 = ScenePlaylistStore(tmp_db) + all_pls = store2.get_all_playlists() + assert len(all_pls) == 1 + assert all_pls[0].name == "Persisted" + assert all_pls[0].items[0].duration_seconds == 12.0 + + def test_clone_supported(self, store): + pl = _make_playlist(store, "Original") + clone = store.clone(pl.id, "Copy") + assert clone.name == "Copy" + assert clone.id != pl.id + assert clone.id.startswith("playlist_") + assert store.count() == 2