hoàn thành tính năng mp3 player

This commit is contained in:
2026-04-03 07:41:10 +07:00
parent 6246481192
commit 34ff7d72df
12 changed files with 731 additions and 28 deletions
@@ -0,0 +1,35 @@
"""add pdf_audios table
Revision ID: 0003_add_pdf_audios
Revises: 0002_add_role_status
Create Date: 2026-04-02
"""
from alembic import op
import sqlalchemy as sa
revision = "0003_add_pdf_audios"
down_revision = "0002_add_role_status"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"pdf_audios",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("pdf_id", sa.Integer(), sa.ForeignKey("pdfs.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("file_path", sa.Text(), nullable=False),
sa.Column("track_name", sa.String(255), nullable=False),
sa.Column("page_number", sa.Integer(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("pdf_audios")
+2 -1
View File
@@ -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)
+17
View File
@@ -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")
+185
View File
@@ -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}",
)
+20
View File
@@ -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