""" 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, AudioPatch, 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 # ── PATCH /api/pdfs/{pdf_id}/audio/{audio_id} ─────────────────────────────── @router.patch("/{pdf_id}/audio/{audio_id}", response_model=AudioOut) def patch_audio( pdf_id: int, audio_id: int, body: AudioPatch, 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.") if body.track_name is not None: track.track_name = body.track_name if body.page_number is not None: track.page_number = body.page_number if body.page_number > 0 else None if body.sort_order is not None: track.sort_order = body.sort_order db.commit() db.refresh(track) return AudioOut.model_validate(track) # ── 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}", )