import hashlib import os from datetime import datetime, timezone import requests from fastapi import HTTPException from src.fingering import normalize_fingering_state MAX_INLINE_SHEET_HTML_BYTES = int(os.getenv("NOTEPILOT_MAX_INLINE_SHEET_HTML_BYTES", "1500000")) def _inline_sheet_html(value: str) -> str: text = value or "" if not text: return "" if len(text.encode("utf-8")) > MAX_INLINE_SHEET_HTML_BYTES: return "" return text def _score_row_to_payload(row: dict, *, include_sheet_assets: bool = True) -> dict: score_data = row.get("score_data") or {} parts = score_data.get("parts") or [] musicxml_source = score_data.get("musicxml_source") or "" fingered_musicxml_source = score_data.get("fingered_musicxml_source") or "" fingered_sheet_html = score_data.get("fingered_sheet_html") or "" right_hand = parts[0]["notes"] if parts else [] left_hand = [] for part in parts[1:]: for event in part.get("notes", []): pitches = event[0] if isinstance(event[0], list) else [event[0]] duration = event[2] if len(event) > 2 else 0.75 merged = [pitches, event[1], duration] if len(event) > 3: merged.append(event[3]) if len(event) > 4: if len(merged) == 3: merged.append(None) merged.extend(event[4:]) left_hand.append(merged) left_hand.sort(key=lambda event: event[1]) sheet_html = row.get("sheet_html") or "" has_sheet = "= 400: try: error_payload = response.json() except ValueError: error_payload = {} error_message = str(error_payload.get("message") or response.text or "").strip() if ( "schema cache" in error_message and ("public.app_users" in error_message or "public.app_sessions" in error_message) ): raise HTTPException( status_code=503, detail="Supabase auth tables are not set up. Run supabase_simple_auth.sql.", ) if response.status_code >= 500: raise HTTPException( status_code=503, detail="Supabase is unavailable. Try again.", ) if "57014" in response.text or "statement timeout" in response.text.lower(): raise HTTPException( status_code=503, detail="Supabase timed out while saving this score. Try again.", ) raise HTTPException(status_code=500, detail=f"Supabase error: {error_message or response.text}") if not response.text: return None return response.json() def list_scores(self, user_id: str): rows = self._request( "GET", "scores", params={ "select": "slug,title,created_at", "user_id": f"eq.{user_id}", "order": "created_at.desc", }, ) or [] names = [row["slug"] for row in rows if row.get("slug")] return { "scores": names, "items": [ { "name": row["slug"], "title": row.get("title") or row["slug"], "created_at": row.get("created_at"), "has_sheet": True, } for row in rows if row.get("slug") ], } def load_score_row(self, user_id: str, name: str): rows = self._request( "GET", "scores", params={ "select": "id,user_id,slug,title,source_type,score_data,measure_beats,sheet_html,created_at", "user_id": f"eq.{user_id}", "slug": f"eq.{name}", "limit": "1", }, ) or [] if not rows: raise HTTPException(status_code=404, detail=f"Score '{name}' not found") return rows[0] def load_score(self, user_id: str, name: str, *, include_sheet_assets: bool = True): return _score_row_to_payload( self.load_score_row(user_id, name), include_sheet_assets=include_sheet_assets, ) def save_score(self, user_id: str, payload: dict): existing = self._request( "GET", "scores", params={ "select": "id,created_at", "user_id": f"eq.{user_id}", "slug": f"eq.{payload['name']}", "limit": "1", }, ) or [] preserve_keys = ( "musicxml_source", "fingered_musicxml_source", "fingered_sheet_html", "fingering", "measure_beats", "sheet_html", ) needs_existing_payload = bool(existing) and any(key not in payload for key in preserve_keys) existing_payload = {} if needs_existing_payload: existing_payload_rows = self._request( "GET", "scores", params={ "select": "score_data,measure_beats,sheet_html", "user_id": f"eq.{user_id}", "slug": f"eq.{payload['name']}", "limit": "1", }, ) or [] existing_payload = existing_payload_rows[0] if existing_payload_rows else {} existing_score_data = existing_payload.get("score_data") or {} existing_measure_beats = existing_payload.get("measure_beats") or [] existing_sheet_html = existing_payload.get("sheet_html") or "" def payload_or_existing(key: str, default=""): if key in payload: return payload[key] if payload[key] is not None else default return existing_score_data.get(key) or default row = { "user_id": user_id, "slug": payload["name"], "title": payload.get("title") or payload["name"], "source_type": payload.get("source_type") or "converted", "score_data": { "parts": payload["parts"], "musicxml_source": payload_or_existing("musicxml_source"), "fingered_musicxml_source": payload_or_existing("fingered_musicxml_source"), "fingered_sheet_html": _inline_sheet_html(payload_or_existing("fingered_sheet_html")), "fingering": payload["fingering"] if "fingering" in payload else (existing_score_data.get("fingering") or {}), }, "measure_beats": payload["measure_beats"] if "measure_beats" in payload else (existing_measure_beats or []), "sheet_html": _inline_sheet_html( payload["sheet_html"] if "sheet_html" in payload else (existing_sheet_html or "") ), } if existing: self._request( "PATCH", "scores", params={ "user_id": f"eq.{user_id}", "slug": f"eq.{payload['name']}", }, json_body=row, headers={"Prefer": "return=minimal"}, ) else: self._request( "POST", "scores", json_body=row, headers={"Prefer": "return=minimal"}, ) saved_row = { "id": existing[0].get("id") if existing else None, **row, "created_at": (existing[0].get("created_at") if existing else None) or datetime.now(timezone.utc).isoformat(), } return _score_row_to_payload(saved_row) def delete_score(self, user_id: str, name: str): self._request( "DELETE", "scores", params={ "user_id": f"eq.{user_id}", "slug": f"eq.{name}", }, headers={"Prefer": "return=minimal"}, ) def update_score_sheet_html(self, user_id: str, name: str, sheet_html: str): sheet_html = _inline_sheet_html(sheet_html) if not sheet_html: return self._request( "PATCH", "scores", params={ "user_id": f"eq.{user_id}", "slug": f"eq.{name}", }, json_body={"sheet_html": sheet_html or ""}, headers={"Prefer": "return=minimal"}, ) def upsert_score_artifact( self, *, score_id: str, user_id: str, artifact_type: str, storage_provider: str, object_key: str, content_type: str, size_bytes: int, ): self._request( "POST", "score_artifacts", params={"on_conflict": "score_id,user_id,artifact_type"}, json_body={ "score_id": score_id, "user_id": user_id, "artifact_type": artifact_type, "storage_provider": storage_provider, "object_key": object_key, "content_type": content_type, "size_bytes": size_bytes, "updated_at": datetime.now(timezone.utc).isoformat(), }, headers={"Prefer": "resolution=merge-duplicates,return=minimal"}, ) def get_score_artifact_by_id(self, *, score_id: str, user_id: str, artifact_type: str): rows = self._request( "GET", "score_artifacts", params={ "select": "object_key,storage_provider,content_type,size_bytes,updated_at", "score_id": f"eq.{score_id}", "user_id": f"eq.{user_id}", "artifact_type": f"eq.{artifact_type}", "limit": "1", }, ) or [] return rows[0] if rows else None def rename_score_title(self, user_id: str, name: str, title: str): result = self._request( "PATCH", "scores", params={ "user_id": f"eq.{user_id}", "slug": f"eq.{name}", "select": "id,user_id,slug,title,source_type,score_data,measure_beats,sheet_html,created_at", }, json_body={"title": title}, headers={"Prefer": "return=representation"}, ) or [] if not result: raise HTTPException(status_code=404, detail=f"Score '{name}' not found") return _score_row_to_payload(result[0]) def get_app_user_by_username(self, username: str): rows = self._request( "GET", "app_users", params={ "select": "id,username,password_hash,created_at", "username": f"eq.{username}", "limit": "1", }, ) or [] return rows[0] if rows else None def create_app_user(self, username: str, password_hash: str): result = self._request( "POST", "app_users", params={"select": "id,username,password_hash,created_at"}, json_body={ "username": username, "password_hash": password_hash, }, headers={"Prefer": "return=representation"}, ) or [] return result[0] if result else None def create_app_session(self, user_id: str, token_hash: str, expires_at_iso: str): result = self._request( "POST", "app_sessions", params={"select": "id,user_id,token_hash,expires_at,created_at"}, json_body={ "user_id": user_id, "token_hash": token_hash, "expires_at": expires_at_iso, }, headers={"Prefer": "return=representation"}, ) or [] return result[0] if result else None def get_app_session_user(self, raw_token: str): token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest() rows = self._request( "GET", "app_sessions", params={ "select": "id,user_id,expires_at", "token_hash": f"eq.{token_hash}", "limit": "1", }, ) or [] if not rows: return None session = rows[0] expires_at = session.get("expires_at") if expires_at: expires_dt = datetime.fromisoformat(expires_at.replace("Z", "+00:00")) if expires_dt <= datetime.now(timezone.utc): self.delete_app_session(raw_token) return None users = self._request( "GET", "app_users", params={ "select": "id,username,created_at", "id": f"eq.{session['user_id']}", "limit": "1", }, ) or [] if not users: return None user = users[0] user["session_id"] = session.get("id") return user def delete_app_session(self, raw_token: str): token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest() self._request( "DELETE", "app_sessions", params={"token_hash": f"eq.{token_hash}"}, headers={"Prefer": "return=minimal"}, ) def create_score_store(): url = os.getenv("SUPABASE_URL", "").strip() key = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "").strip() if not (url and key): raise RuntimeError("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required.") return SupabaseScoreStore(url, key)