feat: recording upload and management API with tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 22:10:42 +05:00
parent bfb1fc9312
commit 2a9cf9e224
6 changed files with 266 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
import os
import uuid
import aiofiles
from fastapi import APIRouter, Depends, HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.config import settings
from app.database import get_db
from app.models.recording import RecordingSource
from app.models.user import User
from app.schemas.recording import RecordingList, RecordingResponse
from app.services.recording import (
create_recording,
get_format_from_filename,
get_media_duration,
get_recording_by_id,
get_recordings,
get_upload_path,
)
router = APIRouter(prefix="/api/recordings", tags=["recordings"])
ALLOWED_FORMATS = {"mp4", "webm", "mp3", "wav", "ogg", "m4a", "flac"}
@router.post("/upload", response_model=RecordingResponse, status_code=201)
async def upload_recording(
file: UploadFile,
title: str = "",
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
fmt = get_format_from_filename(file.filename or "unknown.mp4")
if fmt not in ALLOWED_FORMATS:
raise HTTPException(400, f"Unsupported format: {fmt}")
os.makedirs(settings.upload_dir, exist_ok=True)
file_path = get_upload_path(file.filename or "recording.mp4")
async with aiofiles.open(file_path, "wb") as f:
while chunk := await file.read(1024 * 1024):
await f.write(chunk)
file_size = os.path.getsize(file_path)
duration = get_media_duration(file_path)
final_title = title or file.filename or "Untitled"
recording = await create_recording(
db=db,
user_id=user.id,
title=final_title,
file_path=file_path,
file_size=file_size,
format=fmt,
source=RecordingSource.web,
duration=duration,
)
return recording
@router.get("", response_model=RecordingList)
async def list_recordings(
skip: int = 0,
limit: int = 50,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
items, total = await get_recordings(db, skip=skip, limit=limit)
return RecordingList(items=items, total=total)
@router.get("/{recording_id}", response_model=RecordingResponse)
async def get_recording(
recording_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
recording = await get_recording_by_id(db, recording_id)
if not recording:
raise HTTPException(404, "Recording not found")
return recording
@router.delete("/{recording_id}", status_code=204)
async def delete_recording(
recording_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
recording = await get_recording_by_id(db, recording_id)
if not recording:
raise HTTPException(404, "Recording not found")
if os.path.exists(recording.file_path):
os.remove(recording.file_path)
await db.delete(recording)
await db.commit()
+2
View File
@@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.recordings import router as recordings_router
@asynccontextmanager @asynccontextmanager
@@ -12,6 +13,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Meeting Protocol Service", lifespan=lifespan) app = FastAPI(title="Meeting Protocol Service", lifespan=lifespan)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(recordings_router)
@app.get("/api/health") @app.get("/api/health")
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
from app.models.recording import RecordingSource, RecordingStatus
class RecordingResponse(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
title: str
file_size: int
duration: float | None
format: str
source: RecordingSource
status: RecordingStatus
created_at: datetime
model_config = {"from_attributes": True}
class RecordingList(BaseModel):
items: list[RecordingResponse]
total: int
+76
View File
@@ -0,0 +1,76 @@
import os
import uuid
from pathlib import Path
import ffmpeg
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.recording import Recording, RecordingSource, RecordingStatus
def get_upload_path(filename: str) -> str:
ext = Path(filename).suffix
unique_name = f"{uuid.uuid4().hex}{ext}"
return os.path.join(settings.upload_dir, unique_name)
def get_media_duration(file_path: str) -> float | None:
try:
probe = ffmpeg.probe(file_path)
return float(probe["format"]["duration"])
except Exception:
return None
def get_format_from_filename(filename: str) -> str:
return Path(filename).suffix.lstrip(".").lower()
async def create_recording(
db: AsyncSession,
user_id: uuid.UUID,
title: str,
file_path: str,
file_size: int,
format: str,
source: RecordingSource,
duration: float | None = None,
) -> Recording:
recording = Recording(
user_id=user_id,
title=title,
file_path=file_path,
file_size=file_size,
format=format,
source=source,
duration=duration,
)
db.add(recording)
await db.commit()
await db.refresh(recording)
return recording
async def get_recordings(
db: AsyncSession,
user_id: uuid.UUID | None = None,
skip: int = 0,
limit: int = 50,
) -> tuple[list[Recording], int]:
query = select(Recording).order_by(Recording.created_at.desc())
count_query = select(func.count(Recording.id))
if user_id:
query = query.where(Recording.user_id == user_id)
count_query = count_query.where(Recording.user_id == user_id)
total = (await db.execute(count_query)).scalar()
result = await db.execute(query.offset(skip).limit(limit))
return list(result.scalars().all()), total
async def get_recording_by_id(db: AsyncSession, recording_id: uuid.UUID) -> Recording | None:
result = await db.execute(select(Recording).where(Recording.id == recording_id))
return result.scalar_one_or_none()
+1
View File
@@ -36,6 +36,7 @@ markdown==3.7
# Utils # Utils
ffmpeg-python==0.2.0 ffmpeg-python==0.2.0
httpx==0.28.1 httpx==0.28.1
aiofiles==24.1.0
# Testing # Testing
pytest==8.3.4 pytest==8.3.4
+64
View File
@@ -0,0 +1,64 @@
import io
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_upload_recording(auth_client: AsyncClient):
file_content = b"\x00" * 1024
resp = await auth_client.post(
"/api/recordings/upload",
files={"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")},
data={"title": "Test Meeting"},
)
assert resp.status_code == 201
data = resp.json()
assert data["title"] == "Test Meeting"
assert data["format"] == "mp4"
assert data["source"] == "web"
assert data["status"] == "uploaded"
@pytest.mark.asyncio
async def test_upload_unsupported_format(auth_client: AsyncClient):
resp = await auth_client.post(
"/api/recordings/upload",
files={"file": ("test.exe", io.BytesIO(b"\x00"), "application/octet-stream")},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_list_recordings(auth_client: AsyncClient):
await auth_client.post(
"/api/recordings/upload",
files={"file": ("list_test.mp3", io.BytesIO(b"\x00" * 512), "audio/mp3")},
)
resp = await auth_client.get("/api/recordings")
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 1
assert len(data["items"]) >= 1
@pytest.mark.asyncio
async def test_get_recording_by_id(auth_client: AsyncClient):
upload_resp = await auth_client.post(
"/api/recordings/upload",
files={"file": ("get_test.wav", io.BytesIO(b"\x00" * 256), "audio/wav")},
)
rec_id = upload_resp.json()["id"]
resp = await auth_client.get(f"/api/recordings/{rec_id}")
assert resp.status_code == 200
assert resp.json()["id"] == rec_id
@pytest.mark.asyncio
async def test_delete_recording(auth_client: AsyncClient):
upload_resp = await auth_client.post(
"/api/recordings/upload",
files={"file": ("del_test.ogg", io.BytesIO(b"\x00" * 128), "audio/ogg")},
)
rec_id = upload_resp.json()["id"]
resp = await auth_client.delete(f"/api/recordings/{rec_id}")
assert resp.status_code == 204