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