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>
236 lines
8.7 KiB
Python
236 lines
8.7 KiB
Python
from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException, Response
|
|
from fastapi.responses import FileResponse
|
|
from typing import Optional
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from auth.models import TokenPayload
|
|
from auth.dependencies import require_permission
|
|
from melodies.models import (
|
|
MelodyCreate, MelodyUpdate, MelodyInDB, MelodyListResponse, MelodyInfo,
|
|
)
|
|
from melodies import service
|
|
from database.postgres import get_pg_session
|
|
from shared.audit import log_action
|
|
|
|
router = APIRouter(prefix="/api/melodies", tags=["melodies"])
|
|
|
|
|
|
@router.get("/download/{pid}")
|
|
async def download_binary_by_pid(pid: str):
|
|
"""Download a melody's .bsm binary by PID over plain HTTP.
|
|
|
|
No auth — ESP32 devices call this directly and can't afford a TLS client's
|
|
memory footprint, so this is served from a plain-HTTP-only host
|
|
(melodies.bellsystems.net) rather than the HTTPS console domain.
|
|
"""
|
|
path = await service.get_binary_path_by_pid(pid)
|
|
return FileResponse(
|
|
path=str(path),
|
|
media_type="application/octet-stream",
|
|
filename=f"{pid}.bsm",
|
|
)
|
|
|
|
|
|
@router.get("", response_model=MelodyListResponse)
|
|
async def list_melodies(
|
|
search: Optional[str] = Query(None),
|
|
type: Optional[str] = Query(None),
|
|
tone: Optional[str] = Query(None),
|
|
total_notes: Optional[int] = Query(None),
|
|
status: Optional[str] = Query(None),
|
|
_user: TokenPayload = Depends(require_permission("melodies", "view")),
|
|
):
|
|
melodies = await service.list_melodies(
|
|
search=search,
|
|
melody_type=type,
|
|
tone=tone,
|
|
total_notes=total_notes,
|
|
status=status,
|
|
)
|
|
return MelodyListResponse(melodies=melodies, total=len(melodies))
|
|
|
|
|
|
@router.get("/{melody_id}", response_model=MelodyInDB)
|
|
async def get_melody(
|
|
melody_id: str,
|
|
_user: TokenPayload = Depends(require_permission("melodies", "view")),
|
|
):
|
|
return await service.get_melody(melody_id)
|
|
|
|
|
|
@router.post("", response_model=MelodyInDB, status_code=201)
|
|
async def create_melody(
|
|
body: MelodyCreate,
|
|
publish: bool = Query(False),
|
|
_user: TokenPayload = Depends(require_permission("melodies", "add")),
|
|
db: AsyncSession = Depends(get_pg_session),
|
|
):
|
|
melody = await service.create_melody(body, publish=publish, actor_name=_user.name)
|
|
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "melody",
|
|
melody.id, melody.information.name if melody.information else melody.id)
|
|
return melody
|
|
|
|
|
|
@router.put("/{melody_id}", response_model=MelodyInDB)
|
|
async def update_melody(
|
|
melody_id: str,
|
|
body: MelodyUpdate,
|
|
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
|
db: AsyncSession = Depends(get_pg_session),
|
|
):
|
|
old = await service.get_melody(melody_id)
|
|
melody = await service.update_melody(melody_id, body, actor_name=_user.name)
|
|
_SKIP = {"updated_at", "id", "metadata", "information", "noteAssignments"}
|
|
changes = {
|
|
k: {"old": getattr(old, k, None), "new": getattr(melody, k, None)}
|
|
for k in body.model_fields_set
|
|
if k not in _SKIP and getattr(old, k, None) != getattr(melody, k, None)
|
|
}
|
|
# Surface the name change from inside the information sub-object
|
|
old_name = old.information.name if old.information else None
|
|
new_name = melody.information.name if melody.information else None
|
|
if old_name != new_name:
|
|
changes["name"] = {"old": old_name, "new": new_name}
|
|
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "melody",
|
|
melody_id, new_name or melody_id, changes=changes or None)
|
|
return melody
|
|
|
|
|
|
@router.delete("/{melody_id}", status_code=204)
|
|
async def delete_melody(
|
|
melody_id: str,
|
|
_user: TokenPayload = Depends(require_permission("melodies", "delete")),
|
|
db: AsyncSession = Depends(get_pg_session),
|
|
):
|
|
melody = await service.get_melody(melody_id)
|
|
label = melody.information.name if melody.information else melody_id
|
|
await service.delete_melody(melody_id)
|
|
await log_action(db, _user.sub, _user.name or _user.email, "DELETE", "melody",
|
|
melody_id, label)
|
|
|
|
|
|
@router.post("/{melody_id}/publish", response_model=MelodyInDB)
|
|
async def publish_melody(
|
|
melody_id: str,
|
|
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
|
db: AsyncSession = Depends(get_pg_session),
|
|
):
|
|
melody = await service.publish_melody(melody_id)
|
|
await log_action(db, _user.sub, _user.name or _user.email, "PUBLISH", "melody",
|
|
melody_id, melody.information.name if melody.information else melody_id)
|
|
return melody
|
|
|
|
|
|
@router.post("/{melody_id}/unpublish", response_model=MelodyInDB)
|
|
async def unpublish_melody(
|
|
melody_id: str,
|
|
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
|
db: AsyncSession = Depends(get_pg_session),
|
|
):
|
|
melody = await service.unpublish_melody(melody_id)
|
|
await log_action(db, _user.sub, _user.name or _user.email, "UNPUBLISH", "melody",
|
|
melody_id, melody.information.name if melody.information else melody_id)
|
|
return melody
|
|
|
|
|
|
@router.post("/{melody_id}/upload/{file_type}")
|
|
async def upload_file(
|
|
melody_id: str,
|
|
file_type: str,
|
|
file: UploadFile = File(...),
|
|
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
|
):
|
|
"""Upload a binary or preview file. file_type must be 'binary' or 'preview'."""
|
|
if file_type not in ("binary", "preview"):
|
|
raise HTTPException(status_code=400, detail="file_type must be 'binary' or 'preview'")
|
|
|
|
# Verify melody exists
|
|
melody = await service.get_melody(melody_id)
|
|
|
|
contents = await file.read()
|
|
content_type = file.content_type or "application/octet-stream"
|
|
|
|
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(
|
|
pid=melody.pid,
|
|
file_bytes=contents,
|
|
)
|
|
else:
|
|
url = service.upload_file_for_melody(
|
|
melody_id=melody_id,
|
|
melody_uid=melody.uid,
|
|
melody_pid=melody.pid,
|
|
file_bytes=contents,
|
|
filename=file.filename,
|
|
content_type=content_type,
|
|
)
|
|
|
|
# Update the melody document with the file URL
|
|
if file_type == "preview":
|
|
await service.update_melody(melody_id, MelodyUpdate(
|
|
information=MelodyInfo(
|
|
name=melody.information.name,
|
|
previewURL=url,
|
|
)
|
|
), actor_name=_user.name)
|
|
elif file_type == "binary":
|
|
await service.update_melody(melody_id, MelodyUpdate(url=url), actor_name=_user.name)
|
|
|
|
return {"url": url, "file_type": file_type}
|
|
|
|
|
|
@router.delete("/{melody_id}/files/{file_type}", status_code=204)
|
|
async def delete_file(
|
|
melody_id: str,
|
|
file_type: str,
|
|
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
|
):
|
|
"""Delete a binary or preview file. file_type must be 'binary' or 'preview'."""
|
|
if file_type not in ("binary", "preview"):
|
|
raise HTTPException(status_code=400, detail="file_type must be 'binary' or 'preview'")
|
|
|
|
melody = await service.get_melody(melody_id)
|
|
await service.delete_file(melody_id, file_type, melody.uid, melody.pid)
|
|
|
|
|
|
@router.get("/{melody_id}/files")
|
|
async def get_files(
|
|
melody_id: str,
|
|
_user: TokenPayload = Depends(require_permission("melodies", "view")),
|
|
):
|
|
"""Get storage file URLs for a melody."""
|
|
melody = await service.get_melody(melody_id)
|
|
return service.get_storage_files(melody_id, melody.uid, melody.pid)
|
|
|
|
|
|
@router.patch("/{melody_id}/set-outdated", response_model=MelodyInDB)
|
|
async def set_outdated(
|
|
melody_id: str,
|
|
outdated: bool = Query(...),
|
|
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
|
):
|
|
"""Manually set or clear the outdated_archetype flag on a melody."""
|
|
melody = await service.get_melody(melody_id)
|
|
info = melody.information.model_dump()
|
|
info["outdated_archetype"] = outdated
|
|
return await service.update_melody(
|
|
melody_id,
|
|
MelodyUpdate(information=MelodyInfo(**info)),
|
|
actor_name=_user.name,
|
|
)
|
|
|
|
|
|
@router.get("/{melody_id}/download/binary")
|
|
async def download_binary_file(
|
|
melody_id: str,
|
|
_user: TokenPayload = Depends(require_permission("melodies", "view")),
|
|
):
|
|
"""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.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)
|