import json import uuid import logging from datetime import datetime from pathlib import Path from fastapi import HTTPException from shared.firebase import get_db as get_firestore, get_bucket from shared.exceptions import NotFoundError from melodies.models import MelodyCreate, MelodyUpdate, MelodyInDB from melodies import database as melody_db from config import settings COLLECTION = "melodies" logger = logging.getLogger("melodies.service") # Local disk storage for melody .bsm binaries — served over plain HTTP for ESP32 compatibility. # The audio preview file still goes to Firebase Storage (only ever fetched by the browser admin UI). BINARY_STORAGE_DIR = Path(settings.melody_binaries_storage_path) def _ensure_binary_storage_dir(): BINARY_STORAGE_DIR.mkdir(parents=True, exist_ok=True) def _binary_file_path(pid: str) -> Path: """Path to the local .bsm file for a given archetype PID. Keyed on pid, not melody uid: pid identifies the underlying archetype binary (raw note sequence). Multiple melodies can legitimately share one pid — each melody remaps those notes to actual bells via its own noteAssignments/speed/ duration settings — so several melodies writing/reading the same {pid}.bsm file is expected, not a collision. """ return BINARY_STORAGE_DIR / f"{pid}.bsm" def _parse_localized_string(value: str) -> dict: """Parse a JSON-encoded localized string into a dict. Returns {} on failure.""" if not value: return {} try: parsed = json.loads(value) if isinstance(parsed, dict): return parsed except (json.JSONDecodeError, TypeError): pass return {} def _doc_to_melody(doc) -> MelodyInDB: """Convert a Firestore document snapshot to a MelodyInDB model.""" data = doc.to_dict() return MelodyInDB(id=doc.id, **data) def _row_to_melody(row: dict) -> MelodyInDB: """Convert a SQLite row (with parsed data) to a MelodyInDB model.""" data = row["data"] return MelodyInDB(id=row["id"], status=row["status"], **data) def _matches_filters(melody: MelodyInDB, search: str | None, tone: str | None, total_notes: int | None) -> bool: """Apply client-side filters to a melody.""" if tone and melody.information.melodyTone.value != tone: return False if total_notes is not None and melody.information.totalNotes != total_notes: return False if search: search_lower = search.lower() name_dict = _parse_localized_string(melody.information.name) desc_dict = _parse_localized_string(melody.information.description) name_match = any( search_lower in v.lower() for v in name_dict.values() if isinstance(v, str) ) desc_match = any( search_lower in v.lower() for v in desc_dict.values() if isinstance(v, str) ) tag_match = any(search_lower in t.lower() for t in melody.information.customTags) if not (name_match or desc_match or tag_match): return False return True async def list_melodies( search: str | None = None, melody_type: str | None = None, tone: str | None = None, total_notes: int | None = None, status: str | None = None, ) -> list[MelodyInDB]: """List melodies from SQLite with optional filters.""" rows = await melody_db.list_melodies(status=status) results = [] for row in rows: melody = _row_to_melody(row) # Server-side type filter if melody_type and melody.type.value != melody_type: continue if not _matches_filters(melody, search, tone, total_notes): continue results.append(melody) return results async def get_melody(melody_id: str) -> MelodyInDB: """Get a single melody by ID. Checks SQLite first.""" row = await melody_db.get_melody(melody_id) if row: return _row_to_melody(row) raise NotFoundError("Melody") def _sanitize_metadata_for_create(existing: dict | None, actor_name: str | None) -> dict: now = datetime.utcnow().isoformat() + "Z" metadata = dict(existing or {}) creator = metadata.get("createdBy") or actor_name or "Unknown" created_at = metadata.get("dateCreated") or now metadata["createdBy"] = creator metadata["dateCreated"] = created_at metadata["lastEditedBy"] = actor_name or metadata.get("lastEditedBy") or creator metadata["dateEdited"] = now if "adminNotes" not in metadata: metadata["adminNotes"] = [] return metadata def _sanitize_metadata_for_update(existing: dict | None, incoming: dict | None, actor_name: str | None) -> dict: now = datetime.utcnow().isoformat() + "Z" existing_meta = dict(existing or {}) incoming_meta = dict(incoming or {}) # Created fields are immutable after first set. created_by = existing_meta.get("createdBy") or incoming_meta.get("createdBy") or actor_name or "Unknown" date_created = existing_meta.get("dateCreated") or incoming_meta.get("dateCreated") or now merged = {**existing_meta, **incoming_meta} merged["createdBy"] = created_by merged["dateCreated"] = date_created merged["lastEditedBy"] = actor_name or incoming_meta.get("lastEditedBy") or existing_meta.get("lastEditedBy") or created_by merged["dateEdited"] = now if "adminNotes" not in merged: merged["adminNotes"] = existing_meta.get("adminNotes", []) return merged async def create_melody(data: MelodyCreate, publish: bool = False, actor_name: str | None = None) -> MelodyInDB: """Create a new melody. If publish=True, also push to Firestore.""" melody_id = str(uuid.uuid4()) doc_data = data.model_dump() doc_data["metadata"] = _sanitize_metadata_for_create(doc_data.get("metadata"), actor_name) status = "published" if publish else "draft" # Always save to SQLite await melody_db.insert_melody(melody_id, status, doc_data) # If publishing, also save to Firestore if publish: db = get_firestore() db.collection(COLLECTION).document(melody_id).set(doc_data) return MelodyInDB(id=melody_id, status=status, **doc_data) async def update_melody(melody_id: str, data: MelodyUpdate, actor_name: str | None = None) -> MelodyInDB: """Update an existing melody. If published, also update Firestore.""" row = await melody_db.get_melody(melody_id) if not row: raise NotFoundError("Melody") existing_data = row["data"] update_data = data.model_dump(exclude_none=True) # Merge nested structs for key in ("information", "default_settings"): if key in update_data and key in existing_data: merged = {**existing_data[key], **update_data[key]} update_data[key] = merged if "metadata" in update_data or "metadata" in existing_data: update_data["metadata"] = _sanitize_metadata_for_update( existing_data.get("metadata"), update_data.get("metadata"), actor_name, ) merged_data = {**existing_data, **update_data} # Update SQLite await melody_db.update_melody(melody_id, merged_data) # If published, also update Firestore if row["status"] == "published": db = get_firestore() doc_ref = db.collection(COLLECTION).document(melody_id) doc_ref.set(merged_data) return MelodyInDB(id=melody_id, status=row["status"], **merged_data) async def publish_melody(melody_id: str) -> MelodyInDB: """Publish a draft melody to Firestore.""" row = await melody_db.get_melody(melody_id) if not row: raise NotFoundError("Melody") doc_data = row["data"] # Write to Firestore db = get_firestore() db.collection(COLLECTION).document(melody_id).set(doc_data) # Update status in SQLite await melody_db.update_status(melody_id, "published") return MelodyInDB(id=melody_id, status="published", **doc_data) async def unpublish_melody(melody_id: str) -> MelodyInDB: """Remove melody from Firestore but keep in SQLite as draft.""" row = await melody_db.get_melody(melody_id) if not row: raise NotFoundError("Melody") # Delete from Firestore db = get_firestore() doc_ref = db.collection(COLLECTION).document(melody_id) doc = doc_ref.get() if doc.exists: doc_ref.delete() # Update status in SQLite await melody_db.update_status(melody_id, "draft") return MelodyInDB(id=melody_id, status="draft", **row["data"]) async def delete_melody(melody_id: str) -> None: """Delete a melody from SQLite and Firestore (if published), plus storage files.""" row = await melody_db.get_melody(melody_id) if not row: raise NotFoundError("Melody") # Delete from Firestore if published if row["status"] == "published": db = get_firestore() doc_ref = db.collection(COLLECTION).document(melody_id) doc = doc_ref.get() if doc.exists: doc_ref.delete() # Delete storage files await _delete_storage_files(melody_id, row["data"].get("uid"), row["data"].get("pid")) # Delete from SQLite await melody_db.delete_melody(melody_id) def save_binary_for_melody(pid: str, file_bytes: bytes) -> str: """Save an archetype binary to local disk under its pid, replacing any previous file for that pid. Multiple melodies can share one pid (they remap the same underlying note sequence via their own noteAssignments/speed/duration), so writing the same {pid}.bsm from a different melody is expected — not a collision. Returns the plain-HTTP download URL devices use (served from melody_download_base_url, not Firebase — ESP32 devices can't afford the RAM for a TLS client). """ _ensure_binary_storage_dir() path = _binary_file_path(pid) path.write_bytes(file_bytes) return f"{settings.melody_download_base_url}/{pid}" async def get_binary_path_by_pid(pid: str) -> Path: """Resolve a pid to its local .bsm file path. Used by the unauthenticated device-facing download route.""" path = _binary_file_path(pid) if path.exists(): return path raise NotFoundError("Binary file") def delete_local_binary(pid: str) -> None: """Delete the local .bsm binary file for a pid, if present. Only call this when no other melody still references this pid — since the file may be shared by multiple melodies, deleting one melody shouldn't reflexively delete a binary others still use. """ if not pid: return path = _binary_file_path(pid) if path.exists(): path.unlink() def upload_file(melody_id: str, file_bytes: bytes, filename: str, content_type: str) -> str: """Upload a file to Firebase Storage under melodies/{melody_id}/.""" bucket = get_bucket() if not bucket: raise RuntimeError("Firebase Storage not initialized") if content_type in ("application/octet-stream", "application/macbinary"): storage_path = f"melodies/{melody_id}/binary.bin" else: ext = filename.rsplit(".", 1)[-1] if "." in filename else "mp3" storage_path = f"melodies/{melody_id}/preview.{ext}" blob = bucket.blob(storage_path) blob.upload_from_string(file_bytes, content_type=content_type) blob.make_public() return blob.public_url def _is_binary_blob_name(blob_name: str) -> bool: lower = (blob_name or "").lower() base = lower.rsplit("/", 1)[-1] if "preview" in base: return False return ("binary" in base) or base.endswith(".bin") or base.endswith(".bsm") def _safe_storage_segment(raw: str | None, fallback: str) -> str: value = (raw or "").strip() if not value: value = fallback chars = [] for ch in value: if ch.isalnum() or ch in ("-", "_", "."): chars.append(ch) else: chars.append("_") cleaned = "".join(chars).strip("._") return cleaned or fallback def _storage_prefixes(melody_id: str, melody_uid: str | None) -> list[str]: uid_seg = _safe_storage_segment(melody_uid, melody_id) id_seg = _safe_storage_segment(melody_id, melody_id) prefixes = [f"melodies/{uid_seg}/"] if uid_seg != id_seg: # Legacy path support prefixes.append(f"melodies/{id_seg}/") return prefixes def _list_blobs_for_prefixes(bucket, prefixes: list[str]): all_blobs = [] seen = set() for prefix in prefixes: for blob in bucket.list_blobs(prefix=prefix): if blob.name in seen: continue seen.add(blob.name) all_blobs.append(blob) return all_blobs def upload_file_for_melody(melody_id: str, melody_uid: str | None, melody_pid: str | None, file_bytes: bytes, filename: str, content_type: str) -> str: """Upload a file to Firebase Storage under melodies/{melody_uid or melody_id}/. Binary files are stored as {pid}.bsm and replace previous melody binaries. """ bucket = get_bucket() if not bucket: raise RuntimeError("Firebase Storage not initialized") prefixes = _storage_prefixes(melody_id, melody_uid) primary_prefix = prefixes[0] if content_type in ("application/octet-stream", "application/macbinary"): # Keep one active binary per melody, clean older binaries in both legacy/current prefixes. for blob in _list_blobs_for_prefixes(bucket, prefixes): if _is_binary_blob_name(blob.name): blob.delete() stem = filename.rsplit(".", 1)[0] if "." in filename else filename pid_seg = _safe_storage_segment(stem or melody_pid, "binary") storage_path = f"{primary_prefix}{pid_seg}.bsm" binary_content_type = "application/octet-stream" blob = bucket.blob(storage_path) blob.upload_from_string(file_bytes, content_type=binary_content_type) blob.make_public() return blob.public_url ext = filename.rsplit(".", 1)[-1] if "." in filename else "mp3" storage_path = f"{primary_prefix}preview.{ext}" blob = bucket.blob(storage_path) blob.upload_from_string(file_bytes, content_type=content_type) blob.make_public() return blob.public_url async def _pid_used_by_other_melody(pid: str, exclude_melody_id: str) -> bool: """Check whether any melody other than exclude_melody_id still references this pid.""" if not pid: return False rows = await melody_db.list_melodies() return any( row["id"] != exclude_melody_id and row["data"].get("pid") == pid for row in rows ) def get_binary_file_bytes(melody_id: str, melody_pid: str | None = None) -> tuple[bytes, str]: """Fetch current binary bytes for a melody from local disk storage.""" path = _binary_file_path(melody_pid) if melody_pid else None if not path or not path.exists(): raise NotFoundError("Binary file") return path.read_bytes(), "application/octet-stream" async def delete_file(melody_id: str, file_type: str, melody_uid: str | None = None, melody_pid: str | None = None) -> None: """Delete a specific file from storage. file_type is 'binary' or 'preview'.""" if file_type == "binary": if melody_pid and not await _pid_used_by_other_melody(melody_pid, melody_id): delete_local_binary(melody_pid) return bucket = get_bucket() if not bucket: return prefixes = _storage_prefixes(melody_id, melody_uid) blobs = _list_blobs_for_prefixes(bucket, prefixes) for blob in blobs: if file_type == "preview" and "preview" in blob.name: blob.delete() async def _delete_storage_files(melody_id: str, melody_uid: str | None = None, melody_pid: str | None = None) -> None: """Delete all storage files for a melody (local binary + Firebase preview). The local binary is only deleted if no other melody still references the same pid — multiple melodies can share one archetype binary by design. """ if melody_pid and not await _pid_used_by_other_melody(melody_pid, melody_id): delete_local_binary(melody_pid) bucket = get_bucket() if not bucket: return prefixes = _storage_prefixes(melody_id, melody_uid) blobs = _list_blobs_for_prefixes(bucket, prefixes) for blob in blobs: if _is_binary_blob_name(blob.name): continue # legacy Firebase binaries, if any, are no longer authoritative blob.delete() def get_storage_files(melody_id: str, melody_uid: str | None = None, melody_pid: str | None = None) -> dict: """List storage files for a melody, returning URLs. Binary comes from local disk, preview still comes from Firebase Storage.""" result = {"binary_url": None, "preview_url": None} if melody_pid and _binary_file_path(melody_pid).exists(): result["binary_url"] = f"{settings.melody_download_base_url}/{melody_pid}" bucket = get_bucket() if not bucket: return result prefixes = _storage_prefixes(melody_id, melody_uid) blobs = _list_blobs_for_prefixes(bucket, prefixes) for blob in blobs: if "preview" in blob.name: blob.make_public() result["preview_url"] = blob.public_url return result async def migrate_from_firestore() -> int: """One-time migration: import existing Firestore melodies into SQLite as published. Only runs if the melody_drafts table is empty.""" count = await melody_db.count_melodies() if count > 0: return 0 db = get_firestore() docs = db.collection(COLLECTION).stream() imported = 0 for doc in docs: data = doc.to_dict() await melody_db.insert_melody(doc.id, "published", data) imported += 1 if imported > 0: logger.info(f"Migrated {imported} melodies from Firestore to SQLite") return imported