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("//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("/") @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("/") @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("/") @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("//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"}