Files
bellsystems-cp/backend/melodies/router.py
bonamin 72f7e00990 feat: serve melody .bsm binaries over plain HTTP instead of Firebase Storage
ESP32 devices can't spare the 40KB+ RAM a TLS client needs, so Firebase
Storage's HTTPS-only download URLs were blocking melody downloads. Binaries
are now written to local disk (./data/melody_binaries) and served through a
new unauthenticated /api/melodies/download/{pid} route, exposed publicly on
a separate melodies.bellsystems.net vhost (plain HTTP, no TLS) so the main
console domain can stay HTTPS-only with no exceptions. Preview audio still
uses Firebase Storage since it's only ever fetched by the HTTPS admin UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 09:41:24 +03:00

235 lines
8.6 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"
url = service.save_binary_for_melody(
melody_uid=melody.uid,
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)
service.delete_file(melody_id, file_type, melody.uid)
@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.uid)
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)