"""Sync clock routes: CRUD + runtime control for synchronization clocks.""" from fastapi import APIRouter, Depends, HTTPException from ledgrab.api.auth import AuthRequired from ledgrab.api.dependencies import ( fire_entity_event, get_color_strip_store, get_sync_clock_manager, get_sync_clock_store, get_value_source_store, ) from ledgrab.api.schemas.sync_clocks import ( SyncClockCreate, SyncClockListResponse, SyncClockResponse, SyncClockUpdate, ) from ledgrab.storage.sync_clock import SyncClock from ledgrab.storage.sync_clock_store import SyncClockStore from ledgrab.storage.color_strip_store import ColorStripStore from ledgrab.storage.value_source_store import ValueSourceStore from ledgrab.core.processing.sync_clock_manager import SyncClockManager from ledgrab.utils import get_logger from ledgrab.storage.base_store import EntityNotFoundError logger = get_logger(__name__) router = APIRouter() def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockResponse: """Convert a SyncClock to a SyncClockResponse (with runtime state).""" rt = manager.get_runtime(clock.id) return SyncClockResponse( id=clock.id, name=clock.name, speed=rt.speed if rt else clock.speed, description=clock.description, tags=clock.tags, icon=getattr(clock, "icon", "") or "", icon_color=getattr(clock, "icon_color", "") or "", is_running=rt.is_running if rt else True, elapsed_time=rt.get_time() if rt else 0.0, created_at=clock.created_at, updated_at=clock.updated_at, ) @router.get("/api/v1/sync-clocks", response_model=SyncClockListResponse, tags=["Sync Clocks"]) async def list_sync_clocks( _auth: AuthRequired, store: SyncClockStore = Depends(get_sync_clock_store), manager: SyncClockManager = Depends(get_sync_clock_manager), ): """List all synchronization clocks.""" clocks = store.get_all_clocks() return SyncClockListResponse( clocks=[_to_response(c, manager) for c in clocks], count=len(clocks), ) @router.post( "/api/v1/sync-clocks", response_model=SyncClockResponse, status_code=201, tags=["Sync Clocks"] ) async def create_sync_clock( data: SyncClockCreate, _auth: AuthRequired, store: SyncClockStore = Depends(get_sync_clock_store), manager: SyncClockManager = Depends(get_sync_clock_manager), ): """Create a new synchronization clock.""" try: clock = store.create_clock( name=data.name, speed=data.speed, description=data.description, tags=data.tags, icon=data.icon, icon_color=data.icon_color, ) fire_entity_event("sync_clock", "created", clock.id) return _to_response(clock, manager) except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @router.get( "/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"] ) async def get_sync_clock( clock_id: str, _auth: AuthRequired, store: SyncClockStore = Depends(get_sync_clock_store), manager: SyncClockManager = Depends(get_sync_clock_manager), ): """Get a synchronization clock by ID.""" try: clock = store.get_clock(clock_id) return _to_response(clock, manager) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @router.put( "/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"] ) async def update_sync_clock( clock_id: str, data: SyncClockUpdate, _auth: AuthRequired, store: SyncClockStore = Depends(get_sync_clock_store), manager: SyncClockManager = Depends(get_sync_clock_manager), ): """Update a synchronization clock. Speed changes are hot-applied to running streams.""" try: clock = store.update_clock( clock_id=clock_id, name=data.name, speed=data.speed, description=data.description, tags=data.tags, icon=data.icon, icon_color=data.icon_color, ) # Hot-update runtime speed if data.speed is not None: manager.update_speed(clock_id, clock.speed) fire_entity_event("sync_clock", "updated", clock_id) return _to_response(clock, manager) except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @router.delete("/api/v1/sync-clocks/{clock_id}", status_code=204, tags=["Sync Clocks"]) async def delete_sync_clock( clock_id: str, _auth: AuthRequired, store: SyncClockStore = Depends(get_sync_clock_store), css_store: ColorStripStore = Depends(get_color_strip_store), vs_store: ValueSourceStore = Depends(get_value_source_store), manager: SyncClockManager = Depends(get_sync_clock_manager), ): """Delete a synchronization clock (fails if referenced by CSS or value sources).""" _entity_name: str | None = None try: _entity_name = store.get_clock(clock_id).name except Exception: pass try: # Check references for source in css_store.get_all_sources(): if getattr(source, "clock_id", None) == clock_id: raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'") for vs in vs_store.get_all_sources(): if getattr(vs, "clock_id", None) == clock_id: raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'") manager.release_all_for(clock_id) store.delete_clock(clock_id) fire_entity_event("sync_clock", "deleted", clock_id, entity_name=_entity_name) except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) # ── Runtime control ────────────────────────────────────────────────── @router.post( "/api/v1/sync-clocks/{clock_id}/pause", response_model=SyncClockResponse, tags=["Sync Clocks"] ) async def pause_sync_clock( clock_id: str, _auth: AuthRequired, store: SyncClockStore = Depends(get_sync_clock_store), manager: SyncClockManager = Depends(get_sync_clock_manager), ): """Pause a synchronization clock — all linked animations freeze.""" try: clock = store.get_clock(clock_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) manager.pause(clock_id) fire_entity_event("sync_clock", "updated", clock_id) return _to_response(clock, manager) @router.post( "/api/v1/sync-clocks/{clock_id}/resume", response_model=SyncClockResponse, tags=["Sync Clocks"] ) async def resume_sync_clock( clock_id: str, _auth: AuthRequired, store: SyncClockStore = Depends(get_sync_clock_store), manager: SyncClockManager = Depends(get_sync_clock_manager), ): """Resume a paused synchronization clock.""" try: clock = store.get_clock(clock_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) manager.resume(clock_id) fire_entity_event("sync_clock", "updated", clock_id) return _to_response(clock, manager) @router.post( "/api/v1/sync-clocks/{clock_id}/reset", response_model=SyncClockResponse, tags=["Sync Clocks"] ) async def reset_sync_clock( clock_id: str, _auth: AuthRequired, store: SyncClockStore = Depends(get_sync_clock_store), manager: SyncClockManager = Depends(get_sync_clock_manager), ): """Reset a synchronization clock to t=0 — all linked animations restart.""" try: clock = store.get_clock(clock_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) manager.reset(clock_id) fire_entity_event("sync_clock", "updated", clock_id) return _to_response(clock, manager)