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>
This commit is contained in:
@@ -33,6 +33,8 @@ class Settings(BaseSettings):
|
||||
built_melodies_storage_path: str = "./storage/built_melodies"
|
||||
firmware_storage_path: str = "./storage/firmware"
|
||||
flash_assets_storage_path: str = "./storage/flash_assets"
|
||||
melody_binaries_storage_path: str = "./storage/melody_binaries"
|
||||
melody_download_base_url: str = "http://melodies.bellsystems.net/download"
|
||||
|
||||
# Email (Resend)
|
||||
resend_api_key: str = "re_placeholder_change_me"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -13,6 +14,22 @@ 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),
|
||||
@@ -134,15 +151,20 @@ async def upload_file(
|
||||
|
||||
if file_type == "binary":
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
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,
|
||||
)
|
||||
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":
|
||||
@@ -179,7 +201,7 @@ async def get_files(
|
||||
):
|
||||
"""Get storage file URLs for a melody."""
|
||||
melody = await service.get_melody(melody_id)
|
||||
return service.get_storage_files(melody_id, melody.uid)
|
||||
return service.get_storage_files(melody_id, melody.uid, melody.pid)
|
||||
|
||||
|
||||
@router.patch("/{melody_id}/set-outdated", response_model=MelodyInDB)
|
||||
|
||||
@@ -2,15 +2,31 @@ 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(melody_uid: str) -> Path:
|
||||
return BINARY_STORAGE_DIR / f"{melody_uid}.bsm"
|
||||
|
||||
|
||||
def _parse_localized_string(value: str) -> dict:
|
||||
"""Parse a JSON-encoded localized string into a dict. Returns {} on failure."""
|
||||
@@ -127,8 +143,25 @@ 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)
|
||||
@@ -151,6 +184,9 @@ 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)
|
||||
|
||||
@@ -238,6 +274,41 @@ async def delete_melody(melody_id: str) -> None:
|
||||
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.
|
||||
|
||||
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.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
|
||||
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")
|
||||
|
||||
|
||||
def delete_local_binary(melody_uid: str) -> None:
|
||||
"""Delete the local .bsm binary file for a melody, if present."""
|
||||
if not melody_uid:
|
||||
return
|
||||
path = _binary_file_path(melody_uid)
|
||||
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()
|
||||
@@ -335,31 +406,19 @@ def upload_file_for_melody(melody_id: str, melody_uid: str | None, melody_pid: s
|
||||
|
||||
|
||||
def get_binary_file_bytes(melody_id: str, melody_uid: str | None = None) -> tuple[bytes, str]:
|
||||
"""Fetch current binary bytes for a melody from Firebase Storage."""
|
||||
bucket = get_bucket()
|
||||
if not bucket:
|
||||
raise RuntimeError("Firebase Storage not initialized")
|
||||
|
||||
prefixes = _storage_prefixes(melody_id, melody_uid)
|
||||
blobs = [b for b in _list_blobs_for_prefixes(bucket, prefixes) if _is_binary_blob_name(b.name)]
|
||||
if not blobs:
|
||||
"""Fetch current binary bytes for a melody from local disk storage."""
|
||||
path = _binary_file_path(melody_uid) if melody_uid else None
|
||||
if not path or not path.exists():
|
||||
raise NotFoundError("Binary file")
|
||||
|
||||
# Prefer explicit binary.* naming, then newest.
|
||||
blobs.sort(
|
||||
key=lambda b: (
|
||||
0 if "binary" in b.name.rsplit("/", 1)[-1].lower() else 1,
|
||||
-(int(b.time_created.timestamp()) if getattr(b, "time_created", None) else 0),
|
||||
)
|
||||
)
|
||||
chosen = blobs[0]
|
||||
data = chosen.download_as_bytes()
|
||||
content_type = chosen.content_type or "application/octet-stream"
|
||||
return data, content_type
|
||||
return path.read_bytes(), "application/octet-stream"
|
||||
|
||||
|
||||
def delete_file(melody_id: str, file_type: str, melody_uid: 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)
|
||||
return
|
||||
|
||||
bucket = get_bucket()
|
||||
if not bucket:
|
||||
return
|
||||
@@ -368,14 +427,14 @@ def delete_file(melody_id: str, file_type: str, melody_uid: str | None = None) -
|
||||
blobs = _list_blobs_for_prefixes(bucket, prefixes)
|
||||
|
||||
for blob in blobs:
|
||||
if file_type == "binary" and "binary" in blob.name:
|
||||
blob.delete()
|
||||
elif file_type == "preview" and "preview" in blob.name:
|
||||
if file_type == "preview" and "preview" in blob.name:
|
||||
blob.delete()
|
||||
|
||||
|
||||
def _delete_storage_files(melody_id: str, melody_uid: str | None = None) -> None:
|
||||
"""Delete all storage files for a melody."""
|
||||
"""Delete all storage files for a melody (local binary + Firebase preview)."""
|
||||
delete_local_binary(melody_uid)
|
||||
|
||||
bucket = get_bucket()
|
||||
if not bucket:
|
||||
return
|
||||
@@ -383,24 +442,29 @@ def _delete_storage_files(melody_id: str, melody_uid: str | None = None) -> None
|
||||
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) -> dict:
|
||||
"""List storage files for a melody, returning URLs."""
|
||||
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_uid and melody_pid and _binary_file_path(melody_uid).exists():
|
||||
result["binary_url"] = f"{settings.melody_download_base_url}/{melody_pid}"
|
||||
|
||||
bucket = get_bucket()
|
||||
if not bucket:
|
||||
return {"binary_url": None, "preview_url": None}
|
||||
return result
|
||||
|
||||
prefixes = _storage_prefixes(melody_id, melody_uid)
|
||||
blobs = _list_blobs_for_prefixes(bucket, prefixes)
|
||||
|
||||
result = {"binary_url": None, "preview_url": None}
|
||||
for blob in blobs:
|
||||
blob.make_public()
|
||||
if _is_binary_blob_name(blob.name):
|
||||
result["binary_url"] = blob.public_url
|
||||
elif "preview" in blob.name:
|
||||
if "preview" in blob.name:
|
||||
blob.make_public()
|
||||
result["preview_url"] = blob.public_url
|
||||
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user