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

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

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

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

Tests: new unit + API coverage for the store/model, engine (cycling,
single-active exclusivity, missing-preset skip, shuffle, and the
playlist_state_changed event contract), and routes. Full suite green;
ruff and tsc clean.
This commit is contained in:
2026-06-08 13:48:43 +03:00
parent ca59546711
commit f71e10ee06
27 changed files with 2739 additions and 6 deletions
@@ -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