feat: recording upload and management API with tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user