diff --git a/backend/app/api/recordings.py b/backend/app/api/recordings.py new file mode 100644 index 0000000..14e1d01 --- /dev/null +++ b/backend/app/api/recordings.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index d04dfd3..31dcf94 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,6 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from app.api.auth import router as auth_router +from app.api.recordings import router as recordings_router @asynccontextmanager @@ -12,6 +13,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Meeting Protocol Service", lifespan=lifespan) app.include_router(auth_router) +app.include_router(recordings_router) @app.get("/api/health") diff --git a/backend/app/schemas/recording.py b/backend/app/schemas/recording.py new file mode 100644 index 0000000..ccfc038 --- /dev/null +++ b/backend/app/schemas/recording.py @@ -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 diff --git a/backend/app/services/recording.py b/backend/app/services/recording.py new file mode 100644 index 0000000..1577bb4 --- /dev/null +++ b/backend/app/services/recording.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt index e580269..f746c51 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -36,6 +36,7 @@ markdown==3.7 # Utils ffmpeg-python==0.2.0 httpx==0.28.1 +aiofiles==24.1.0 # Testing pytest==8.3.4 diff --git a/backend/tests/test_recordings.py b/backend/tests/test_recordings.py new file mode 100644 index 0000000..9aa15ae --- /dev/null +++ b/backend/tests/test_recordings.py @@ -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