melody.uid is never actually populated anywhere in MelodyForm.jsx, so keying local .bsm storage on it (as the previous commit did) would silently break for every existing melody. pid is the correct key anyway: it identifies the underlying archetype binary, and multiple melodies legitimately share one pid (each remaps the same note sequence to different bells/speed/duration via its own settings). Deletion is now share-aware — a melody's binary is only removed from disk once no other melody still references its pid. Also adds backend/scripts/migrate_melody_binaries_to_local.py to backfill existing melodies from their old Firebase URLs to local storage, with --dry-run support and a warning list for pids whose melodies point at different source files (a pre-existing data issue, flagged for manual review via each melody's playback button rather than silently resolved). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
500 lines
18 KiB
Python
500 lines
18 KiB
Python
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
|