Files
ITOSMPiano-BachToBasics/backend/routers/export_routes.py
T

149 lines
5.0 KiB
Python

"""
/export/pdf - MusicXML to PDF via music21 + LilyPond (or Audiveris)
/export/mp3 - WAV buffer to MP3 via FFmpeg
"""
import base64
import binascii
import logging
import os
import subprocess
import tempfile
from fastapi import APIRouter, BackgroundTasks, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter()
# C2 - size caps
MAX_B64_BYTES = 300 * 1024 * 1024 # 300 MB base64 string (≈ 200 MB decoded)
MAX_WAV_BYTES = 200 * 1024 * 1024 # 200 MB decoded WAV
MAX_MUSICXML_BYTES = 10 * 1024 * 1024 # 10 MB
# M5 - WAV magic bytes: "RIFF....WAVE"
_WAV_RIFF = b"RIFF"
_WAV_WAVE = b"WAVE"
class ExportPdfRequest(BaseModel):
musicxml: str
class ExportMp3Request(BaseModel):
wav_base64: str # base64-encoded WAV
def _rm(path: str) -> None:
try:
os.unlink(path)
except OSError:
pass
@router.post("/pdf")
async def export_pdf(req: ExportPdfRequest, background_tasks: BackgroundTasks):
"""Convert MusicXML to PDF sheet music."""
if len(req.musicxml.encode()) > MAX_MUSICXML_BYTES:
raise HTTPException(status_code=413, detail="MusicXML payload too large (max 10 MB)")
try:
from services.music21_svc import musicxml_to_pdf
pdf_path = await musicxml_to_pdf(req.musicxml)
background_tasks.add_task(_rm, pdf_path) # H5
return FileResponse(
pdf_path,
media_type="application/pdf",
filename="sheet.pdf",
headers={
"Cache-Control": "no-store",
"Content-Disposition": 'attachment; filename="sheet.pdf"',
},
)
except HTTPException:
raise
except Exception as e:
# Surface actionable runtime errors (e.g. missing LilyPond) to the client
# so the user knows what to install instead of seeing a generic message.
# NOTE: musicxml_to_pdf runs in a ProcessPoolExecutor; exceptions get
# round-tripped through pickle, so we match by message substring rather
# than exception type (which can be wrapped / mutated on the way back).
logger.exception("export_pdf failed")
msg = str(e)
if "lilypond" in msg.lower():
raise HTTPException(
status_code=503,
detail=(
"PDF export needs LilyPond on the backend (not bundled by default).\n\n"
"Quick install: brew install lilypond (macOS) or apt install lilypond "
"(Linux). For Docker, see the README section 'Optional: PDF export via "
"LilyPond'. MIDI and MusicXML export work without it."
),
)
raise HTTPException(status_code=500, detail=f"PDF export failed: {msg or 'Unknown error'}")
@router.post("/mp3")
async def export_mp3(req: ExportMp3Request, background_tasks: BackgroundTasks):
"""Convert base64 WAV to MP3 using FFmpeg."""
# L6 - size-check before decoding; catch malformed base64 explicitly
if len(req.wav_base64) > MAX_B64_BYTES:
raise HTTPException(status_code=413, detail="WAV payload too large (max 200 MB)")
try:
wav_bytes = base64.b64decode(req.wav_base64, validate=True)
except binascii.Error:
raise HTTPException(status_code=422, detail="Invalid base64 encoding")
# C2 - check decoded size too
if len(wav_bytes) > MAX_WAV_BYTES:
raise HTTPException(status_code=413, detail="WAV data too large (max 200 MB)")
# M5 - validate WAV magic bytes: "RIFF....WAVE"
if len(wav_bytes) < 12 or wav_bytes[:4] != _WAV_RIFF or wav_bytes[8:12] != _WAV_WAVE:
raise HTTPException(status_code=422, detail="Not a valid WAV file")
try:
wav_tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
wav_tmp.write(wav_bytes)
wav_tmp.close()
mp3_tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
mp3_tmp.close()
# H5 - register cleanup regardless of success/failure
background_tasks.add_task(_rm, wav_tmp.name)
background_tasks.add_task(_rm, mp3_tmp.name)
result = subprocess.run(
[
"ffmpeg", "-y",
"-i", wav_tmp.name,
"-codec:a", "libmp3lame",
"-q:a", "2",
mp3_tmp.name,
],
capture_output=True,
timeout=60,
)
if result.returncode != 0:
logger.error("ffmpeg failed: %s", result.stderr.decode(errors="replace")[:500])
raise RuntimeError("ffmpeg conversion failed")
return FileResponse(
mp3_tmp.name,
media_type="audio/mpeg",
filename="export.mp3",
headers={
"Cache-Control": "no-store",
"Content-Disposition": 'attachment; filename="export.mp3"',
},
)
except HTTPException:
raise
except Exception:
logger.exception("export_mp3 failed")
raise HTTPException(status_code=500, detail="MP3 export failed. Please try again.")