fix: key melody binary storage on pid instead of uid, add migration script

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>
This commit is contained in:
2026-07-06 10:53:25 +03:00
parent 72f7e00990
commit 1ed5012a95
4 changed files with 384 additions and 65 deletions

View File

@@ -151,8 +151,9 @@ async def upload_file(
if file_type == "binary":
content_type = "application/octet-stream"
if not melody.pid:
raise HTTPException(status_code=400, detail="Melody must have a PID before uploading a binary")
url = service.save_binary_for_melody(
melody_uid=melody.uid,
pid=melody.pid,
file_bytes=contents,
)
@@ -191,7 +192,7 @@ async def delete_file(
raise HTTPException(status_code=400, detail="file_type must be 'binary' or 'preview'")
melody = await service.get_melody(melody_id)
service.delete_file(melody_id, file_type, melody.uid)
await service.delete_file(melody_id, file_type, melody.uid, melody.pid)
@router.get("/{melody_id}/files")
@@ -228,7 +229,7 @@ async def download_binary_file(
):
"""Download current melody binary with a PID-based filename."""
melody = await service.get_melody(melody_id)
file_bytes, content_type = service.get_binary_file_bytes(melody_id, melody.uid)
file_bytes, content_type = service.get_binary_file_bytes(melody_id, melody.pid)
filename = f"{(melody.pid or 'binary')}.bsm"
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
return Response(content=file_bytes, media_type=content_type, headers=headers)

View File

@@ -24,8 +24,16 @@ def _ensure_binary_storage_dir():
BINARY_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
def _binary_file_path(melody_uid: str) -> Path:
return BINARY_STORAGE_DIR / f"{melody_uid}.bsm"
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:
@@ -143,25 +151,8 @@ def _sanitize_metadata_for_update(existing: dict | None, incoming: dict | None,
return merged
async def _check_pid_unique(pid: str, exclude_melody_id: str | None = None) -> None:
"""Raise 409 if another melody already uses this PID.
PIDs are the public lookup key for the /melodies/download/{pid} device-facing
route, so two melodies sharing a PID would make that route resolve ambiguously.
"""
if not pid:
return
rows = await melody_db.list_melodies()
for row in rows:
if exclude_melody_id and row["id"] == exclude_melody_id:
continue
if row["data"].get("pid") == pid:
raise HTTPException(status_code=409, detail=f"A melody with PID '{pid}' already exists.")
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."""
await _check_pid_unique(data.pid)
melody_id = str(uuid.uuid4())
doc_data = data.model_dump()
doc_data["metadata"] = _sanitize_metadata_for_create(doc_data.get("metadata"), actor_name)
@@ -184,9 +175,6 @@ async def update_melody(melody_id: str, data: MelodyUpdate, actor_name: str | No
if not row:
raise NotFoundError("Melody")
if data.pid is not None and data.pid != row["data"].get("pid"):
await _check_pid_unique(data.pid, exclude_melody_id=melody_id)
existing_data = row["data"]
update_data = data.model_dump(exclude_none=True)
@@ -268,43 +256,46 @@ async def delete_melody(melody_id: str) -> None:
doc_ref.delete()
# Delete storage files
_delete_storage_files(melody_id, row["data"].get("uid"))
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(melody_uid: str, pid: str, file_bytes: bytes) -> str:
"""Save a melody's .bsm binary to local disk, replacing any previous binary for this melody.
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(melody_uid)
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 melody's PID to its local .bsm file path. Used by the unauthenticated
"""Resolve a pid to its local .bsm file path. Used by the unauthenticated
device-facing download route."""
rows = await melody_db.list_melodies()
for row in rows:
if row["data"].get("pid") == pid:
melody_uid = row["data"].get("uid")
path = _binary_file_path(melody_uid)
if path.exists():
return path
raise NotFoundError("Binary file")
raise NotFoundError("Melody")
path = _binary_file_path(pid)
if path.exists():
return path
raise NotFoundError("Binary file")
def delete_local_binary(melody_uid: str) -> None:
"""Delete the local .bsm binary file for a melody, if present."""
if not melody_uid:
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(melody_uid)
path = _binary_file_path(pid)
if path.exists():
path.unlink()
@@ -405,18 +396,30 @@ def upload_file_for_melody(melody_id: str, melody_uid: str | None, melody_pid: s
return blob.public_url
def get_binary_file_bytes(melody_id: str, melody_uid: str | None = None) -> tuple[bytes, str]:
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_uid) if melody_uid else None
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"
def delete_file(melody_id: str, file_type: str, melody_uid: str | None = None) -> None:
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":
delete_local_binary(melody_uid)
if melody_pid and not await _pid_used_by_other_melody(melody_pid, melody_id):
delete_local_binary(melody_pid)
return
bucket = get_bucket()
@@ -431,9 +434,14 @@ def delete_file(melody_id: str, file_type: str, melody_uid: str | None = None) -
blob.delete()
def _delete_storage_files(melody_id: str, melody_uid: str | None = None) -> None:
"""Delete all storage files for a melody (local binary + Firebase preview)."""
delete_local_binary(melody_uid)
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:
@@ -452,7 +460,7 @@ def get_storage_files(melody_id: str, melody_uid: str | None = None, melody_pid:
preview still comes from Firebase Storage."""
result = {"binary_url": None, "preview_url": None}
if melody_uid and melody_pid and _binary_file_path(melody_uid).exists():
if melody_pid and _binary_file_path(melody_pid).exists():
result["binary_url"] = f"{settings.melody_download_base_url}/{melody_pid}"
bucket = get_bucket()