feat(scenes): scene playlists with timed auto-cycling

Add ordered, timed sequences of scene presets that auto-cycle — activating
each preset and holding it for its dwell duration before advancing.

Backend:
- ScenePlaylist / PlaylistItem models + SQLite store (new scene_playlists table)
- PlaylistEngine: cycles ONE playlist at a time (starting one stops any other),
  loop/shuffle, re-reads the playlist each cycle so edits/deletes apply at the
  boundary, skips missing presets, guards against busy-loops; reuses the shared
  apply_scene_state path used by scene presets and automations
- REST API: CRUD + /start, /stop, /state with scene-preset reference validation
- Constructed in the app lifespan with a bounded stop on shutdown

Frontend:
- New "Playlists" sub-tab in the Automations tab with start/stop controls and a
  running indicator; editor modal with ordered scene rows (reorder + per-item
  duration), loop/shuffle toggles, and tags
- Live refresh via the playlist_state_changed WebSocket event
- i18n in en/ru/zh

Tests: new unit + API coverage for the store/model, engine (cycling,
single-active exclusivity, missing-preset skip, shuffle, and the
playlist_state_changed event contract), and routes. Full suite green;
ruff and tsc clean.
This commit is contained in:
2026-06-08 13:48:43 +03:00
parent ca59546711
commit f71e10ee06
27 changed files with 2739 additions and 6 deletions
+20
View File
@@ -42,6 +42,7 @@ Complete REST + WebSocket API reference for the LedGrab server.
- [Weather sources](#weather-sources) - [Weather sources](#weather-sources)
- [Automations](#automations) - [Automations](#automations)
- [Scene presets](#scene-presets) - [Scene presets](#scene-presets)
- [Scene playlists](#scene-playlists)
- [Sync clocks](#sync-clocks) - [Sync clocks](#sync-clocks)
- [Webhooks](#webhooks) - [Webhooks](#webhooks)
- [HTTP endpoints](#http-endpoints) - [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}/recapture` | Re-capture current state into the preset. |
| POST | `/api/v1/scene-presets/{preset_id}/activate` | Activate the preset (restore captured state). | | 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 ## Sync clocks
Shared clocks that drive linked animations with configurable speed. Shared clocks that drive linked animations with configurable speed.
+2
View File
@@ -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.value_sources import router as value_sources_router
from .routes.automations import router as automations_router from .routes.automations import router as automations_router
from .routes.scene_presets import router as scene_presets_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.webhooks import router as webhooks_router
from .routes.sync_clocks import router as sync_clocks_router from .routes.sync_clocks import router as sync_clocks_router
from .routes.color_strip_processing import router as cspt_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(output_targets_control_router)
router.include_router(automations_router) router.include_router(automations_router)
router.include_router(scene_presets_router) router.include_router(scene_presets_router)
router.include_router(scene_playlists_router)
router.include_router(webhooks_router) router.include_router(webhooks_router)
router.include_router(sync_clocks_router) router.include_router(sync_clocks_router)
router.include_router(cspt_router) router.include_router(cspt_router)
+14
View File
@@ -19,6 +19,7 @@ from ledgrab.storage.audio_template_store import AudioTemplateStore
from ledgrab.storage.value_source_store import ValueSourceStore from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.storage.automation_store import AutomationStore from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore 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.sync_clock_store import SyncClockStore
from ledgrab.storage.color_strip_processing_template_store import ( from ledgrab.storage.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore, ColorStripProcessingTemplateStore,
@@ -27,6 +28,7 @@ from ledgrab.storage.gradient_store import GradientStore
from ledgrab.storage.weather_source_store import WeatherSourceStore from ledgrab.storage.weather_source_store import WeatherSourceStore
from ledgrab.storage.asset_store import AssetStore from ledgrab.storage.asset_store import AssetStore
from ledgrab.core.automations.automation_engine import AutomationEngine 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.weather.weather_manager import WeatherManager
from ledgrab.core.backup.auto_backup import AutoBackupEngine from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.core.processing.sync_clock_manager import SyncClockManager from ledgrab.core.processing.sync_clock_manager import SyncClockManager
@@ -110,6 +112,14 @@ def get_automation_engine() -> AutomationEngine:
return _get("automation_engine", "Automation engine") 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: def get_auto_backup_engine() -> AutoBackupEngine:
return _get("auto_backup_engine", "Auto-backup engine") return _get("auto_backup_engine", "Auto-backup engine")
@@ -226,7 +236,9 @@ def init_dependencies(
value_source_store: ValueSourceStore | None = None, value_source_store: ValueSourceStore | None = None,
automation_store: AutomationStore | None = None, automation_store: AutomationStore | None = None,
scene_preset_store: ScenePresetStore | None = None, scene_preset_store: ScenePresetStore | None = None,
scene_playlist_store: ScenePlaylistStore | None = None,
automation_engine: AutomationEngine | None = None, automation_engine: AutomationEngine | None = None,
playlist_engine: PlaylistEngine | None = None,
auto_backup_engine: AutoBackupEngine | None = None, auto_backup_engine: AutoBackupEngine | None = None,
sync_clock_store: SyncClockStore | None = None, sync_clock_store: SyncClockStore | None = None,
sync_clock_manager: SyncClockManager | None = None, sync_clock_manager: SyncClockManager | None = None,
@@ -262,7 +274,9 @@ def init_dependencies(
"value_source_store": value_source_store, "value_source_store": value_source_store,
"automation_store": automation_store, "automation_store": automation_store,
"scene_preset_store": scene_preset_store, "scene_preset_store": scene_preset_store,
"scene_playlist_store": scene_playlist_store,
"automation_engine": automation_engine, "automation_engine": automation_engine,
"playlist_engine": playlist_engine,
"auto_backup_engine": auto_backup_engine, "auto_backup_engine": auto_backup_engine,
"sync_clock_store": sync_clock_store, "sync_clock_store": sync_clock_store,
"sync_clock_manager": sync_clock_manager, "sync_clock_manager": sync_clock_manager,
@@ -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())
@@ -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
@@ -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)
+17
View File
@@ -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.value_source_store import ValueSourceStore
from ledgrab.storage.automation_store import AutomationStore from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore 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.sync_clock_store import SyncClockStore
from ledgrab.storage.color_strip_processing_template_store import ( from ledgrab.storage.color_strip_processing_template_store import (
ColorStripProcessingTemplateStore, ColorStripProcessingTemplateStore,
@@ -47,6 +48,7 @@ from ledgrab.core.weather.weather_manager import WeatherManager
from ledgrab.storage.home_assistant_store import HomeAssistantStore from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.core.automations.automation_engine import AutomationEngine 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.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus from ledgrab.core.game_integration.event_bus import GameEventBus
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters 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) value_source_store = ValueSourceStore(db)
automation_store = AutomationStore(db) automation_store = AutomationStore(db)
scene_preset_store = ScenePresetStore(db) scene_preset_store = ScenePresetStore(db)
scene_playlist_store = ScenePlaylistStore(db)
sync_clock_store = SyncClockStore(db) sync_clock_store = SyncClockStore(db)
cspt_store = ColorStripProcessingTemplateStore(db) cspt_store = ColorStripProcessingTemplateStore(db)
gradient_store = GradientStore(db) gradient_store = GradientStore(db)
@@ -278,6 +281,15 @@ async def lifespan(app: FastAPI):
value_source_store=value_source_store, 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 # Create auto-backup engine — derive paths from database location so that
# demo mode auto-backups go to data/demo/ instead of data/. # demo mode auto-backups go to data/demo/ instead of data/.
_data_dir = Path(config.storage.database_file).parent _data_dir = Path(config.storage.database_file).parent
@@ -314,7 +326,9 @@ async def lifespan(app: FastAPI):
value_source_store=value_source_store, value_source_store=value_source_store,
automation_store=automation_store, automation_store=automation_store,
scene_preset_store=scene_preset_store, scene_preset_store=scene_preset_store,
scene_playlist_store=scene_playlist_store,
automation_engine=automation_engine, automation_engine=automation_engine,
playlist_engine=playlist_engine,
auto_backup_engine=auto_backup_engine, auto_backup_engine=auto_backup_engine,
sync_clock_store=sync_clock_store, sync_clock_store=sync_clock_store,
sync_clock_manager=sync_clock_manager, sync_clock_manager=sync_clock_manager,
@@ -436,6 +450,9 @@ async def lifespan(app: FastAPI):
# would talk to processors mid-shutdown. # would talk to processors mid-shutdown.
await _bounded("automation_engine.stop", automation_engine.stop(), timeout=1.5) 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 # Stop discovery watcher and OS notification listener so they stop
# firing events into a shutting-down processor manager. # firing events into a shutting-down processor manager.
if discovery_watcher is not None: if discovery_watcher is not None:
@@ -1134,6 +1134,175 @@ textarea:focus-visible {
cursor: not-allowed; 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 (reusable type picker) ──────────────────────── */
.icon-select-trigger { .icon-select-trigger {
+16
View File
@@ -116,6 +116,11 @@ import {
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset, activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
addSceneTarget, addSceneTarget,
} from './features/scene-presets.ts'; } 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 // Layer 5: device-discovery, targets
import { import {
@@ -463,6 +468,17 @@ Object.assign(window, {
recaptureScenePreset, recaptureScenePreset,
addSceneTarget, addSceneTarget,
// scene playlists — modal buttons + mod-card inline handlers
openPlaylistEditor,
editPlaylist,
savePlaylist,
closePlaylistEditor,
clonePlaylist,
deletePlaylist,
addPlaylistItem,
startScenePlaylist,
stopScenePlaylist,
// integrations // integrations
loadIntegrations, loadIntegrations,
switchIntegrationTab, switchIntegrationTab,
@@ -8,7 +8,7 @@
import { import {
devicesCache, outputTargetsCache, colorStripSourcesCache, devicesCache, outputTargetsCache, colorStripSourcesCache,
streamsCache, audioSourcesCache, valueSourcesCache, streamsCache, audioSourcesCache, valueSourcesCache,
syncClocksCache, automationsCacheObj, scenePresetsCache, syncClocksCache, automationsCacheObj, scenePresetsCache, scenePlaylistsCache,
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache, captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
patternTemplatesCache, patternTemplatesCache,
weatherSourcesCache, haSourcesCache, mqttSourcesCache, weatherSourcesCache, haSourcesCache, mqttSourcesCache,
@@ -26,6 +26,7 @@ const ENTITY_CACHE_MAP = {
sync_clock: syncClocksCache, sync_clock: syncClocksCache,
automation: automationsCacheObj, automation: automationsCacheObj,
scene_preset: scenePresetsCache, scene_preset: scenePresetsCache,
scene_playlist: scenePlaylistsCache,
capture_template: captureTemplatesCache, capture_template: captureTemplatesCache,
audio_template: audioTemplatesCache, audio_template: audioTemplatesCache,
pp_template: ppTemplatesCache, pp_template: ppTemplatesCache,
@@ -51,6 +52,7 @@ const ENTITY_LOADER_MAP = {
pp_template: 'loadPictureSources', pp_template: 'loadPictureSources',
automation: 'loadAutomations', automation: 'loadAutomations',
scene_preset: 'loadAutomations', scene_preset: 'loadAutomations',
scene_playlist: 'loadAutomations',
weather_source: 'loadIntegrations', weather_source: 'loadIntegrations',
home_assistant_source: 'loadIntegrations', home_assistant_source: 'loadIntegrations',
mqtt_source: 'loadIntegrations', mqtt_source: 'loadIntegrations',
@@ -40,6 +40,7 @@ const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
'server_restarting', 'server_restarting',
'state_change', 'state_change',
'automation_state_changed', 'automation_state_changed',
'playlist_state_changed',
'entity_changed', 'entity_changed',
'device_health_changed', 'device_health_changed',
'update_available', 'update_available',
+6 -1
View File
@@ -15,7 +15,7 @@
import { DataCache } from './cache.ts'; import { DataCache } from './cache.ts';
import type { import type {
Device, OutputTarget, ColorStripSource, PatternTemplate, Device, OutputTarget, ColorStripSource, PatternTemplate,
ValueSource, AudioSource, PictureSource, ScenePreset, ValueSource, AudioSource, PictureSource, ScenePreset, ScenePlaylist,
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo, SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate, CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle, ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
@@ -436,6 +436,11 @@ export const scenePresetsCache = new DataCache<ScenePreset[]>({
extractData: json => json.presets || [], extractData: json => json.presets || [],
}); });
export const scenePlaylistsCache = new DataCache<ScenePlaylist[]>({
endpoint: '/scene-playlists',
extractData: json => json.playlists || [],
});
export interface GradientEntity { export interface GradientEntity {
id: string; id: string;
name: string; name: string;
@@ -4,7 +4,7 @@
import { import {
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
scenePresetsCache, _cachedHASources, haSourcesCache, scenePresetsCache, scenePlaylistsCache, _cachedHASources, haSourcesCache,
_cachedValueSources, valueSourcesCache, _cachedValueSources, valueSourcesCache,
getHAEntityFriendlyName, setHAEntityNames, getHAEntityFriendlyName, setHAEntityNames,
} from '../core/state.ts'; } from '../core/state.ts';
@@ -18,7 +18,7 @@ import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts'; import { CardSection } from '../core/card-sections.ts';
import { updateTabBadge, updateSubTabHash } from './tabs.ts'; import { updateTabBadge, updateSubTabHash } from './tabs.ts';
import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.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 * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts'; import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.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 { attachProcessPicker, attachAppPicker } from '../core/process-picker.ts';
import { TreeNav } from '../core/tree-nav.ts'; import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts'; import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import { csPlaylists, createPlaylistCard, initPlaylistDelegation } from './scene-playlists.ts';
import type { Automation, RuleType } from '../types.ts'; import type { Automation, RuleType } from '../types.ts';
registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({ registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({
@@ -252,6 +253,7 @@ export async function loadAutomations() {
const [automations, scenes] = await Promise.all([ const [automations, scenes] = await Promise.all([
automationsCacheObj.fetch(), automationsCacheObj.fetch(),
scenePresetsCache.fetch(), scenePresetsCache.fetch(),
scenePlaylistsCache.fetch(),
haSourcesCache.fetch(), haSourcesCache.fetch(),
valueSourcesCache.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 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 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 activeTab = getActiveSubTab('automations')!;
const treeItems = [ const treeItems = [
{ key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length }, { key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length },
{ key: 'scenes', icon: ICON_SCENE, titleKey: 'scenes.title', count: scenePresetsCache.data.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()) { if (csAutomations.isMounted()) {
_automationsTree.updateCounts({ _automationsTree.updateCounts({
automations: automations.length, automations: automations.length,
scenes: scenePresetsCache.data.length, scenes: scenePresetsCache.data.length,
playlists: scenePlaylistsCache.data.length,
}); });
csAutomations.reconcile(autoItems); csAutomations.reconcile(autoItems);
csScenes.reconcile(sceneItems); csScenes.reconcile(sceneItems);
csPlaylists.reconcile(playlistItems);
} else { } else {
const panels = [ const panels = [
{ key: 'automations', html: csAutomations.render(autoItems) }, { key: 'automations', html: csAutomations.render(autoItems) },
{ key: 'scenes', html: csScenes.render(sceneItems) }, { key: 'scenes', html: csScenes.render(sceneItems) },
{ key: 'playlists', html: csPlaylists.render(playlistItems) },
].map(p => `<div class="automation-sub-tab-panel stream-tab-panel${p.key === activeTab ? ' active' : ''}" id="automation-tab-${p.key}">${p.html}</div>`).join(''); ].map(p => `<div class="automation-sub-tab-panel stream-tab-panel${p.key === activeTab ? ' active' : ''}" id="automation-tab-${p.key}">${p.html}</div>`).join('');
container!.innerHTML = panels; 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!); initScenePresetDelegation(container!);
initPlaylistDelegation(container!);
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`); _automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_automationsTree.update(treeItems, activeTab); _automationsTree.update(treeItems, activeTab);
_automationsTree.observeSections('automations-content', { _automationsTree.observeSections('automations-content', {
'automations': 'automations', 'automations': 'automations',
'scenes': 'scenes', 'scenes': 'scenes',
'playlists': 'playlists',
}); });
} }
} }
@@ -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 = '<span class="scene-color-dot" style="background:#4fc3f7"></span>';
registerIconEntityType('scene_playlist', makeSimpleIconAdapter<ScenePlaylist>({
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<string, ScenePreset> = {};
// ── Scene-preset lookup helpers ──
async function _primePresets(): Promise<void> {
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 `
<div class="playlist-item-icon" aria-hidden="true">${_presetIconSvg(preset)}</div>
<div class="playlist-item-id">
<span class="playlist-item-name">${escapeHtml(_presetName(presetId))}</span>
<span class="playlist-item-type${missing}">${preset ? 'SCN' : (t('playlists.item.missing') || 'MISSING')}</span>
</div>
<div class="playlist-item-duration-wrap">
${ICON_CLOCK}
<input type="number" class="playlist-item-duration" min="${MIN_ITEM_DURATION}" step="1"
value="${Math.max(MIN_ITEM_DURATION, Math.round(duration))}"
aria-label="${escapeHtml(t('playlists.item.duration') || 'Seconds')}">
<span class="playlist-item-unit">s</span>
</div>
<div class="playlist-item-actions">
<button type="button" class="playlist-item-btn" data-action="playlist-item-up" title="${escapeHtml(upLabel)}" aria-label="${escapeHtml(upLabel)}">&#x2191;</button>
<button type="button" class="playlist-item-btn" data-action="playlist-item-down" title="${escapeHtml(downLabel)}" aria-label="${escapeHtml(downLabel)}">&#x2193;</button>
<button type="button" class="playlist-item-btn playlist-item-remove" data-action="playlist-item-remove" title="${escapeHtml(removeLabel)}" aria-label="${escapeHtml(removeLabel)}">${ICON_TRASH}</button>
</div>
`;
}
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}</div>`) : cardHtml;
}
export async function loadPlaylists(): Promise<ScenePlaylist[]> {
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<void> {
_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<void> {
await _openEditorWith({
editingId: null, name: '', description: '', loop: true, shuffle: false,
items: [], tags: [], titleKey: 'playlists.add',
});
}
export async function editPlaylist(playlistId: string): Promise<void> {
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<void> {
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<void> {
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<void> {
await playlistModal.close();
}
// ===== Item selector =====
export async function addPlaylistItem(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<string, (id: string) => 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<HTMLElement>('[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<HTMLElement>('[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();
});
+6
View File
@@ -108,6 +108,12 @@ export type {
ScenePreset, ScenePreset,
ScenePresetListResponse, ScenePresetListResponse,
} from './types/scene-preset.ts'; } from './types/scene-preset.ts';
export type {
PlaylistItem,
ScenePlaylist,
PlaylistRuntimeState,
ScenePlaylistListResponse,
} from './types/scene-playlist.ts';
// ── Sync Clock ──────────────────────────────────────────────── // ── Sync Clock ────────────────────────────────────────────────
export type { SyncClock, SyncClockListResponse } from './types/sync-clock.ts'; export type { SyncClock, SyncClockListResponse } from './types/sync-clock.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;
}
+46
View File
@@ -790,6 +790,7 @@
"device.icon.entity.ha_source": "Home Assistant source", "device.icon.entity.ha_source": "Home Assistant source",
"device.icon.entity.automation": "Automation", "device.icon.entity.automation": "Automation",
"device.icon.entity.scene_preset": "Scene preset", "device.icon.entity.scene_preset": "Scene preset",
"device.icon.entity.scene_playlist": "Playlist",
"device.icon.entity.sync_clock": "Sync clock", "device.icon.entity.sync_clock": "Sync clock",
"device.icon.entity.game_integration": "Game integration", "device.icon.entity.game_integration": "Game integration",
"device.icon.entity.audio_processing_template": "Audio processing template", "device.icon.entity.audio_processing_template": "Audio processing template",
@@ -1351,6 +1352,50 @@
"scenes.error.delete_failed": "Failed to delete scene", "scenes.error.delete_failed": "Failed to delete scene",
"scenes.cloned": "Scene cloned", "scenes.cloned": "Scene cloned",
"scenes.error.clone_failed": "Failed to clone scene", "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.led": "LED",
"dashboard.type.kc": "Key Colors", "dashboard.type.kc": "Key Colors",
"aria.close": "Close", "aria.close": "Close",
@@ -2690,6 +2735,7 @@
"section.empty.cspt": "No CSS processing templates yet. Click + to add one.", "section.empty.cspt": "No CSS processing templates yet. Click + to add one.",
"section.empty.automations": "No automations 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.scenes": "No scene presets yet. Click + to add one.",
"section.empty.playlists": "No playlists yet. Click + to add one.",
"bulk.select": "Select", "bulk.select": "Select",
"bulk.cancel": "Cancel", "bulk.cancel": "Cancel",
"bulk.selected_count.one": "{count} selected", "bulk.selected_count.one": "{count} selected",
+46
View File
@@ -847,6 +847,7 @@
"device.icon.entity.ha_source": "Источник Home Assistant", "device.icon.entity.ha_source": "Источник Home Assistant",
"device.icon.entity.automation": "Автоматизация", "device.icon.entity.automation": "Автоматизация",
"device.icon.entity.scene_preset": "Сцена", "device.icon.entity.scene_preset": "Сцена",
"device.icon.entity.scene_playlist": "Плейлист",
"device.icon.entity.sync_clock": "Часы синхронизации", "device.icon.entity.sync_clock": "Часы синхронизации",
"device.icon.entity.game_integration": "Игровая интеграция", "device.icon.entity.game_integration": "Игровая интеграция",
"device.icon.entity.audio_processing_template": "Шаблон обработки аудио", "device.icon.entity.audio_processing_template": "Шаблон обработки аудио",
@@ -1385,6 +1386,50 @@
"scenes.error.delete_failed": "Не удалось удалить сцену", "scenes.error.delete_failed": "Не удалось удалить сцену",
"scenes.cloned": "Сцена клонирована", "scenes.cloned": "Сцена клонирована",
"scenes.error.clone_failed": "Не удалось клонировать сцену", "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.led": "LED",
"dashboard.type.kc": "Цвета клавиш", "dashboard.type.kc": "Цвета клавиш",
"aria.close": "Закрыть", "aria.close": "Закрыть",
@@ -2371,6 +2416,7 @@
"section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.", "section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.",
"section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.", "section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.",
"section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.", "section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.",
"section.empty.playlists": "Плейлистов пока нет. Нажмите + для добавления.",
"bulk.select": "Выбрать", "bulk.select": "Выбрать",
"bulk.cancel": "Отмена", "bulk.cancel": "Отмена",
"bulk.selected_count.one": "{count} выбран", "bulk.selected_count.one": "{count} выбран",
+46
View File
@@ -843,6 +843,7 @@
"device.icon.entity.ha_source": "Home Assistant 源", "device.icon.entity.ha_source": "Home Assistant 源",
"device.icon.entity.automation": "自动化", "device.icon.entity.automation": "自动化",
"device.icon.entity.scene_preset": "场景预设", "device.icon.entity.scene_preset": "场景预设",
"device.icon.entity.scene_playlist": "播放列表",
"device.icon.entity.sync_clock": "同步时钟", "device.icon.entity.sync_clock": "同步时钟",
"device.icon.entity.game_integration": "游戏集成", "device.icon.entity.game_integration": "游戏集成",
"device.icon.entity.audio_processing_template": "音频处理模板", "device.icon.entity.audio_processing_template": "音频处理模板",
@@ -1381,6 +1382,50 @@
"scenes.error.delete_failed": "删除场景失败", "scenes.error.delete_failed": "删除场景失败",
"scenes.cloned": "场景已克隆", "scenes.cloned": "场景已克隆",
"scenes.error.clone_failed": "克隆场景失败", "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.led": "LED",
"dashboard.type.kc": "关键颜色", "dashboard.type.kc": "关键颜色",
"aria.close": "关闭", "aria.close": "关闭",
@@ -2367,6 +2412,7 @@
"section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。", "section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。",
"section.empty.automations": "暂无自动化。点击 + 添加。", "section.empty.automations": "暂无自动化。点击 + 添加。",
"section.empty.scenes": "暂无场景预设。点击 + 添加。", "section.empty.scenes": "暂无场景预设。点击 + 添加。",
"section.empty.playlists": "暂无播放列表。点击 + 添加。",
"bulk.select": "选择", "bulk.select": "选择",
"bulk.cancel": "取消", "bulk.cancel": "取消",
"bulk.selected_count.one": "已选 {count} 项", "bulk.selected_count.one": "已选 {count} 项",
+1
View File
@@ -50,6 +50,7 @@ _ENTITY_TABLES = [
"value_sources", "value_sources",
"automations", "automations",
"scene_presets", "scene_presets",
"scene_playlists",
"sync_clocks", "sync_clocks",
"color_strip_processing_templates", "color_strip_processing_templates",
"gradients", "gradients",
@@ -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())
),
)
@@ -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
+1
View File
@@ -246,6 +246,7 @@
{% include 'modals/cspt-modal.html' %} {% include 'modals/cspt-modal.html' %}
{% include 'modals/automation-editor.html' %} {% include 'modals/automation-editor.html' %}
{% include 'modals/scene-preset-editor.html' %} {% include 'modals/scene-preset-editor.html' %}
{% include 'modals/scene-playlist-editor.html' %}
{% include 'modals/audio-source-editor.html' %} {% include 'modals/audio-source-editor.html' %}
{% include 'modals/test-audio-source.html' %} {% include 'modals/test-audio-source.html' %}
{% include 'modals/audio-template.html' %} {% include 'modals/audio-template.html' %}
@@ -0,0 +1,107 @@
<!-- Scene Playlist Editor Modal — ordered, timed sequence of scene presets.
Mirrors the scene-preset / automation editor rack-panel vocabulary:
.ds-section wrappers carry the channel accent (signal = identity,
amber = playback, cyan = scenes). Inner element IDs are consumed by
scene-playlists.ts. -->
<div id="playlist-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="playlist-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="playlist-editor-title"><svg class="icon" viewBox="0 0 24 24"><path d="M21 15V6"/><path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5"/><path d="M12 12H3"/><path d="M16 6H3"/><path d="M12 18H3"/></svg> <span data-i18n="playlists.add">New Playlist</span></h2>
<button class="modal-close-btn" onclick="closePlaylistEditor()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="playlist-editor-form">
<input type="hidden" id="playlist-editor-id">
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="playlist-editor-name" data-i18n="playlists.name">Name:</label>
<input type="text" id="playlist-editor-name" data-i18n-placeholder="playlists.name.placeholder" placeholder="My Playlist" required>
<div id="playlist-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="playlist-editor-description" data-i18n="playlists.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="playlists.description.hint">Optional description of what this playlist does</small>
<input type="text" id="playlist-editor-description">
</div>
</div>
</section>
<!-- ── 02 · PLAYBACK ───────────────────────────────── -->
<section class="ds-section" data-ds-key="playback" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="playlists.section.playback">Playback</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="label-row">
<label for="playlist-editor-loop" data-i18n="playlists.loop">Loop:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="playlists.loop.hint">Restart from the first scene after the last one; off plays through once and stops</small>
</div>
<label class="settings-toggle">
<input type="checkbox" id="playlist-editor-loop" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="label-row">
<label for="playlist-editor-shuffle" data-i18n="playlists.shuffle">Shuffle:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="playlists.shuffle.hint">Randomise the scene order at the start of every cycle</small>
</div>
<label class="settings-toggle">
<input type="checkbox" id="playlist-editor-shuffle">
<span class="settings-toggle-slider"></span>
</label>
</div>
</div>
</section>
<!-- ── 03 · SCENES ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="scenes" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="playlists.scenes">Scenes</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="playlists.scenes">Scenes:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="playlists.scenes.hint">The scene presets this playlist cycles through, each held for its own duration</small>
<div id="playlist-item-list" class="playlist-item-list" data-empty="No scenes yet"></div>
<button type="button" id="playlist-item-add-btn" class="scene-target-add-slot" onclick="addPlaylistItem()"><span data-i18n="playlists.scenes.add">Add Scene</span></button>
</div>
</div>
</section>
<div id="playlist-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closePlaylistEditor()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="savePlaylist()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>
@@ -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
+317
View File
@@ -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) == []
@@ -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