Add audio sources as first-class entities, add mapped CSS type, simplify target editor for mapped sources

- Audio sources moved to separate tab with dedicated CRUD API, store, and editor modal
- New "mapped" color strip source type: assigns different CSS sources to distinct LED sub-ranges (zones)
- Mapped stream runtime with per-zone sub-streams, auto-sizing, hot-update support
- Target editor auto-collapses segments UI when mapped CSS is selected
- Delete protection for CSS sources referenced by mapped zones
- Compact header/footer layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 23:35:58 +03:00
parent 199039326b
commit 9efb08acb6
28 changed files with 1729 additions and 153 deletions

View File

@@ -0,0 +1,139 @@
"""Audio source routes: CRUD for audio sources."""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_audio_source_store,
get_color_strip_store,
)
from wled_controller.api.schemas.audio_sources import (
AudioSourceCreate,
AudioSourceListResponse,
AudioSourceResponse,
AudioSourceUpdate,
)
from wled_controller.storage.audio_source import AudioSource
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
def _to_response(source: AudioSource) -> AudioSourceResponse:
"""Convert an AudioSource to an AudioSourceResponse."""
return AudioSourceResponse(
id=source.id,
name=source.name,
source_type=source.source_type,
device_index=getattr(source, "device_index", None),
is_loopback=getattr(source, "is_loopback", None),
audio_source_id=getattr(source, "audio_source_id", None),
channel=getattr(source, "channel", None),
description=source.description,
created_at=source.created_at,
updated_at=source.updated_at,
)
@router.get("/api/v1/audio-sources", response_model=AudioSourceListResponse, tags=["Audio Sources"])
async def list_audio_sources(
_auth: AuthRequired,
source_type: Optional[str] = Query(None, description="Filter by source_type: multichannel or mono"),
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""List all audio sources, optionally filtered by type."""
sources = store.get_all_sources()
if source_type:
sources = [s for s in sources if s.source_type == source_type]
return AudioSourceListResponse(
sources=[_to_response(s) for s in sources],
count=len(sources),
)
@router.post("/api/v1/audio-sources", response_model=AudioSourceResponse, status_code=201, tags=["Audio Sources"])
async def create_audio_source(
data: AudioSourceCreate,
_auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""Create a new audio source."""
try:
source = store.create_source(
name=data.name,
source_type=data.source_type,
device_index=data.device_index,
is_loopback=data.is_loopback,
audio_source_id=data.audio_source_id,
channel=data.channel,
description=data.description,
)
return _to_response(source)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
async def get_audio_source(
source_id: str,
_auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""Get an audio source by ID."""
try:
source = store.get_source(source_id)
return _to_response(source)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/audio-sources/{source_id}", response_model=AudioSourceResponse, tags=["Audio Sources"])
async def update_audio_source(
source_id: str,
data: AudioSourceUpdate,
_auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store),
):
"""Update an existing audio source."""
try:
source = store.update_source(
source_id=source_id,
name=data.name,
device_index=data.device_index,
is_loopback=data.is_loopback,
audio_source_id=data.audio_source_id,
channel=data.channel,
description=data.description,
)
return _to_response(source)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/api/v1/audio-sources/{source_id}", tags=["Audio Sources"])
async def delete_audio_source(
source_id: str,
_auth: AuthRequired,
store: AudioSourceStore = Depends(get_audio_source_store),
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete an audio source."""
try:
# Check if any CSS entities reference this audio source
from wled_controller.storage.color_strip_source import AudioColorStripSource
for css in css_store.get_all_sources():
if isinstance(css, AudioColorStripSource) and getattr(css, "audio_source_id", None) == source_id:
raise ValueError(
f"Cannot delete: referenced by color strip source '{css.name}'"
)
store.delete_source(source_id)
return {"status": "deleted", "id": source_id}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -80,10 +80,9 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
frame_interpolation=getattr(source, "frame_interpolation", None),
animation=getattr(source, "animation", None),
layers=getattr(source, "layers", None),
zones=getattr(source, "zones", None),
visualization_mode=getattr(source, "visualization_mode", None),
audio_device_index=getattr(source, "audio_device_index", None),
audio_loopback=getattr(source, "audio_loopback", None),
audio_channel=getattr(source, "audio_channel", None),
audio_source_id=getattr(source, "audio_source_id", None),
sensitivity=getattr(source, "sensitivity", None),
color_peak=getattr(source, "color_peak", None),
overlay_active=overlay_active,
@@ -137,6 +136,8 @@ async def create_color_strip_source(
layers = [l.model_dump() for l in data.layers] if data.layers is not None else None
zones = [z.model_dump() for z in data.zones] if data.zones is not None else None
source = store.create_source(
name=data.name,
source_type=data.source_type,
@@ -162,10 +163,9 @@ async def create_color_strip_source(
scale=data.scale,
mirror=data.mirror,
layers=layers,
zones=zones,
visualization_mode=data.visualization_mode,
audio_device_index=data.audio_device_index,
audio_loopback=data.audio_loopback,
audio_channel=data.audio_channel,
audio_source_id=data.audio_source_id,
sensitivity=data.sensitivity,
color_peak=data.color_peak,
)
@@ -211,6 +211,8 @@ async def update_color_strip_source(
layers = [l.model_dump() for l in data.layers] if data.layers is not None else None
zones = [z.model_dump() for z in data.zones] if data.zones is not None else None
source = store.update_source(
source_id=source_id,
name=data.name,
@@ -236,10 +238,9 @@ async def update_color_strip_source(
scale=data.scale,
mirror=data.mirror,
layers=layers,
zones=zones,
visualization_mode=data.visualization_mode,
audio_device_index=data.audio_device_index,
audio_loopback=data.audio_loopback,
audio_channel=data.audio_channel,
audio_source_id=data.audio_source_id,
sensitivity=data.sensitivity,
color_peak=data.color_peak,
)
@@ -284,6 +285,14 @@ async def delete_color_strip_source(
detail=f"Color strip source is used as a layer in composite source(s): {names}. "
"Remove it from the composite(s) first.",
)
mapped_names = store.get_mapped_referencing(source_id)
if mapped_names:
names = ", ".join(mapped_names)
raise HTTPException(
status_code=409,
detail=f"Color strip source is used as a zone in mapped source(s): {names}. "
"Remove it from the mapped source(s) first.",
)
store.delete_source(source_id)
except HTTPException:
raise