Files

186 lines
6.7 KiB
Python

"""
Audio management endpoints.
POST /api/pdfs/{pdf_id}/audio — upload one or more MP3 files (admin/teacher only)
GET /api/pdfs/{pdf_id}/audio — list all audio tracks for a PDF (any authenticated user)
DELETE /api/pdfs/{pdf_id}/audio/{audio_id} — delete a single track (admin/teacher only)
GET /api/pdfs/{pdf_id}/audio/{audio_id}/file — stream the audio file
"""
import os
import re
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..dependencies import get_current_user, require_role
from ..models import PDF, PDFAudio, UserRole
from ..schemas import AudioOut, AudioUploadResult
router = APIRouter(prefix="/pdfs", tags=["audio"])
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/uploads"))
MAX_AUDIO_SIZE = 200 * 1024 * 1024 # 200 MB per file
ALLOWED_AUDIO_TYPES = {"audio/mpeg", "audio/mp3", "audio/mp4", "audio/x-m4a", "audio/ogg", "application/octet-stream"}
def _audio_dir(pdf_id: int) -> Path:
d = UPLOAD_DIR / "audio" / str(pdf_id)
d.mkdir(parents=True, exist_ok=True)
return d
def _safe_track_name(raw: str) -> str:
"""Strip control characters; truncate to 255 chars."""
cleaned = re.sub(r"[\x00-\x1f\x7f]", "", raw).strip()
return cleaned[:255] or "Track"
# ── GET /api/pdfs/{pdf_id}/audio ──────────────────────────────────────────────
@router.get("/{pdf_id}/audio", response_model=list[AudioOut])
def list_audio(
pdf_id: int,
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
):
pdf = db.get(PDF, pdf_id)
if not pdf:
raise HTTPException(status_code=404, detail="PDF not found.")
tracks = (
db.query(PDFAudio)
.filter(PDFAudio.pdf_id == pdf_id)
.order_by(PDFAudio.sort_order, PDFAudio.id)
.all()
)
return tracks
# ── POST /api/pdfs/{pdf_id}/audio ────────────────────────────────────────────
@router.post(
"/{pdf_id}/audio",
response_model=list[AudioUploadResult],
status_code=status.HTTP_201_CREATED,
)
async def upload_audio(
pdf_id: int,
files: list[UploadFile],
track_names: list[str] = Form(default=[]),
page_numbers: list[int] = Form(default=[]),
sort_orders: list[int] = Form(default=[]),
current_user=Depends(require_role(UserRole.admin, UserRole.teacher)),
db: Session = Depends(get_db),
):
"""
Upload one or more audio files for a PDF.
Optional form fields (parallel arrays, indexed per file):
- track_names: display name for each track
- page_numbers: page the track belongs to (0 = whole PDF / unset)
- sort_orders: ordering integer (default 1)
"""
pdf = db.get(PDF, pdf_id)
if not pdf:
raise HTTPException(status_code=404, detail="PDF not found.")
results: list[AudioUploadResult] = []
for idx, file in enumerate(files):
fname = file.filename or f"audio_{idx + 1}"
content_type = (file.content_type or "").lower()
if content_type not in ALLOWED_AUDIO_TYPES:
# Accept based on extension if browser sends wrong MIME
ext = Path(fname).suffix.lower()
if ext not in {".mp3", ".m4a", ".ogg", ".wav", ".aac"}:
results.append(AudioUploadResult(filename=fname, success=False, error="Unsupported audio format."))
continue
body = await file.read()
if len(body) > MAX_AUDIO_SIZE:
results.append(AudioUploadResult(filename=fname, success=False, error="File exceeds 200 MB limit."))
continue
safe_name = f"{uuid.uuid4()}{Path(fname).suffix or '.mp3'}"
dest = _audio_dir(pdf_id) / safe_name
dest.write_bytes(body)
# Derive metadata from parallel arrays (fall back to sensible defaults)
raw_track_name = track_names[idx] if idx < len(track_names) else Path(fname).stem
track_name = _safe_track_name(raw_track_name) or Path(fname).stem or f"Track {idx + 1}"
page_num = page_numbers[idx] if idx < len(page_numbers) else None
if page_num == 0:
page_num = None
sort_ord = sort_orders[idx] if idx < len(sort_orders) else (idx + 1)
# Determine next sort order if not supplied
if not sort_orders:
existing_count = db.query(PDFAudio).filter(PDFAudio.pdf_id == pdf_id).count()
sort_ord = existing_count + idx + 1
audio = PDFAudio(
pdf_id=pdf_id,
file_path=str(dest),
track_name=track_name,
page_number=page_num,
sort_order=sort_ord,
)
db.add(audio)
db.commit()
db.refresh(audio)
results.append(AudioUploadResult(filename=fname, success=True, audio=AudioOut.model_validate(audio)))
return results
# ── DELETE /api/pdfs/{pdf_id}/audio/{audio_id} ───────────────────────────────
@router.delete("/{pdf_id}/audio/{audio_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_audio(
pdf_id: int,
audio_id: int,
current_user=Depends(require_role(UserRole.admin, UserRole.teacher)),
db: Session = Depends(get_db),
):
track = db.query(PDFAudio).filter(PDFAudio.id == audio_id, PDFAudio.pdf_id == pdf_id).first()
if not track:
raise HTTPException(status_code=404, detail="Audio track not found.")
file_path = Path(track.file_path)
db.delete(track)
db.commit()
if file_path.exists():
file_path.unlink(missing_ok=True)
# ── GET /api/pdfs/{pdf_id}/audio/{audio_id}/file ─────────────────────────────
@router.get("/{pdf_id}/audio/{audio_id}/file")
def serve_audio(
pdf_id: int,
audio_id: int,
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
):
track = db.query(PDFAudio).filter(PDFAudio.id == audio_id, PDFAudio.pdf_id == pdf_id).first()
if not track:
raise HTTPException(status_code=404, detail="Audio track not found.")
file_path = Path(track.file_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found on disk.")
# Determine media type from extension
ext = file_path.suffix.lower()
media_type_map = {".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".ogg": "audio/ogg", ".wav": "audio/wav", ".aac": "audio/aac"}
media_type = media_type_map.get(ext, "audio/mpeg")
return FileResponse(
path=str(file_path),
media_type=media_type,
filename=f"{track.track_name}{ext}",
)