299 lines
10 KiB
Python
299 lines
10 KiB
Python
from microdot import Microdot
|
|
from microdot.session import with_session
|
|
from models.sequence import Sequence
|
|
from models.profile import Profile
|
|
from models.transport import get_current_bridge
|
|
from models.preset import Preset
|
|
from util.profile_bundle import export_sequence_bundle, import_sequence_bundle
|
|
import json
|
|
|
|
controller = Microdot()
|
|
sequences = Sequence()
|
|
profiles = Profile()
|
|
presets = Preset()
|
|
|
|
|
|
def get_current_profile_id(session=None):
|
|
"""Get the current active profile ID from session or fallback to first."""
|
|
profile_list = profiles.list()
|
|
session_profile = None
|
|
if session is not None:
|
|
session_profile = session.get("current_profile")
|
|
if session_profile and session_profile in profile_list:
|
|
return session_profile
|
|
if profile_list:
|
|
return profile_list[0]
|
|
return None
|
|
|
|
|
|
@controller.get("")
|
|
@with_session
|
|
async def list_sequences(request, session):
|
|
"""List sequences for the current profile."""
|
|
sequences.load()
|
|
current_profile_id = get_current_profile_id(session)
|
|
if not current_profile_id:
|
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
scoped = {
|
|
sid: sdata
|
|
for sid, sdata in sequences.items()
|
|
if isinstance(sdata, dict)
|
|
and str(sdata.get("profile_id")) == str(current_profile_id)
|
|
}
|
|
return json.dumps(scoped), 200, {"Content-Type": "application/json"}
|
|
|
|
|
|
@controller.get("/<id>/export")
|
|
@with_session
|
|
async def export_sequence(request, session, id):
|
|
"""Export a sequence and referenced presets as a JSON bundle."""
|
|
current_profile_id = get_current_profile_id(session)
|
|
if not current_profile_id:
|
|
return json.dumps({"error": "No profile available"}), 404, {"Content-Type": "application/json"}
|
|
try:
|
|
bundle = export_sequence_bundle(
|
|
id,
|
|
sequences,
|
|
presets,
|
|
profile_id=current_profile_id,
|
|
)
|
|
return json.dumps(bundle), 200, {"Content-Type": "application/json"}
|
|
except ValueError as e:
|
|
return json.dumps({"error": str(e)}), 404, {"Content-Type": "application/json"}
|
|
|
|
|
|
@controller.post("/import")
|
|
@with_session
|
|
async def import_sequence(request, session):
|
|
"""Import a sequence bundle into the current profile."""
|
|
try:
|
|
current_profile_id = get_current_profile_id(session)
|
|
if not current_profile_id:
|
|
return (
|
|
json.dumps({"error": "No profile available"}),
|
|
404,
|
|
{"Content-Type": "application/json"},
|
|
)
|
|
body = request.json or {}
|
|
bundle = body.get("bundle") if isinstance(body, dict) else body
|
|
if not isinstance(bundle, dict):
|
|
return (
|
|
json.dumps({"error": "Expected JSON bundle"}),
|
|
400,
|
|
{"Content-Type": "application/json"},
|
|
)
|
|
new_id, seq_data = import_sequence_bundle(bundle, sequences, presets, current_profile_id)
|
|
return (
|
|
json.dumps({new_id: seq_data}),
|
|
201,
|
|
{"Content-Type": "application/json"},
|
|
)
|
|
except ValueError as e:
|
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
|
except Exception as e:
|
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
|
|
|
|
|
@controller.get("/<id>")
|
|
@with_session
|
|
async def get_sequence(request, session, id):
|
|
"""Get a specific sequence by ID (current profile only)."""
|
|
sequences.load()
|
|
current_profile_id = get_current_profile_id(session)
|
|
seq = sequences.read(id)
|
|
if (
|
|
seq
|
|
and current_profile_id
|
|
and str(seq.get("profile_id")) == str(current_profile_id)
|
|
):
|
|
return json.dumps(seq), 200, {"Content-Type": "application/json"}
|
|
return json.dumps({"error": "Sequence not found"}), 404
|
|
|
|
|
|
@controller.post("")
|
|
@with_session
|
|
async def create_sequence(request, session):
|
|
"""Create a new sequence for the current profile."""
|
|
try:
|
|
try:
|
|
data = request.json or {}
|
|
except Exception:
|
|
return (
|
|
json.dumps({"error": "Invalid JSON"}),
|
|
400,
|
|
{"Content-Type": "application/json"},
|
|
)
|
|
current_profile_id = get_current_profile_id(session)
|
|
if not current_profile_id:
|
|
return (
|
|
json.dumps({"error": "No profile available"}),
|
|
404,
|
|
{"Content-Type": "application/json"},
|
|
)
|
|
sequence_id = sequences.create(current_profile_id)
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
data = dict(data)
|
|
data["profile_id"] = str(current_profile_id)
|
|
if sequences.update(sequence_id, data):
|
|
seq_data = sequences.read(sequence_id)
|
|
return (
|
|
json.dumps({sequence_id: seq_data}),
|
|
201,
|
|
{"Content-Type": "application/json"},
|
|
)
|
|
return (
|
|
json.dumps({"error": "Failed to create sequence"}),
|
|
400,
|
|
{"Content-Type": "application/json"},
|
|
)
|
|
except Exception as e:
|
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
|
|
|
|
|
@controller.put("/<id>")
|
|
@with_session
|
|
async def update_sequence(request, session, id):
|
|
"""Update an existing sequence (current profile only)."""
|
|
try:
|
|
current_profile_id = get_current_profile_id(session)
|
|
seq = sequences.read(id)
|
|
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
|
|
return json.dumps({"error": "Sequence not found"}), 404
|
|
data = request.json
|
|
if not isinstance(data, dict):
|
|
return (
|
|
json.dumps({"error": "Invalid JSON"}),
|
|
400,
|
|
{"Content-Type": "application/json"},
|
|
)
|
|
data = dict(data)
|
|
data["profile_id"] = str(current_profile_id)
|
|
if sequences.update(id, data):
|
|
try:
|
|
from util.sequence_playback import stop_if_playing_sequence
|
|
|
|
stop_if_playing_sequence(str(id))
|
|
except Exception:
|
|
pass
|
|
return json.dumps(sequences.read(id)), 200, {"Content-Type": "application/json"}
|
|
return json.dumps({"error": "Sequence not found"}), 404
|
|
except Exception as e:
|
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
|
|
|
|
|
@controller.delete("/<id>")
|
|
@with_session
|
|
async def delete_sequence(request, session, id):
|
|
"""Delete a sequence (current profile only)."""
|
|
current_profile_id = get_current_profile_id(session)
|
|
seq = sequences.read(id)
|
|
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
|
|
return json.dumps({"error": "Sequence not found"}), 404
|
|
try:
|
|
from util.sequence_playback import stop_if_playing_sequence
|
|
|
|
stop_if_playing_sequence(str(id))
|
|
except Exception:
|
|
pass
|
|
if sequences.delete(id):
|
|
return (
|
|
json.dumps({"message": "Sequence deleted successfully"}),
|
|
200,
|
|
{"Content-Type": "application/json"},
|
|
)
|
|
return json.dumps({"error": "Sequence not found"}), 404
|
|
|
|
|
|
@controller.post("/sync-phase")
|
|
@with_session
|
|
async def sync_sequence_beat_phase(request, session):
|
|
"""Align beat counters while a sequence is playing (body: {\"mode\": \"step\"|\"pass\"})."""
|
|
_ = session
|
|
try:
|
|
data = request.json or {}
|
|
except Exception:
|
|
data = {}
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
mode = data.get("mode") or data.get("align") or "step"
|
|
try:
|
|
from util.sequence_playback import sync_beat_phase
|
|
|
|
if not await sync_beat_phase(str(mode)):
|
|
return (
|
|
json.dumps({"error": "No sequence is playing"}),
|
|
409,
|
|
{"Content-Type": "application/json"},
|
|
)
|
|
from util.audio_detector import anchor_shared_bar_phase
|
|
|
|
anchor_shared_bar_phase()
|
|
return json.dumps({"ok": True, "mode": str(mode).strip().lower()}), 200, {
|
|
"Content-Type": "application/json"
|
|
}
|
|
except Exception as e:
|
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
|
|
|
|
|
@controller.post("/stop")
|
|
@with_session
|
|
async def stop_sequence_playback(request, session):
|
|
"""Stop server-driven zone sequence playback."""
|
|
_ = request
|
|
try:
|
|
from util.sequence_playback import stop_playback
|
|
|
|
await stop_playback(clear_devices=True)
|
|
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
|
|
except Exception as e:
|
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
|
|
|
|
|
@controller.post("/<id>/play")
|
|
@with_session
|
|
async def play_sequence(request, session, id):
|
|
"""Start server-driven playback for a sequence in a zone (body: {\"zone_id\": \"...\"})."""
|
|
if not get_current_bridge():
|
|
return (
|
|
json.dumps({"error": "Transport not configured"}),
|
|
503,
|
|
{"Content-Type": "application/json"},
|
|
)
|
|
current_profile_id = get_current_profile_id(session)
|
|
if not current_profile_id:
|
|
return (
|
|
json.dumps({"error": "No profile available"}),
|
|
404,
|
|
{"Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
data = request.json or {}
|
|
except Exception:
|
|
data = {}
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
zone_id = data.get("zone_id") or data.get("zoneId")
|
|
if zone_id is None or str(zone_id).strip() == "":
|
|
return (
|
|
json.dumps({"error": "zone_id required"}),
|
|
400,
|
|
{"Content-Type": "application/json"},
|
|
)
|
|
zone_id = str(zone_id).strip()
|
|
try:
|
|
from util.sequence_playback import start
|
|
|
|
play_opts = data if isinstance(data, dict) else None
|
|
await start(zone_id, str(id), str(current_profile_id), play_opts)
|
|
from util.sequence_playback import pending_play_status
|
|
|
|
body = {"ok": True, **pending_play_status()}
|
|
return json.dumps(body), 200, {"Content-Type": "application/json"}
|
|
except ValueError as e:
|
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
|
except RuntimeError as e:
|
|
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
|
except Exception as e:
|
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|