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:
+20
@@ -42,6 +42,7 @@ Complete REST + WebSocket API reference for the LedGrab server.
|
||||
- [Weather sources](#weather-sources)
|
||||
- [Automations](#automations)
|
||||
- [Scene presets](#scene-presets)
|
||||
- [Scene playlists](#scene-playlists)
|
||||
- [Sync clocks](#sync-clocks)
|
||||
- [Webhooks](#webhooks)
|
||||
- [HTTP endpoints](#http-endpoints)
|
||||
@@ -518,6 +519,25 @@ Captured snapshots of target state that can be restored.
|
||||
| POST | `/api/v1/scene-presets/{preset_id}/recapture` | Re-capture current state into the preset. |
|
||||
| POST | `/api/v1/scene-presets/{preset_id}/activate` | Activate the preset (restore captured state). |
|
||||
|
||||
## Scene playlists
|
||||
|
||||
Ordered, timed sequences of scene presets that auto-cycle. The engine drives
|
||||
**one** playlist at a time — starting a playlist stops any other. Each item
|
||||
references a scene preset and holds it for its `duration_seconds` (min 1s)
|
||||
before advancing; `loop` repeats from the start and `shuffle` randomises the
|
||||
order each cycle.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| POST | `/api/v1/scene-playlists` | Create a playlist (items reference scene presets). |
|
||||
| GET | `/api/v1/scene-playlists` | List all playlists plus the current cycling `state`. |
|
||||
| GET | `/api/v1/scene-playlists/state` | Get the current cycling state (idle if nothing runs). |
|
||||
| GET | `/api/v1/scene-playlists/{playlist_id}` | Get a playlist by ID. |
|
||||
| PUT | `/api/v1/scene-playlists/{playlist_id}` | Update metadata, items, and `loop`/`shuffle`. |
|
||||
| DELETE | `/api/v1/scene-playlists/{playlist_id}` | Delete a playlist (stops it first if running). |
|
||||
| POST | `/api/v1/scene-playlists/{playlist_id}/start` | Start cycling (stops any other playlist first). |
|
||||
| POST | `/api/v1/scene-playlists/stop` | Stop the active playlist (leaves the last scene applied). |
|
||||
|
||||
## Sync clocks
|
||||
|
||||
Shared clocks that drive linked animations with configurable speed.
|
||||
|
||||
@@ -18,6 +18,7 @@ from .routes.audio_templates import router as audio_templates_router
|
||||
from .routes.value_sources import router as value_sources_router
|
||||
from .routes.automations import router as automations_router
|
||||
from .routes.scene_presets import router as scene_presets_router
|
||||
from .routes.scene_playlists import router as scene_playlists_router
|
||||
from .routes.webhooks import router as webhooks_router
|
||||
from .routes.sync_clocks import router as sync_clocks_router
|
||||
from .routes.color_strip_processing import router as cspt_router
|
||||
@@ -53,6 +54,7 @@ router.include_router(output_targets_router)
|
||||
router.include_router(output_targets_control_router)
|
||||
router.include_router(automations_router)
|
||||
router.include_router(scene_presets_router)
|
||||
router.include_router(scene_playlists_router)
|
||||
router.include_router(webhooks_router)
|
||||
router.include_router(sync_clocks_router)
|
||||
router.include_router(cspt_router)
|
||||
|
||||
@@ -19,6 +19,7 @@ from ledgrab.storage.audio_template_store import AudioTemplateStore
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
from ledgrab.storage.automation_store import AutomationStore
|
||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||
from ledgrab.storage.sync_clock_store import SyncClockStore
|
||||
from ledgrab.storage.color_strip_processing_template_store import (
|
||||
ColorStripProcessingTemplateStore,
|
||||
@@ -27,6 +28,7 @@ from ledgrab.storage.gradient_store import GradientStore
|
||||
from ledgrab.storage.weather_source_store import WeatherSourceStore
|
||||
from ledgrab.storage.asset_store import AssetStore
|
||||
from ledgrab.core.automations.automation_engine import AutomationEngine
|
||||
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
|
||||
from ledgrab.core.weather.weather_manager import WeatherManager
|
||||
from ledgrab.core.backup.auto_backup import AutoBackupEngine
|
||||
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
||||
@@ -110,6 +112,14 @@ def get_automation_engine() -> AutomationEngine:
|
||||
return _get("automation_engine", "Automation engine")
|
||||
|
||||
|
||||
def get_scene_playlist_store() -> ScenePlaylistStore:
|
||||
return _get("scene_playlist_store", "Scene playlist store")
|
||||
|
||||
|
||||
def get_playlist_engine() -> PlaylistEngine:
|
||||
return _get("playlist_engine", "Playlist engine")
|
||||
|
||||
|
||||
def get_auto_backup_engine() -> AutoBackupEngine:
|
||||
return _get("auto_backup_engine", "Auto-backup engine")
|
||||
|
||||
@@ -226,7 +236,9 @@ def init_dependencies(
|
||||
value_source_store: ValueSourceStore | None = None,
|
||||
automation_store: AutomationStore | None = None,
|
||||
scene_preset_store: ScenePresetStore | None = None,
|
||||
scene_playlist_store: ScenePlaylistStore | None = None,
|
||||
automation_engine: AutomationEngine | None = None,
|
||||
playlist_engine: PlaylistEngine | None = None,
|
||||
auto_backup_engine: AutoBackupEngine | None = None,
|
||||
sync_clock_store: SyncClockStore | None = None,
|
||||
sync_clock_manager: SyncClockManager | None = None,
|
||||
@@ -262,7 +274,9 @@ def init_dependencies(
|
||||
"value_source_store": value_source_store,
|
||||
"automation_store": automation_store,
|
||||
"scene_preset_store": scene_preset_store,
|
||||
"scene_playlist_store": scene_playlist_store,
|
||||
"automation_engine": automation_engine,
|
||||
"playlist_engine": playlist_engine,
|
||||
"auto_backup_engine": auto_backup_engine,
|
||||
"sync_clock_store": sync_clock_store,
|
||||
"sync_clock_manager": sync_clock_manager,
|
||||
|
||||
@@ -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)
|
||||
@@ -35,6 +35,7 @@ import ledgrab.core.audio # noqa: F401 — trigger engine auto-registration
|
||||
from ledgrab.storage.value_source_store import ValueSourceStore
|
||||
from ledgrab.storage.automation_store import AutomationStore
|
||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||
from ledgrab.storage.sync_clock_store import SyncClockStore
|
||||
from ledgrab.storage.color_strip_processing_template_store import (
|
||||
ColorStripProcessingTemplateStore,
|
||||
@@ -47,6 +48,7 @@ from ledgrab.core.weather.weather_manager import WeatherManager
|
||||
from ledgrab.storage.home_assistant_store import HomeAssistantStore
|
||||
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
|
||||
from ledgrab.core.automations.automation_engine import AutomationEngine
|
||||
from ledgrab.core.scenes.playlist_engine import PlaylistEngine
|
||||
from ledgrab.storage.game_integration_store import GameIntegrationStore
|
||||
from ledgrab.core.game_integration.event_bus import GameEventBus
|
||||
import ledgrab.core.game_integration.adapters # noqa: F401 — register built-in adapters
|
||||
@@ -157,6 +159,7 @@ audio_template_store = AudioTemplateStore(db)
|
||||
value_source_store = ValueSourceStore(db)
|
||||
automation_store = AutomationStore(db)
|
||||
scene_preset_store = ScenePresetStore(db)
|
||||
scene_playlist_store = ScenePlaylistStore(db)
|
||||
sync_clock_store = SyncClockStore(db)
|
||||
cspt_store = ColorStripProcessingTemplateStore(db)
|
||||
gradient_store = GradientStore(db)
|
||||
@@ -278,6 +281,15 @@ async def lifespan(app: FastAPI):
|
||||
value_source_store=value_source_store,
|
||||
)
|
||||
|
||||
# Create playlist engine — auto-cycles scene presets, one playlist at a
|
||||
# time. Idle (no background task) until a playlist is started via the API.
|
||||
playlist_engine = PlaylistEngine(
|
||||
playlist_store=scene_playlist_store,
|
||||
scene_preset_store=scene_preset_store,
|
||||
target_store=output_target_store,
|
||||
processor_manager=processor_manager,
|
||||
)
|
||||
|
||||
# Create auto-backup engine — derive paths from database location so that
|
||||
# demo mode auto-backups go to data/demo/ instead of data/.
|
||||
_data_dir = Path(config.storage.database_file).parent
|
||||
@@ -314,7 +326,9 @@ async def lifespan(app: FastAPI):
|
||||
value_source_store=value_source_store,
|
||||
automation_store=automation_store,
|
||||
scene_preset_store=scene_preset_store,
|
||||
scene_playlist_store=scene_playlist_store,
|
||||
automation_engine=automation_engine,
|
||||
playlist_engine=playlist_engine,
|
||||
auto_backup_engine=auto_backup_engine,
|
||||
sync_clock_store=sync_clock_store,
|
||||
sync_clock_manager=sync_clock_manager,
|
||||
@@ -436,6 +450,9 @@ async def lifespan(app: FastAPI):
|
||||
# would talk to processors mid-shutdown.
|
||||
await _bounded("automation_engine.stop", automation_engine.stop(), timeout=1.5)
|
||||
|
||||
# Stop the playlist engine so its cycling task can't apply scenes mid-shutdown.
|
||||
await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0)
|
||||
|
||||
# Stop discovery watcher and OS notification listener so they stop
|
||||
# firing events into a shutting-down processor manager.
|
||||
if discovery_watcher is not None:
|
||||
|
||||
@@ -1134,6 +1134,175 @@ textarea:focus-visible {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Scene playlist items — ordered, timed channel rows ──────────
|
||||
Mirrors .scene-target-* (cyan patch-bay) but adds a per-item dwell
|
||||
duration field and reorder controls. Slot index via CSS counter so
|
||||
DOM reorders need no JS renumbering. Paired with
|
||||
.ds-section[data-ch="cyan"] in scene-playlist-editor.html. */
|
||||
.playlist-item-list {
|
||||
--st-ch: var(--ch-cyan, var(--info-color, #00d8ff));
|
||||
counter-reset: st-slot;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
.playlist-item-list:empty::before {
|
||||
content: attr(data-empty);
|
||||
display: block;
|
||||
padding: 14px 12px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
border: 1px dashed color-mix(in srgb, var(--st-ch) 40%, var(--lux-line, var(--border-color)));
|
||||
border-radius: var(--lux-r-md, 6px);
|
||||
background:
|
||||
repeating-linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--st-ch) 4%, transparent) 0 6px,
|
||||
transparent 6px 12px);
|
||||
text-align: center;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.playlist-item {
|
||||
counter-increment: st-slot;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 26px 32px minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px 6px 6px;
|
||||
border: 1px solid var(--lux-line, var(--border-color));
|
||||
border-radius: var(--lux-r-md, 6px);
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--st-ch) 3%, var(--lux-bg-2, var(--bg-secondary))) 0%,
|
||||
color-mix(in srgb, var(--lux-bg-1, var(--card-bg)) 70%, transparent) 100%);
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.playlist-item:hover {
|
||||
border-color: color-mix(in srgb, var(--st-ch) 55%, var(--lux-line, var(--border-color)));
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--st-ch) 80%, transparent);
|
||||
}
|
||||
.playlist-item::before {
|
||||
content: counter(st-slot, decimal-leading-zero);
|
||||
grid-column: 1;
|
||||
justify-self: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: color-mix(in srgb, var(--st-ch) 75%, var(--lux-ink-dim, var(--text-secondary)));
|
||||
opacity: 0.85;
|
||||
}
|
||||
.playlist-item-icon {
|
||||
grid-column: 2;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--st-ch);
|
||||
background: color-mix(in srgb, var(--st-ch) 9%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--st-ch) 22%, transparent);
|
||||
border-radius: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.playlist-item-icon svg,
|
||||
.playlist-item-icon .icon { width: 18px; height: 18px; }
|
||||
.playlist-item-icon .scene-color-dot { width: 12px; height: 12px; border-radius: 50%; }
|
||||
.playlist-item-id {
|
||||
grid-column: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.playlist-item-name {
|
||||
font-weight: 600;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.playlist-item-type {
|
||||
align-self: flex-start;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: color-mix(in srgb, var(--st-ch) 70%, var(--lux-ink-dim, var(--text-secondary)));
|
||||
padding: 1px 5px;
|
||||
border: 1px solid color-mix(in srgb, var(--st-ch) 28%, transparent);
|
||||
border-radius: 2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.playlist-item-type.playlist-item--missing {
|
||||
color: var(--ch-coral, var(--danger-color, #ff5e5e));
|
||||
border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 40%, transparent);
|
||||
}
|
||||
.playlist-item-duration-wrap {
|
||||
grid-column: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
}
|
||||
.playlist-item-duration-wrap svg,
|
||||
.playlist-item-duration-wrap .icon { width: 13px; height: 13px; opacity: 0.7; }
|
||||
.playlist-item-duration {
|
||||
width: 52px;
|
||||
padding: 3px 5px;
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--lux-line, var(--border-color));
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color, transparent);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
.playlist-item-unit {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.playlist-item-actions {
|
||||
grid-column: 5;
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.playlist-item-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
width: 24px;
|
||||
height: 26px;
|
||||
border-radius: 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
font-size: 0.9rem;
|
||||
transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.playlist-item-btn:hover,
|
||||
.playlist-item-btn:focus-visible {
|
||||
opacity: 1;
|
||||
color: var(--st-ch);
|
||||
background: color-mix(in srgb, var(--st-ch) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--st-ch) 30%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
.playlist-item-btn.playlist-item-remove:hover,
|
||||
.playlist-item-btn.playlist-item-remove:focus-visible {
|
||||
color: var(--ch-coral, var(--danger-color, #ff5e5e));
|
||||
background: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color, #ff5e5e)) 35%, transparent);
|
||||
}
|
||||
.playlist-item-btn .icon,
|
||||
.playlist-item-btn svg { width: 14px; height: 14px; }
|
||||
|
||||
/* ── Icon Select (reusable type picker) ──────────────────────── */
|
||||
|
||||
.icon-select-trigger {
|
||||
|
||||
@@ -116,6 +116,11 @@ import {
|
||||
activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset,
|
||||
addSceneTarget,
|
||||
} from './features/scene-presets.ts';
|
||||
import {
|
||||
openPlaylistEditor, editPlaylist, savePlaylist, closePlaylistEditor,
|
||||
clonePlaylist, deletePlaylist, addPlaylistItem,
|
||||
startScenePlaylist, stopScenePlaylist,
|
||||
} from './features/scene-playlists.ts';
|
||||
|
||||
// Layer 5: device-discovery, targets
|
||||
import {
|
||||
@@ -463,6 +468,17 @@ Object.assign(window, {
|
||||
recaptureScenePreset,
|
||||
addSceneTarget,
|
||||
|
||||
// scene playlists — modal buttons + mod-card inline handlers
|
||||
openPlaylistEditor,
|
||||
editPlaylist,
|
||||
savePlaylist,
|
||||
closePlaylistEditor,
|
||||
clonePlaylist,
|
||||
deletePlaylist,
|
||||
addPlaylistItem,
|
||||
startScenePlaylist,
|
||||
stopScenePlaylist,
|
||||
|
||||
// integrations
|
||||
loadIntegrations,
|
||||
switchIntegrationTab,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import {
|
||||
devicesCache, outputTargetsCache, colorStripSourcesCache,
|
||||
streamsCache, audioSourcesCache, valueSourcesCache,
|
||||
syncClocksCache, automationsCacheObj, scenePresetsCache,
|
||||
syncClocksCache, automationsCacheObj, scenePresetsCache, scenePlaylistsCache,
|
||||
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
|
||||
patternTemplatesCache,
|
||||
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
|
||||
@@ -26,6 +26,7 @@ const ENTITY_CACHE_MAP = {
|
||||
sync_clock: syncClocksCache,
|
||||
automation: automationsCacheObj,
|
||||
scene_preset: scenePresetsCache,
|
||||
scene_playlist: scenePlaylistsCache,
|
||||
capture_template: captureTemplatesCache,
|
||||
audio_template: audioTemplatesCache,
|
||||
pp_template: ppTemplatesCache,
|
||||
@@ -51,6 +52,7 @@ const ENTITY_LOADER_MAP = {
|
||||
pp_template: 'loadPictureSources',
|
||||
automation: 'loadAutomations',
|
||||
scene_preset: 'loadAutomations',
|
||||
scene_playlist: 'loadAutomations',
|
||||
weather_source: 'loadIntegrations',
|
||||
home_assistant_source: 'loadIntegrations',
|
||||
mqtt_source: 'loadIntegrations',
|
||||
|
||||
@@ -40,6 +40,7 @@ const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'server_restarting',
|
||||
'state_change',
|
||||
'automation_state_changed',
|
||||
'playlist_state_changed',
|
||||
'entity_changed',
|
||||
'device_health_changed',
|
||||
'update_available',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { DataCache } from './cache.ts';
|
||||
import type {
|
||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
||||
ValueSource, AudioSource, PictureSource, ScenePreset, ScenePlaylist,
|
||||
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||
@@ -436,6 +436,11 @@ export const scenePresetsCache = new DataCache<ScenePreset[]>({
|
||||
extractData: json => json.presets || [],
|
||||
});
|
||||
|
||||
export const scenePlaylistsCache = new DataCache<ScenePlaylist[]>({
|
||||
endpoint: '/scene-playlists',
|
||||
extractData: json => json.playlists || [],
|
||||
});
|
||||
|
||||
export interface GradientEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import {
|
||||
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
|
||||
scenePresetsCache, _cachedHASources, haSourcesCache,
|
||||
scenePresetsCache, scenePlaylistsCache, _cachedHASources, haSourcesCache,
|
||||
_cachedValueSources, valueSourcesCache,
|
||||
getHAEntityFriendlyName, setHAEntityNames,
|
||||
} from '../core/state.ts';
|
||||
@@ -18,7 +18,7 @@ import { Modal } from '../core/modal.ts';
|
||||
import { CardSection } from '../core/card-sections.ts';
|
||||
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
|
||||
import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
|
||||
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE } from '../core/icons.ts';
|
||||
import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE, ICON_LIST_CHECKS } from '../core/icons.ts';
|
||||
import * as P from '../core/icon-paths.ts';
|
||||
import { wrapCard } from '../core/card-colors.ts';
|
||||
import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
|
||||
@@ -32,6 +32,7 @@ import { enhanceMiniSelects } from '../core/mini-select.ts';
|
||||
import { attachProcessPicker, attachAppPicker } from '../core/process-picker.ts';
|
||||
import { TreeNav } from '../core/tree-nav.ts';
|
||||
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
|
||||
import { csPlaylists, createPlaylistCard, initPlaylistDelegation } from './scene-playlists.ts';
|
||||
import type { Automation, RuleType } from '../types.ts';
|
||||
|
||||
registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({
|
||||
@@ -252,6 +253,7 @@ export async function loadAutomations() {
|
||||
const [automations, scenes] = await Promise.all([
|
||||
automationsCacheObj.fetch(),
|
||||
scenePresetsCache.fetch(),
|
||||
scenePlaylistsCache.fetch(),
|
||||
haSourcesCache.fetch(),
|
||||
valueSourcesCache.fetch(),
|
||||
]);
|
||||
@@ -291,38 +293,45 @@ function renderAutomations(automations: any, sceneMap: any) {
|
||||
|
||||
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
|
||||
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
|
||||
const playlistItems = csPlaylists.applySortOrder(scenePlaylistsCache.data.map(p => ({ key: p.id, html: createPlaylistCard(p) })));
|
||||
|
||||
const activeTab = getActiveSubTab('automations')!;
|
||||
|
||||
const treeItems = [
|
||||
{ key: 'automations', icon: ICON_AUTOMATION, titleKey: 'automations.title', count: automations.length },
|
||||
{ key: 'scenes', icon: ICON_SCENE, titleKey: 'scenes.title', count: scenePresetsCache.data.length },
|
||||
{ key: 'playlists', icon: ICON_LIST_CHECKS, titleKey: 'playlists.title', count: scenePlaylistsCache.data.length },
|
||||
];
|
||||
|
||||
if (csAutomations.isMounted()) {
|
||||
_automationsTree.updateCounts({
|
||||
automations: automations.length,
|
||||
scenes: scenePresetsCache.data.length,
|
||||
playlists: scenePlaylistsCache.data.length,
|
||||
});
|
||||
csAutomations.reconcile(autoItems);
|
||||
csScenes.reconcile(sceneItems);
|
||||
csPlaylists.reconcile(playlistItems);
|
||||
} else {
|
||||
const panels = [
|
||||
{ key: 'automations', html: csAutomations.render(autoItems) },
|
||||
{ key: 'scenes', html: csScenes.render(sceneItems) },
|
||||
{ key: 'playlists', html: csPlaylists.render(playlistItems) },
|
||||
].map(p => `<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;
|
||||
CardSection.bindAll([csAutomations, csScenes]);
|
||||
CardSection.bindAll([csAutomations, csScenes, csPlaylists]);
|
||||
|
||||
// Event delegation for scene preset card actions
|
||||
// Event delegation for scene preset + playlist card actions
|
||||
initScenePresetDelegation(container!);
|
||||
initPlaylistDelegation(container!);
|
||||
|
||||
_automationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
||||
_automationsTree.update(treeItems, activeTab);
|
||||
_automationsTree.observeSections('automations-content', {
|
||||
'automations': 'automations',
|
||||
'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)}">↑</button>
|
||||
<button type="button" class="playlist-item-btn" data-action="playlist-item-down" title="${escapeHtml(downLabel)}" aria-label="${escapeHtml(downLabel)}">↓</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();
|
||||
});
|
||||
@@ -108,6 +108,12 @@ export type {
|
||||
ScenePreset,
|
||||
ScenePresetListResponse,
|
||||
} from './types/scene-preset.ts';
|
||||
export type {
|
||||
PlaylistItem,
|
||||
ScenePlaylist,
|
||||
PlaylistRuntimeState,
|
||||
ScenePlaylistListResponse,
|
||||
} from './types/scene-playlist.ts';
|
||||
|
||||
// ── Sync Clock ────────────────────────────────────────────────
|
||||
export type { SyncClock, SyncClockListResponse } from './types/sync-clock.ts';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -790,6 +790,7 @@
|
||||
"device.icon.entity.ha_source": "Home Assistant source",
|
||||
"device.icon.entity.automation": "Automation",
|
||||
"device.icon.entity.scene_preset": "Scene preset",
|
||||
"device.icon.entity.scene_playlist": "Playlist",
|
||||
"device.icon.entity.sync_clock": "Sync clock",
|
||||
"device.icon.entity.game_integration": "Game integration",
|
||||
"device.icon.entity.audio_processing_template": "Audio processing template",
|
||||
@@ -1351,6 +1352,50 @@
|
||||
"scenes.error.delete_failed": "Failed to delete scene",
|
||||
"scenes.cloned": "Scene cloned",
|
||||
"scenes.error.clone_failed": "Failed to clone scene",
|
||||
"playlists.title": "Playlists",
|
||||
"playlists.add": "New Playlist",
|
||||
"playlists.edit": "Edit Playlist",
|
||||
"playlists.name": "Name:",
|
||||
"playlists.name.placeholder": "My Playlist",
|
||||
"playlists.description": "Description:",
|
||||
"playlists.description.hint": "Optional description of what this playlist does",
|
||||
"playlists.section.playback": "Playback",
|
||||
"playlists.loop": "Loop:",
|
||||
"playlists.loop.hint": "Restart from the first scene after the last one; off plays through once and stops",
|
||||
"playlists.shuffle": "Shuffle:",
|
||||
"playlists.shuffle.hint": "Randomise the scene order at the start of every cycle",
|
||||
"playlists.scenes": "Scenes:",
|
||||
"playlists.scenes.hint": "The scene presets this playlist cycles through, each held for its own duration",
|
||||
"playlists.scenes.add": "Add Scene",
|
||||
"playlists.scenes_count": "scenes",
|
||||
"playlists.scene_one": "scene",
|
||||
"playlists.scene_many": "scenes",
|
||||
"playlists.items.empty": "No scenes yet — add some below",
|
||||
"playlists.items.search_placeholder": "Search scenes...",
|
||||
"playlists.item.duration": "Seconds",
|
||||
"playlists.item.move_up": "Move up",
|
||||
"playlists.item.move_down": "Move down",
|
||||
"playlists.item.missing": "Missing",
|
||||
"playlists.chip.loop": "Loop",
|
||||
"playlists.chip.shuffle": "Shuffle",
|
||||
"playlists.action.start": "Start",
|
||||
"playlists.action.stop": "Stop",
|
||||
"playlists.start": "Start playlist",
|
||||
"playlists.stop": "Stop playlist",
|
||||
"playlists.status.playing": "Playing",
|
||||
"playlists.status.stopped": "Stopped",
|
||||
"playlists.started": "Playlist started",
|
||||
"playlists.stopped": "Playlist stopped",
|
||||
"playlists.created": "Playlist created",
|
||||
"playlists.updated": "Playlist updated",
|
||||
"playlists.deleted": "Playlist deleted",
|
||||
"playlists.delete_confirm": "Delete playlist \"{name}\"?",
|
||||
"playlists.error.name_required": "Name is required",
|
||||
"playlists.error.save_failed": "Failed to save playlist",
|
||||
"playlists.error.start_failed": "Failed to start playlist",
|
||||
"playlists.error.stop_failed": "Failed to stop playlist",
|
||||
"playlists.error.delete_failed": "Failed to delete playlist",
|
||||
"playlists.error.no_presets": "Create a scene preset first",
|
||||
"dashboard.type.led": "LED",
|
||||
"dashboard.type.kc": "Key Colors",
|
||||
"aria.close": "Close",
|
||||
@@ -2690,6 +2735,7 @@
|
||||
"section.empty.cspt": "No CSS processing templates yet. Click + to add one.",
|
||||
"section.empty.automations": "No automations yet. Click + to add one.",
|
||||
"section.empty.scenes": "No scene presets yet. Click + to add one.",
|
||||
"section.empty.playlists": "No playlists yet. Click + to add one.",
|
||||
"bulk.select": "Select",
|
||||
"bulk.cancel": "Cancel",
|
||||
"bulk.selected_count.one": "{count} selected",
|
||||
|
||||
@@ -847,6 +847,7 @@
|
||||
"device.icon.entity.ha_source": "Источник Home Assistant",
|
||||
"device.icon.entity.automation": "Автоматизация",
|
||||
"device.icon.entity.scene_preset": "Сцена",
|
||||
"device.icon.entity.scene_playlist": "Плейлист",
|
||||
"device.icon.entity.sync_clock": "Часы синхронизации",
|
||||
"device.icon.entity.game_integration": "Игровая интеграция",
|
||||
"device.icon.entity.audio_processing_template": "Шаблон обработки аудио",
|
||||
@@ -1385,6 +1386,50 @@
|
||||
"scenes.error.delete_failed": "Не удалось удалить сцену",
|
||||
"scenes.cloned": "Сцена клонирована",
|
||||
"scenes.error.clone_failed": "Не удалось клонировать сцену",
|
||||
"playlists.title": "Плейлисты",
|
||||
"playlists.add": "Новый плейлист",
|
||||
"playlists.edit": "Изменить плейлист",
|
||||
"playlists.name": "Название:",
|
||||
"playlists.name.placeholder": "Мой плейлист",
|
||||
"playlists.description": "Описание:",
|
||||
"playlists.description.hint": "Необязательное описание плейлиста",
|
||||
"playlists.section.playback": "Воспроизведение",
|
||||
"playlists.loop": "Зацикливание:",
|
||||
"playlists.loop.hint": "Начинать заново с первой сцены после последней; если выключено — проиграть один раз и остановиться",
|
||||
"playlists.shuffle": "Перемешивание:",
|
||||
"playlists.shuffle.hint": "Случайный порядок сцен в начале каждого цикла",
|
||||
"playlists.scenes": "Сцены:",
|
||||
"playlists.scenes.hint": "Пресеты сцен, которые перебирает плейлист, каждая удерживается своё время",
|
||||
"playlists.scenes.add": "Добавить сцену",
|
||||
"playlists.scenes_count": "сцен",
|
||||
"playlists.scene_one": "сцена",
|
||||
"playlists.scene_many": "сцен",
|
||||
"playlists.items.empty": "Сцен пока нет — добавьте ниже",
|
||||
"playlists.items.search_placeholder": "Поиск сцен...",
|
||||
"playlists.item.duration": "Секунды",
|
||||
"playlists.item.move_up": "Вверх",
|
||||
"playlists.item.move_down": "Вниз",
|
||||
"playlists.item.missing": "Отсутствует",
|
||||
"playlists.chip.loop": "Цикл",
|
||||
"playlists.chip.shuffle": "Перемешать",
|
||||
"playlists.action.start": "Запустить",
|
||||
"playlists.action.stop": "Остановить",
|
||||
"playlists.start": "Запустить плейлист",
|
||||
"playlists.stop": "Остановить плейлист",
|
||||
"playlists.status.playing": "Воспроизводится",
|
||||
"playlists.status.stopped": "Остановлен",
|
||||
"playlists.started": "Плейлист запущен",
|
||||
"playlists.stopped": "Плейлист остановлен",
|
||||
"playlists.created": "Плейлист создан",
|
||||
"playlists.updated": "Плейлист обновлён",
|
||||
"playlists.deleted": "Плейлист удалён",
|
||||
"playlists.delete_confirm": "Удалить плейлист «{name}»?",
|
||||
"playlists.error.name_required": "Требуется название",
|
||||
"playlists.error.save_failed": "Не удалось сохранить плейлист",
|
||||
"playlists.error.start_failed": "Не удалось запустить плейлист",
|
||||
"playlists.error.stop_failed": "Не удалось остановить плейлист",
|
||||
"playlists.error.delete_failed": "Не удалось удалить плейлист",
|
||||
"playlists.error.no_presets": "Сначала создайте пресет сцены",
|
||||
"dashboard.type.led": "LED",
|
||||
"dashboard.type.kc": "Цвета клавиш",
|
||||
"aria.close": "Закрыть",
|
||||
@@ -2371,6 +2416,7 @@
|
||||
"section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.",
|
||||
"section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.",
|
||||
"section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.",
|
||||
"section.empty.playlists": "Плейлистов пока нет. Нажмите + для добавления.",
|
||||
"bulk.select": "Выбрать",
|
||||
"bulk.cancel": "Отмена",
|
||||
"bulk.selected_count.one": "{count} выбран",
|
||||
|
||||
@@ -843,6 +843,7 @@
|
||||
"device.icon.entity.ha_source": "Home Assistant 源",
|
||||
"device.icon.entity.automation": "自动化",
|
||||
"device.icon.entity.scene_preset": "场景预设",
|
||||
"device.icon.entity.scene_playlist": "播放列表",
|
||||
"device.icon.entity.sync_clock": "同步时钟",
|
||||
"device.icon.entity.game_integration": "游戏集成",
|
||||
"device.icon.entity.audio_processing_template": "音频处理模板",
|
||||
@@ -1381,6 +1382,50 @@
|
||||
"scenes.error.delete_failed": "删除场景失败",
|
||||
"scenes.cloned": "场景已克隆",
|
||||
"scenes.error.clone_failed": "克隆场景失败",
|
||||
"playlists.title": "播放列表",
|
||||
"playlists.add": "新建播放列表",
|
||||
"playlists.edit": "编辑播放列表",
|
||||
"playlists.name": "名称:",
|
||||
"playlists.name.placeholder": "我的播放列表",
|
||||
"playlists.description": "描述:",
|
||||
"playlists.description.hint": "此播放列表的可选描述",
|
||||
"playlists.section.playback": "播放",
|
||||
"playlists.loop": "循环:",
|
||||
"playlists.loop.hint": "最后一个场景结束后从第一个重新开始;关闭则播放一遍后停止",
|
||||
"playlists.shuffle": "随机:",
|
||||
"playlists.shuffle.hint": "每个循环开始时随机打乱场景顺序",
|
||||
"playlists.scenes": "场景:",
|
||||
"playlists.scenes.hint": "此播放列表循环的场景预设,每个按各自的时长保持",
|
||||
"playlists.scenes.add": "添加场景",
|
||||
"playlists.scenes_count": "个场景",
|
||||
"playlists.scene_one": "个场景",
|
||||
"playlists.scene_many": "个场景",
|
||||
"playlists.items.empty": "还没有场景 — 在下方添加",
|
||||
"playlists.items.search_placeholder": "搜索场景...",
|
||||
"playlists.item.duration": "秒",
|
||||
"playlists.item.move_up": "上移",
|
||||
"playlists.item.move_down": "下移",
|
||||
"playlists.item.missing": "缺失",
|
||||
"playlists.chip.loop": "循环",
|
||||
"playlists.chip.shuffle": "随机",
|
||||
"playlists.action.start": "开始",
|
||||
"playlists.action.stop": "停止",
|
||||
"playlists.start": "开始播放列表",
|
||||
"playlists.stop": "停止播放列表",
|
||||
"playlists.status.playing": "播放中",
|
||||
"playlists.status.stopped": "已停止",
|
||||
"playlists.started": "播放列表已开始",
|
||||
"playlists.stopped": "播放列表已停止",
|
||||
"playlists.created": "播放列表已创建",
|
||||
"playlists.updated": "播放列表已更新",
|
||||
"playlists.deleted": "播放列表已删除",
|
||||
"playlists.delete_confirm": "删除播放列表“{name}”?",
|
||||
"playlists.error.name_required": "需要名称",
|
||||
"playlists.error.save_failed": "保存播放列表失败",
|
||||
"playlists.error.start_failed": "启动播放列表失败",
|
||||
"playlists.error.stop_failed": "停止播放列表失败",
|
||||
"playlists.error.delete_failed": "删除播放列表失败",
|
||||
"playlists.error.no_presets": "请先创建场景预设",
|
||||
"dashboard.type.led": "LED",
|
||||
"dashboard.type.kc": "关键颜色",
|
||||
"aria.close": "关闭",
|
||||
@@ -2367,6 +2412,7 @@
|
||||
"section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。",
|
||||
"section.empty.automations": "暂无自动化。点击 + 添加。",
|
||||
"section.empty.scenes": "暂无场景预设。点击 + 添加。",
|
||||
"section.empty.playlists": "暂无播放列表。点击 + 添加。",
|
||||
"bulk.select": "选择",
|
||||
"bulk.cancel": "取消",
|
||||
"bulk.selected_count.one": "已选 {count} 项",
|
||||
|
||||
@@ -50,6 +50,7 @@ _ENTITY_TABLES = [
|
||||
"value_sources",
|
||||
"automations",
|
||||
"scene_presets",
|
||||
"scene_playlists",
|
||||
"sync_clocks",
|
||||
"color_strip_processing_templates",
|
||||
"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
|
||||
@@ -246,6 +246,7 @@
|
||||
{% include 'modals/cspt-modal.html' %}
|
||||
{% include 'modals/automation-editor.html' %}
|
||||
{% include 'modals/scene-preset-editor.html' %}
|
||||
{% include 'modals/scene-playlist-editor.html' %}
|
||||
{% include 'modals/audio-source-editor.html' %}
|
||||
{% include 'modals/test-audio-source.html' %}
|
||||
{% include 'modals/audio-template.html' %}
|
||||
|
||||
@@ -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">✕</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">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="savePlaylist()" title="Save" data-i18n-aria-label="aria.save">✓</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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user