accompy / src /storage.py
jasonmdong's picture
Sync from GitHub via hub-sync
c78e8af verified
Raw
History Blame Contribute Delete
10.5 kB
import hashlib
import os
from datetime import datetime, timezone
import requests
from fastapi import HTTPException
from src.fingering import normalize_fingering_state
def _score_row_to_payload(row: dict) -> 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 = "<svg" in sheet_html or bool(musicxml_source)
has_fingered_sheet = "<svg" in fingered_sheet_html or bool(fingered_musicxml_source)
fingering = normalize_fingering_state(
parts,
score_data.get("fingering"),
has_fingered_sheet=has_fingered_sheet,
)
return {
"name": row.get("slug") or row.get("id"),
"title": row.get("title") or row.get("slug") or row.get("id"),
"source_type": row.get("source_type") or "converted",
"parts": parts,
"has_sheet": has_sheet,
"has_fingered_sheet": has_fingered_sheet,
"measure_beats": row.get("measure_beats") or [],
"right_hand": right_hand,
"left_hand": left_hand,
"sheet_html": sheet_html if has_sheet else "",
"musicxml_source": musicxml_source,
"fingered_musicxml_source": fingered_musicxml_source,
"fingering": fingering,
}
class SupabaseScoreStore:
def __init__(self, url: str, service_role_key: str):
self.base_url = url.rstrip("/")
self.rest_url = f"{self.base_url}/rest/v1"
self.headers = {
"apikey": service_role_key,
"Authorization": f"Bearer {service_role_key}",
"Content-Type": "application/json",
}
def _request(self, method: str, path: str, *, params=None, json_body=None, headers=None):
try:
response = requests.request(
method,
f"{self.rest_url}/{path.lstrip('/')}",
params=params,
json=json_body,
headers={**self.headers, **(headers or {})},
timeout=20,
)
except requests.exceptions.Timeout as exc:
raise HTTPException(status_code=503, detail="Supabase timed out. Try again.") from exc
except requests.exceptions.RequestException as exc:
raise HTTPException(status_code=503, detail="Supabase request failed. Try again.") from exc
if response.status_code >= 400:
raise HTTPException(status_code=500, detail=f"Supabase error: {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,sheet_html,score_data",
"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"],
"has_sheet": "<svg" in (row.get("sheet_html") or "") or bool((row.get("score_data") or {}).get("musicxml_source")),
}
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):
return _score_row_to_payload(self.load_score_row(user_id, name))
def save_score(self, user_id: str, payload: dict):
existing = self._request(
"GET",
"scores",
params={
"select": "id,score_data",
"user_id": f"eq.{user_id}",
"slug": f"eq.{payload['name']}",
"limit": "1",
},
) or []
existing_score_data = (existing[0].get("score_data") or {}) if existing else {}
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.get("musicxml_source") or "",
"fingered_musicxml_source": payload["fingered_musicxml_source"] if "fingered_musicxml_source" in payload else (existing_score_data.get("fingered_musicxml_source") or ""),
"fingered_sheet_html": payload["fingered_sheet_html"] if "fingered_sheet_html" in payload else (existing_score_data.get("fingered_sheet_html") or ""),
"fingering": payload["fingering"] if "fingering" in payload else (existing_score_data.get("fingering") or {}),
},
"measure_beats": payload.get("measure_beats") or [],
"sheet_html": payload.get("sheet_html") or "",
}
if existing:
result = self._request(
"PATCH",
"scores",
params={
"user_id": f"eq.{user_id}",
"slug": f"eq.{payload['name']}",
"select": "id,user_id,slug,title,source_type,score_data,measure_beats,sheet_html,created_at",
},
json_body=row,
headers={"Prefer": "return=representation"},
) or []
else:
result = self._request(
"POST",
"scores",
params={"select": "id,user_id,slug,title,source_type,score_data,measure_beats,sheet_html,created_at"},
json_body=row,
headers={"Prefer": "return=representation"},
) or []
return _score_row_to_payload(result[0]) if result else payload
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 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)