mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 10:33:11 +07:00
hoàn thành tính năng mp3 player
This commit is contained in:
+2
-1
@@ -4,7 +4,7 @@ import os
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .routers import admin, annotations, auth, backup, pdfs, ws
|
||||
from .routers import admin, annotations, audio, auth, backup, pdfs, ws
|
||||
from .database import SessionLocal
|
||||
from .models import User, UserRole, UserStatus
|
||||
from .security import hash_password
|
||||
@@ -57,5 +57,6 @@ app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
app.include_router(backup.router, prefix="/api")
|
||||
app.include_router(pdfs.router, prefix="/api")
|
||||
app.include_router(audio.router, prefix="/api")
|
||||
app.include_router(annotations.router, prefix="/api")
|
||||
app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path)
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import (
|
||||
ForeignKey,
|
||||
DateTime,
|
||||
UniqueConstraint,
|
||||
Boolean,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
@@ -57,6 +58,7 @@ class PDF(Base):
|
||||
# Relationships
|
||||
owner = relationship("User", back_populates="pdfs")
|
||||
annotations = relationship("Annotation", back_populates="pdf", cascade="all, delete-orphan")
|
||||
audio_tracks = relationship("PDFAudio", back_populates="pdf", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Annotation(Base):
|
||||
@@ -81,3 +83,18 @@ class Annotation(Base):
|
||||
# Relationships
|
||||
pdf = relationship("PDF", back_populates="annotations")
|
||||
owner = relationship("User", back_populates="annotations")
|
||||
|
||||
|
||||
class PDFAudio(Base):
|
||||
__tablename__ = "pdf_audios"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
pdf_id = Column(Integer, ForeignKey("pdfs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
file_path = Column(Text, nullable=False)
|
||||
track_name = Column(String(255), nullable=False)
|
||||
page_number = Column(Integer, nullable=True) # optional: link to a specific page
|
||||
sort_order = Column(Integer, nullable=False, default=1)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
# Relationships
|
||||
pdf = relationship("PDF", back_populates="audio_tracks")
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
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}",
|
||||
)
|
||||
@@ -98,6 +98,26 @@ class AnnotationIn(BaseModel):
|
||||
canvas_data: dict
|
||||
|
||||
|
||||
# ── Audio ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AudioOut(BaseModel):
|
||||
id: int
|
||||
pdf_id: int
|
||||
track_name: str
|
||||
page_number: int | None
|
||||
sort_order: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AudioUploadResult(BaseModel):
|
||||
filename: str
|
||||
success: bool
|
||||
audio: AudioOut | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class AnnotationOut(BaseModel):
|
||||
id: int
|
||||
pdf_id: int
|
||||
|
||||
Reference in New Issue
Block a user