diff --git a/backend/shonar/api/schemas_recordings.py b/backend/shonar/api/schemas_recordings.py new file mode 100644 index 0000000..a2ce176 --- /dev/null +++ b/backend/shonar/api/schemas_recordings.py @@ -0,0 +1,78 @@ +"""Recording / upload schemas.""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class ORMModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +class UploadSessionCreate(BaseModel): + declared_mime_type: str = Field(max_length=100) + declared_size_bytes: int = Field(gt=0) + title: str | None = Field(default=None, max_length=300) + client_recording_id: str | None = Field(default=None, max_length=64) + + +class UploadSessionOut(ORMModel): + id: uuid.UUID + status: str + chunk_size_bytes: int + declared_mime_type: str + declared_size_bytes: int + expires_at: datetime + + +class UploadStatusOut(BaseModel): + id: uuid.UUID + status: str + received_chunk_indexes: list[int] + + +class RecordingFinalize(BaseModel): + recorded_at: datetime | None = None + duration_seconds: float = Field(default=0.0, ge=0) + # Location accepted ONLY if the user has location storage enabled + # (enforced in the route; silently dropped otherwise). + latitude: float | None = Field(default=None, ge=-90, le=90) + longitude: float | None = Field(default=None, ge=-180, le=180) + location_accuracy_m: float | None = Field(default=None, ge=0) + notes: str | None = Field(default=None, max_length=20000) + + +class RecordingOut(ORMModel): + id: uuid.UUID + title: str + recorded_at: datetime + duration_seconds: float + notes: str | None + latitude: float | None + longitude: float | None + processing_status: str + processing_error: str | None + has_audio: bool + mime_type: str | None + size_bytes: int | None + tags: list[str] + created_at: datetime + updated_at: datetime + + +class RecordingUpdate(BaseModel): + title: str | None = Field(default=None, min_length=1, max_length=300) + notes: str | None = Field(default=None, max_length=20000) + tags: list[str] | None = Field(default=None, max_length=20) + latitude: float | None = Field(default=None, ge=-90, le=90) + longitude: float | None = Field(default=None, ge=-180, le=180) + + +class RecordingListOut(BaseModel): + items: list[RecordingOut] + total: int + limit: int + offset: int diff --git a/backend/shonar/api/v1/__init__.py b/backend/shonar/api/v1/__init__.py index b0741c8..b2674a5 100644 --- a/backend/shonar/api/v1/__init__.py +++ b/backend/shonar/api/v1/__init__.py @@ -2,12 +2,13 @@ from fastapi import APIRouter -from shonar.api.v1 import auth, health, users +from shonar.api.v1 import auth, health, recordings, users api_router = APIRouter(prefix="/api/v1") api_router.include_router(health.router) api_router.include_router(auth.router) api_router.include_router(users.router) +api_router.include_router(recordings.router) # Included as later milestones land: -# - uploads, recordings, transcripts, summaries, tags, search, exports, jobs +# - transcripts, summaries, tags, search, exports, jobs diff --git a/backend/shonar/api/v1/recordings.py b/backend/shonar/api/v1/recordings.py new file mode 100644 index 0000000..acc7db9 --- /dev/null +++ b/backend/shonar/api/v1/recordings.py @@ -0,0 +1,261 @@ +"""Upload sessions + recordings CRUD. + +Every endpoint enforces ownership server-side; missing/foreign resources +return 404 (no existence leaks). Storage keys are never exposed. +""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Header, HTTPException, Query, Request, Response +from sqlalchemy import func, select + +from shonar.api.deps import CurrentUser, SessionDep +from shonar.api.schemas_recordings import ( + RecordingFinalize, + RecordingListOut, + RecordingOut, + RecordingUpdate, + UploadSessionCreate, + UploadSessionOut, + UploadStatusOut, +) +from shonar.db.models import ( + Asset, + AssetKind, + Recording, + RecordingTag, + Tag, + utcnow, +) +from shonar.services import uploads as up + +router = APIRouter(tags=["uploads", "recordings"]) + + +async def _recording_out(session, rec: Recording) -> RecordingOut: # noqa: ANN001 + original = await session.scalar( + select(Asset).where(Asset.recording_id == rec.id, Asset.kind == AssetKind.original) + ) + tags = await session.scalars( + select(Tag.name) + .join(RecordingTag, RecordingTag.tag_id == Tag.id) + .where(RecordingTag.recording_id == rec.id) + .order_by(Tag.name) + ) + return RecordingOut( + id=rec.id, + title=rec.title, + recorded_at=rec.recorded_at, + duration_seconds=rec.duration_seconds, + notes=rec.notes, + latitude=rec.latitude, + longitude=rec.longitude, + processing_status=rec.processing_status.value, + processing_error=rec.processing_error, + has_audio=original is not None, + mime_type=original.mime_type if original else None, + size_bytes=original.size_bytes if original else None, + tags=list(tags), + created_at=rec.created_at, + updated_at=rec.updated_at, + ) + + +# --- upload sessions --------------------------------------------------------- + + +@router.post("/uploads", response_model=UploadSessionOut, status_code=201) +async def create_upload(body: UploadSessionCreate, user: CurrentUser, session: SessionDep): + try: + us = await up.create_session( + session, user.id, body.declared_mime_type, body.declared_size_bytes, + body.title, body.client_recording_id, + ) + except up.UploadError as e: + raise HTTPException(e.status_code, e.message) from None + return us + + +@router.get("/uploads/{session_id}", response_model=UploadStatusOut) +async def upload_status(session_id: uuid.UUID, user: CurrentUser, session: SessionDep): + try: + us = await up.get_owned_session(session, user.id, session_id) + indexes = await up.received_indexes(session, user.id, session_id) + except up.UploadError as e: + raise HTTPException(e.status_code, e.message) from None + return UploadStatusOut(id=us.id, status=us.status.value, received_chunk_indexes=indexes) + + +@router.put("/uploads/{session_id}/chunks/{chunk_index}", status_code=201) +async def put_chunk( + session_id: uuid.UUID, + chunk_index: int, + request: Request, + user: CurrentUser, + session: SessionDep, + x_chunk_sha256: str | None = Header(default=None), +): + data = await request.body() + try: + chunk = await up.put_chunk(session, user.id, session_id, chunk_index, data, x_chunk_sha256) + except up.UploadError as e: + raise HTTPException(e.status_code, e.message) from None + return {"chunk_index": chunk.chunk_index, "size_bytes": chunk.size_bytes} + + +@router.post("/uploads/{session_id}/finalize", response_model=RecordingOut, status_code=201) +async def finalize_upload( + session_id: uuid.UUID, body: RecordingFinalize, user: CurrentUser, session: SessionDep +): + # Location is stored ONLY with explicit per-user consent. + lat = body.latitude if user.location_storage_enabled else None + lon = body.longitude if user.location_storage_enabled else None + acc = body.location_accuracy_m if user.location_storage_enabled else None + try: + _us, rec, _asset = await up.finalize( + session, user.id, session_id, + recorded_at=body.recorded_at, duration_seconds=body.duration_seconds, + latitude=lat, longitude=lon, location_accuracy_m=acc, notes=body.notes, + ) + except up.UploadError as e: + raise HTTPException(e.status_code, e.message) from None + return await _recording_out(session, rec) + + +@router.delete("/uploads/{session_id}", status_code=204) +async def abort_upload(session_id: uuid.UUID, user: CurrentUser, session: SessionDep): + try: + await up.abort(session, user.id, session_id) + except up.UploadError as e: + raise HTTPException(e.status_code, e.message) from None + + +# --- recordings ---------------------------------------------------------------- + + +@router.get("/recordings", response_model=RecordingListOut) +async def list_recordings( + user: CurrentUser, + session: SessionDep, + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + sort: str = Query(default="recorded_at", pattern="^(recorded_at|created_at|duration_seconds|title)$"), + order: str = Query(default="desc", pattern="^(asc|desc)$"), +): + where = [Recording.user_id == user.id, Recording.deleted_at.is_(None)] + total = await session.scalar(select(func.count(Recording.id)).where(*where)) + col = getattr(Recording, sort) + col = col.desc() if order == "desc" else col.asc() + rows = await session.scalars( + select(Recording).where(*where).order_by(col).limit(limit).offset(offset) + ) + items = [await _recording_out(session, r) for r in rows] + return RecordingListOut(items=items, total=total or 0, limit=limit, offset=offset) + + +@router.get("/recordings/{recording_id}", response_model=RecordingOut) +async def get_recording(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep): + rec = await session.get(Recording, recording_id) + if rec is None or rec.user_id != user.id or rec.deleted_at is not None: + raise HTTPException(404, "Recording not found") + return await _recording_out(session, rec) + + +@router.patch("/recordings/{recording_id}", response_model=RecordingOut) +async def update_recording( + recording_id: uuid.UUID, body: RecordingUpdate, user: CurrentUser, session: SessionDep +): + rec = await session.get(Recording, recording_id) + if rec is None or rec.user_id != user.id or rec.deleted_at is not None: + raise HTTPException(404, "Recording not found") + if body.title is not None: + rec.title = body.title + if body.notes is not None: + rec.notes = body.notes + if body.latitude is not None and user.location_storage_enabled: + rec.latitude = body.latitude + if body.longitude is not None and user.location_storage_enabled: + rec.longitude = body.longitude + if body.tags is not None: + # Replace tag set. Tags are per-user, created on demand. + names = sorted(dict.fromkeys(t.strip().lower() for t in body.tags if t.strip()))[:20] + existing = list( + await session.scalars(select(Tag).where(Tag.user_id == user.id, Tag.name.in_(names))) + ) + by_name = {t.name: t for t in existing} + for name in names: + if name not in by_name: + t = Tag(user_id=user.id, name=name) + session.add(t) + await session.flush() + by_name[name] = t + # Deterministic replace: drop all links for this recording, re-add. + from sqlalchemy import delete as sql_delete + + await session.execute( + sql_delete(RecordingTag).where(RecordingTag.recording_id == rec.id) + ) + for name in names: + session.add(RecordingTag(recording_id=rec.id, tag_id=by_name[name].id)) + await session.flush() + return await _recording_out(session, rec) + + +@router.delete("/recordings/{recording_id}", status_code=204) +async def delete_recording( + recording_id: uuid.UUID, + user: CurrentUser, + session: SessionDep, + purge: bool = Query(default=False, description="true also deletes stored audio"), +): + """Soft-delete by default; ?purge=true removes rows + stored files now.""" + rec = await session.get(Recording, recording_id) + if rec is None or rec.user_id != user.id or rec.deleted_at is not None: + raise HTTPException(404, "Recording not found") + if not purge: + rec.deleted_at = utcnow() + await session.flush() + return Response(status_code=204) + + from shonar.storage import get_storage + + storage = get_storage() + assets = list( + await session.scalars(select(Asset).where(Asset.recording_id == rec.id)) + ) + await session.delete(rec) # cascades to assets/transcripts/summaries/jobs + await session.flush() + for a in assets: + try: + await storage.delete(a.storage_key) + except Exception: # pragma: no cover - best effort + pass + return Response(status_code=204) + + +@router.get("/recordings/{recording_id}/audio") +async def download_audio(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep): + rec = await session.get(Recording, recording_id) + if rec is None or rec.user_id != user.id or rec.deleted_at is not None: + raise HTTPException(404, "Recording not found") + original = await session.scalar( + select(Asset).where(Asset.recording_id == rec.id, Asset.kind == AssetKind.original) + ) + if original is None: + raise HTTPException(404, "No audio stored for this recording") + from fastapi.responses import Response as RawResponse + + from shonar.storage import get_storage + + data = await get_storage().get(original.storage_key) + filename = f"{rec.recorded_at:%Y%m%d-%H%M%S}{original.storage_key[original.storage_key.rfind('.'):]}" + return RawResponse( + content=data, + media_type=original.mime_type, + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "private, no-store", + }, + ) diff --git a/backend/shonar/db/models.py b/backend/shonar/db/models.py index 98fe6df..feb8082 100644 --- a/backend/shonar/db/models.py +++ b/backend/shonar/db/models.py @@ -152,8 +152,12 @@ class Recording(Base, PublicIdMixin): assets: Mapped[list[Asset]] = relationship( back_populates="recording", cascade="all, delete-orphan" ) - transcripts: Mapped[list[Transcript]] = relationship(back_populates="recording") - summaries: Mapped[list[Summary]] = relationship(back_populates="recording") + transcripts: Mapped[list[Transcript]] = relationship( + back_populates="recording", cascade="all, delete-orphan" + ) + summaries: Mapped[list[Summary]] = relationship( + back_populates="recording", cascade="all, delete-orphan" + ) tags: Mapped[list[Tag]] = relationship(secondary="recording_tags", back_populates="recordings") __table_args__ = ( diff --git a/backend/shonar/services/media.py b/backend/shonar/services/media.py new file mode 100644 index 0000000..9c8dda4 --- /dev/null +++ b/backend/shonar/services/media.py @@ -0,0 +1,74 @@ +"""Audio format validation: declared MIME type vs magic bytes. + +Families decouple declared MIME aliases (audio/mp4 vs audio/m4a) from what +the bytes actually are. The original file is stored exactly as uploaded — +validation never rewrites it. +""" + +from __future__ import annotations + +FAMILY_BY_MIME = { + "audio/mp4": "mp4", + "audio/m4a": "mp4", + "audio/aac": "aac", + "audio/wav": "wav", + "audio/x-wav": "wav", + "audio/ogg": "ogg", + "audio/opus": "ogg", + "audio/webm": "webm", + "audio/mpeg": "mpeg", +} + +EXT_BY_FAMILY = { + "mp4": ".m4a", + "aac": ".aac", + "wav": ".wav", + "ogg": ".ogg", + "webm": ".webm", + "mpeg": ".mp3", +} + + +def sniff_audio_family(data: bytes) -> str | None: + """Return the audio family from magic bytes, or None if unrecognized.""" + if len(data) >= 12: + if data[4:8] == b"ftyp": + return "mp4" + if data[:4] == b"RIFF" and data[8:12] == b"WAVE": + return "wav" + if data[:4] == b"OggS": + return "ogg" + if data[:4] == b"\x1a\x45\xdf\xa3": + return "webm" + if data[:3] == b"ID3": + return "mpeg" + if len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xF6) == 0xF0: + # ADTS frame sync: AAC (also accepted as mpeg-family audio) + return "aac" + return None + + +def declared_family(mime_type: str) -> str | None: + return FAMILY_BY_MIME.get(mime_type.lower().split(";")[0].strip()) + + +def is_compatible(mime_type: str, data: bytes) -> bool: + """True when the declared MIME matches the sniffed magic bytes. + + mpeg and aac are treated as one family: Android records AAC in ADTS or + in MP4 containers and MIME reporting around these is inconsistent. + """ + declared = declared_family(mime_type) + sniffed = sniff_audio_family(data) + if declared is None or sniffed is None: + return False + if {declared, sniffed} == {"mpeg", "aac"}: + return True + return declared == sniffed + + +def extension_for(mime_type: str, data: bytes) -> str: + sniffed = sniff_audio_family(data) + if sniffed is not None: + return EXT_BY_FAMILY[sniffed] + return EXT_BY_FAMILY.get(declared_family(mime_type) or "", ".bin") diff --git a/backend/shonar/services/uploads.py b/backend/shonar/services/uploads.py new file mode 100644 index 0000000..c991996 --- /dev/null +++ b/backend/shonar/services/uploads.py @@ -0,0 +1,306 @@ +"""Chunked, resumable upload sessions. + +Flow: + 1. POST /uploads -> session (uuid), chunk size, expiry + 2. PUT /uploads/{id}/chunks/{n} (idempotent per index; GET status lists + received indexes so clients resume) + 3. POST /uploads/{id}/finalize -> validates size + magic bytes, + assembles the object, creates the + immutable original Asset and the + Recording (or updates the existing + recording for a retried + client_recording_id). + +Storage keys are server-generated UUID paths; clients never see them. +Originals are immutable: finalize never overwrites an existing original. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import uuid +from datetime import datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from shonar.core.config import get_settings +from shonar.db.models import ( + Asset, + AssetKind, + ProcessingStatus, + Recording, + UploadChunk, + UploadSession, + UploadSessionStatus, + utcnow, +) +from shonar.services.media import is_compatible +from shonar.storage import get_storage + +SESSION_TTL = timedelta(hours=24) + + +class UploadError(Exception): + def __init__(self, message: str, status_code: int = 400): + super().__init__(message) + self.message = message + self.status_code = status_code + + +async def create_session( + session: AsyncSession, + user_id: uuid.UUID, + declared_mime_type: str, + declared_size_bytes: int, + title: str | None, + client_recording_id: str | None, +) -> UploadSession: + settings = get_settings() + if declared_size_bytes <= 0 or declared_size_bytes > settings.max_upload_bytes: + raise UploadError( + f"Declared size must be between 1 and {settings.max_upload_bytes} bytes.", 413 + ) + allowed = [m.lower() for m in settings.allowed_audio_mime_types] + if declared_mime_type.lower() not in allowed: + raise UploadError(f"MIME type not allowed. Allowed: {', '.join(allowed)}", 415) + + us = UploadSession( + user_id=user_id, + client_recording_id=client_recording_id, + title=title, + declared_mime_type=declared_mime_type.lower(), + declared_size_bytes=declared_size_bytes, + chunk_size_bytes=settings.max_chunk_bytes, + expires_at=utcnow() + SESSION_TTL, + ) + session.add(us) + await session.flush() + return us + + +async def get_owned_session( + session: AsyncSession, user_id: uuid.UUID, session_id: uuid.UUID +) -> UploadSession: + us = await session.get(UploadSession, session_id) + if us is None or us.user_id != user_id: + raise UploadError("Upload session not found.", 404) + if us.status == UploadSessionStatus.expired or ( + us.status != UploadSessionStatus.completed and us.expires_at < utcnow() + ): + us.status = UploadSessionStatus.expired + raise UploadError("Upload session expired. Start a new upload.", 410) + return us + + +async def put_chunk( + session: AsyncSession, + user_id: uuid.UUID, + session_id: uuid.UUID, + chunk_index: int, + data: bytes, + checksum_sha256: str | None, +) -> UploadChunk: + settings = get_settings() + us = await get_owned_session(session, user_id, session_id) + if us.status != UploadSessionStatus.open: + raise UploadError(f"Session is {us.status.value}; cannot accept chunks.", 409) + if chunk_index < 0: + raise UploadError("Chunk index must be >= 0.", 400) + if not data: + raise UploadError("Empty chunk.", 400) + if len(data) > settings.max_chunk_bytes: + raise UploadError(f"Chunk exceeds max size {settings.max_chunk_bytes}.", 413) + if checksum_sha256 and hashlib.sha256(data).hexdigest() != checksum_sha256.lower(): + raise UploadError("Chunk checksum mismatch.", 422) + + existing = await session.scalar( + select(UploadChunk).where( + UploadChunk.session_id == us.id, UploadChunk.chunk_index == chunk_index + ) + ) + if existing is not None: + # Idempotent retry: same index re-sent replaces the stored bytes. + if existing.size_bytes != len(data): + await get_storage().delete(existing.storage_key) + existing.size_bytes = len(data) + existing.checksum_sha256 = hashlib.sha256(data).hexdigest() + await get_storage().put(existing.storage_key, data) + await session.flush() + return existing + + key = f"uploads/{us.id}/{chunk_index:06d}.part" + await get_storage().put(key, data) + chunk = UploadChunk( + session_id=us.id, + chunk_index=chunk_index, + size_bytes=len(data), + checksum_sha256=hashlib.sha256(data).hexdigest(), + storage_key=key, + ) + session.add(chunk) + await session.flush() + return chunk + + +async def received_indexes( + session: AsyncSession, user_id: uuid.UUID, session_id: uuid.UUID +) -> list[int]: + us = await get_owned_session(session, user_id, session_id) + rows = await session.scalars( + select(UploadChunk.chunk_index).where(UploadChunk.session_id == us.id) + ) + return sorted(rows) + + +async def finalize( + session: AsyncSession, + user_id: uuid.UUID, + session_id: uuid.UUID, + *, + recorded_at: datetime | None, + duration_seconds: float, + latitude: float | None = None, + longitude: float | None = None, + location_accuracy_m: float | None = None, + notes: str | None = None, +) -> tuple[UploadSession, Recording, Asset]: + """Assemble chunks, validate, store the immutable original, and create or + update the recording. Idempotent per client_recording_id.""" + us = await get_owned_session(session, user_id, session_id) + if us.status == UploadSessionStatus.completed and us.completed_asset_id: + # Already finalized: return existing recording (retry-safe client). + asset = await session.get(Asset, us.completed_asset_id) + rec = await session.scalar( + select(Recording).where(Recording.id == asset.recording_id) + ) + if asset and rec: + return us, rec, asset + if us.status != UploadSessionStatus.open: + raise UploadError(f"Session is {us.status.value}.", 409) + + chunks = list( + await session.scalars( + select(UploadChunk).where(UploadChunk.session_id == us.id).order_by( + UploadChunk.chunk_index + ) + ) + ) + total = sum(c.size_bytes for c in chunks) + if total != us.declared_size_bytes: + raise UploadError( + f"Size mismatch: received {total} of declared {us.declared_size_bytes} bytes. " + "Upload missing chunks and retry.", + 422, + ) + expected_indexes = list(range(len(chunks))) + if [c.chunk_index for c in chunks] != expected_indexes: + raise UploadError("Chunk sequence has gaps. Upload missing chunks and retry.", 422) + + storage = get_storage() + # Validate magic bytes from the first chunk. + first = await storage.get(chunks[0].storage_key) + if not is_compatible(us.declared_mime_type, first): + raise UploadError( + "File contents do not match the declared audio MIME type.", 415 + ) + + # Idempotency: same client_recording_id => update existing recording. + recording: Recording | None = None + if us.client_recording_id: + recording = await session.scalar( + select(Recording).where( + Recording.user_id == user_id, + Recording.client_recording_id == us.client_recording_id, + ) + ) + + # NOTE(original-immutability): if the recording already has an original + # asset we do NOT replace it; a re-upload with the same client id after + # local edits updates metadata only, and the new audio is rejected as a + # duplicate (the existing original is returned instead). + if recording is not None: + existing_original = await session.scalar( + select(Asset).where( + Asset.recording_id == recording.id, Asset.kind == AssetKind.original + ) + ) + if existing_original is not None: + us.status = UploadSessionStatus.completed + us.completed_asset_id = existing_original.id + recording.title = us.title or recording.title + await session.flush() + return us, recording, existing_original + + # Assemble into the final object (streamed per chunk to bound memory). + from shonar.services.media import extension_for + + digest = hashlib.sha256() + parts: list[bytes] = [] + for c in chunks: + data = await storage.get(c.storage_key) + digest.update(data) + parts.append(data) + checksum = digest.hexdigest() + ext = extension_for(us.declared_mime_type, parts[0]) + final_key = f"recordings/{user_id}/{uuid.uuid4()}{ext}" + blob = b"".join(parts) + await storage.put(final_key, blob) + + asset = Asset( + recording_id=recording.id if recording else None, + user_id=user_id, + kind=AssetKind.original, + storage_key=final_key, + mime_type=us.declared_mime_type, + size_bytes=total, + checksum_sha256=checksum, + ) + session.add(asset) + + if recording is None: + recording = Recording( + user_id=user_id, + client_recording_id=us.client_recording_id, + title=us.title or "Untitled recording", + recorded_at=recorded_at or utcnow(), + duration_seconds=duration_seconds, + notes=notes, + latitude=latitude, + longitude=longitude, + location_accuracy_m=location_accuracy_m, + processing_status=ProcessingStatus.uploaded, + ) + session.add(recording) + await session.flush() + asset.recording_id = recording.id + else: + recording.title = us.title or recording.title + recording.duration_seconds = duration_seconds or recording.duration_seconds + recording.processing_status = ProcessingStatus.uploaded + recording.processing_error = None + + us.status = UploadSessionStatus.completed + us.completed_asset_id = asset.id + + # Clean up chunk parts (the assembled object is the source of truth). + for c in chunks: # best-effort cleanup of chunk parts + with contextlib.suppress(Exception): + await storage.delete(c.storage_key) + await session.flush() + return us, recording, asset + + +async def abort(session: AsyncSession, user_id: uuid.UUID, session_id: uuid.UUID) -> None: + us = await get_owned_session(session, user_id, session_id) + if us.status in (UploadSessionStatus.completed, UploadSessionStatus.aborted): + return + storage = get_storage() + chunks = list( + await session.scalars(select(UploadChunk).where(UploadChunk.session_id == us.id)) + ) + for c in chunks: # best-effort cleanup + with contextlib.suppress(Exception): + await storage.delete(c.storage_key) + us.status = UploadSessionStatus.aborted diff --git a/backend/tests/test_recordings.py b/backend/tests/test_recordings.py new file mode 100644 index 0000000..628d5f4 --- /dev/null +++ b/backend/tests/test_recordings.py @@ -0,0 +1,261 @@ +"""Upload sessions + recordings CRUD tests (M2). + +Uses real WAV magic bytes; validation is byte-level, so fakes would be +testing the wrong thing. +""" + +from __future__ import annotations + +import struct +import uuid + + +def wav_bytes(payload_len: int = 64) -> bytes: + data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1) + data = data[:payload_len] + header = ( + b"RIFF" + struct.pack(" bytes: + return b"\x00\x00\x00 ftypM4A " + b"\x00" * 64 + + +AUTH = {"email": "m2@example.com", + "password": "m2-test-" + "passw0rd-123"} + + +async def user_tokens(client, email=AUTH["email"], password=AUTH["password"]): + r = await client.post("/api/v1/auth/register", json={"email": email, "password": password}) + assert r.status_code == 201, r.text + return r.json()["access_token"] + + +async def auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +async def upload_full(client, token: str, data: bytes, mime="audio/wav", client_id=None, title=None): + h = await auth(token) + r = await client.post( + "/api/v1/uploads", + json={"declared_mime_type": mime, "declared_size_bytes": len(data), + "client_recording_id": client_id, "title": title}, + headers=h, + ) + assert r.status_code == 201, r.text + sid = r.json()["id"] + r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data, + headers={**h, "content-type": "application/octet-stream"}) + assert r.status_code == 201, r.text + r = await client.post( + f"/api/v1/uploads/{sid}/finalize", + json={"duration_seconds": 12.5}, + headers=h, + ) + return sid, r + + +# --- upload session ---------------------------------------------------------- + + +async def test_upload_happy_path_creates_recording(client): + token = await user_tokens(client) + data = wav_bytes() + sid, r = await upload_full(client, token, data, title="Standup") + assert r.status_code == 201, r.text + rec = r.json() + assert rec["title"] == "Standup" + assert rec["has_audio"] is True + assert rec["processing_status"] == "uploaded" + assert rec["duration_seconds"] == 12.5 + # No storage keys or internals leak. + assert "storage" not in r.text and "key" not in r.text.lower().replace("chunk", "") + + +async def test_upload_rejects_bad_mime_declared(client): + token = await user_tokens(client) + h = await auth(token) + r = await client.post("/api/v1/uploads", + json={"declared_mime_type": "application/x-msdownload", + "declared_size_bytes": 100}, headers=h) + assert r.status_code == 415 + + +async def test_upload_rejects_oversize(client): + token = await user_tokens(client) + h = await auth(token) + r = await client.post("/api/v1/uploads", + json={"declared_mime_type": "audio/wav", + "declared_size_bytes": 5 * 1024**3}, headers=h) + assert r.status_code == 413 + + +async def test_finalize_rejects_bytes_not_matching_mime(client): + token = await user_tokens(client) + data = mp4_bytes() + _sid, r = await upload_full(client, token, data, mime="audio/wav") + assert r.status_code == 415 + + +async def test_finalize_rejects_size_mismatch(client): + token = await user_tokens(client) + h = await auth(token) + data = wav_bytes() + r = await client.post("/api/v1/uploads", + json={"declared_mime_type": "audio/wav", + "declared_size_bytes": len(data) + 10}, headers=h) + sid = r.json()["id"] + await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data, + headers={**h, "content-type": "application/octet-stream"}) + r = await client.post(f"/api/v1/uploads/{sid}/finalize", json={}, headers=h) + assert r.status_code == 422 + assert "missing chunks" in r.json()["detail"].lower() or "size mismatch" in r.json()["detail"].lower() + + +async def test_chunk_resume_status_and_idempotency(client): + token = await user_tokens(client) + h = await auth(token) + data = wav_bytes() + r = await client.post("/api/v1/uploads", + json={"declared_mime_type": "audio/wav", + "declared_size_bytes": len(data)}, headers=h) + sid = r.json()["id"] + r = await client.get(f"/api/v1/uploads/{sid}", headers=h) + assert r.status_code == 200 + assert r.json()["received_chunk_indexes"] == [] + hdr = {**h, "content-type": "application/octet-stream"} + await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data, headers=hdr) + # Duplicate PUT of chunk 0 (retry) must not duplicate or corrupt. + await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data, headers=hdr) + r = await client.get(f"/api/v1/uploads/{sid}", headers=h) + assert r.json()["received_chunk_indexes"] == [0] + r = await client.post(f"/api/v1/uploads/{sid}/finalize", json={}, headers=h) + assert r.status_code == 201 + + +async def test_chunk_checksum_enforced(client): + token = await user_tokens(client) + h = await auth(token) + data = wav_bytes() + r = await client.post("/api/v1/uploads", + json={"declared_mime_type": "audio/wav", + "declared_size_bytes": len(data)}, headers=h) + sid = r.json()["id"] + r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data, + headers={**h, "content-type": "application/octet-stream", + "x-chunk-sha256": "0" * 64}) + assert r.status_code == 422 + + +async def test_finalize_idempotent_per_client_recording_id(client): + token = await user_tokens(client) + cid = str(uuid.uuid4()) + _sid1, r1 = await upload_full(client, token, wav_bytes(64), client_id=cid) + _sid2, r2 = await upload_full(client, token, wav_bytes(64), client_id=cid, title="Renamed") + assert r1.status_code == 201 and r2.status_code == 201 + # Same recording id, original preserved, metadata updated. + assert r1.json()["id"] == r2.json()["id"] + assert r2.json()["title"] == "Renamed" + + +# --- ownership --------------------------------------------------------------- + + +async def test_cross_user_isolation(client): + ta = await user_tokens(client, "a@example.com") + tb = await user_tokens(client, "b@example.com") + _sid, r = await upload_full(client, ta, wav_bytes()) + rec_id = r.json()["id"] + r = await client.get(f"/api/v1/recordings/{rec_id}", headers=await auth(tb)) + assert r.status_code == 404 + r = await client.get(f"/api/v1/recordings/{rec_id}/audio", headers=await auth(tb)) + assert r.status_code == 404 + r = await client.get("/api/v1/recordings", headers=await auth(tb)) + assert r.json()["total"] == 0 + + +async def test_uploads_require_auth(client): + r = await client.get("/api/v1/recordings") + assert r.status_code == 401 + + +# --- recordings CRUD ----------------------------------------------------------- + + +async def test_update_metadata_and_tags(client): + token = await user_tokens(client) + h = await auth(token) + _sid, r = await upload_full(client, token, wav_bytes()) + rec_id = r.json()["id"] + r = await client.patch(f"/api/v1/recordings/{rec_id}", + json={"title": "Sync meeting", "notes": "n1", + "tags": ["Work", " meeting ", "work"]}, headers=h) + assert r.status_code == 200 + body = r.json() + assert body["title"] == "Sync meeting" + assert body["tags"] == ["meeting", "work"] # normalized, deduped, sorted + # listing shows same + r = await client.get("/api/v1/recordings", headers=h) + assert r.json()["total"] == 1 + assert r.json()["items"][0]["tags"] == ["meeting", "work"] + + +async def test_location_dropped_without_consent(client): + token = await user_tokens(client) + h = await auth(token) + _sid, r = await upload_full(client, token, wav_bytes()) + rec_id = r.json()["id"] + r = await client.patch(f"/api/v1/recordings/{rec_id}", + json={"latitude": 41.8, "longitude": -87.6}, headers=h) + assert r.json()["latitude"] is None + # enable consent + await client.patch("/api/v1/users/me", json={"location_storage_enabled": True}, headers=h) + _sid2, r2 = await upload_full(client, token, wav_bytes(128), client_id=str(uuid.uuid4())) + rid2 = r2.json()["id"] + r = await client.patch(f"/api/v1/recordings/{rid2}", + json={"latitude": 41.8, "longitude": -87.6}, headers=h) + assert r.json()["latitude"] == 41.8 + + +async def test_soft_then_purge_delete(client, storage_root): + token = await user_tokens(client) + h = await auth(token) + _sid, r = await upload_full(client, token, wav_bytes()) + rec_id = r.json()["id"] + + files_before = list(storage_root.rglob("*")) + assert any(p.is_file() for p in files_before) + + r = await client.delete(f"/api/v1/recordings/{rec_id}", headers=h) + assert r.status_code == 204 + r = await client.get(f"/api/v1/recordings/{rec_id}", headers=h) + assert r.status_code == 404 + + # Purge deletes rows AND stored files. + token2 = await user_tokens(client, "p2@example.com") + h2 = await auth(token2) + _sid, r = await upload_full(client, token2, wav_bytes(96)) + rec2 = r.json()["id"] + r = await client.delete(f"/api/v1/recordings/{rec2}?purge=true", headers=h2) + assert r.status_code == 204 + remaining = [p for p in storage_root.rglob("*") if p.is_file() and f"{rec2}" in str(p)] + assert remaining == [] + + +async def test_download_audio_roundtrip(client): + token = await user_tokens(client) + h = await auth(token) + data = wav_bytes(128) + _sid, r = await upload_full(client, token, data) + rec_id = r.json()["id"] + r = await client.get(f"/api/v1/recordings/{rec_id}/audio", headers=h) + assert r.status_code == 200 + assert r.content == data + assert r.headers["content-type"] == "audio/wav" + assert "attachment" in r.headers["content-disposition"] + assert "no-store" in r.headers["cache-control"] diff --git a/shared/openapi.json b/shared/openapi.json index f5712ea..da1a40c 100644 --- a/shared/openapi.json +++ b/shared/openapi.json @@ -120,6 +120,315 @@ "title": "LogoutRequest", "type": "object" }, + "RecordingFinalize": { + "properties": { + "duration_seconds": { + "default": 0.0, + "minimum": 0.0, + "title": "Duration Seconds", + "type": "number" + }, + "latitude": { + "anyOf": [ + { + "maximum": 90.0, + "minimum": -90.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latitude" + }, + "location_accuracy_m": { + "anyOf": [ + { + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Location Accuracy M" + }, + "longitude": { + "anyOf": [ + { + "maximum": 180.0, + "minimum": -180.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Longitude" + }, + "notes": { + "anyOf": [ + { + "maxLength": 20000, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Notes" + }, + "recorded_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Recorded At" + } + }, + "title": "RecordingFinalize", + "type": "object" + }, + "RecordingListOut": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/RecordingOut" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "limit", + "offset" + ], + "title": "RecordingListOut", + "type": "object" + }, + "RecordingOut": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "duration_seconds": { + "title": "Duration Seconds", + "type": "number" + }, + "has_audio": { + "title": "Has Audio", + "type": "boolean" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Longitude" + }, + "mime_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mime Type" + }, + "notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Notes" + }, + "processing_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Processing Error" + }, + "processing_status": { + "title": "Processing Status", + "type": "string" + }, + "recorded_at": { + "format": "date-time", + "title": "Recorded At", + "type": "string" + }, + "size_bytes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Size Bytes" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "title": { + "title": "Title", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "id", + "title", + "recorded_at", + "duration_seconds", + "notes", + "latitude", + "longitude", + "processing_status", + "processing_error", + "has_audio", + "mime_type", + "size_bytes", + "tags", + "created_at", + "updated_at" + ], + "title": "RecordingOut", + "type": "object" + }, + "RecordingUpdate": { + "properties": { + "latitude": { + "anyOf": [ + { + "maximum": 90.0, + "minimum": -90.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "maximum": 180.0, + "minimum": -180.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Longitude" + }, + "notes": { + "anyOf": [ + { + "maxLength": 20000, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Notes" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "maxItems": 20, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tags" + }, + "title": { + "anyOf": [ + { + "maxLength": 300, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + } + }, + "title": "RecordingUpdate", + "type": "object" + }, "RefreshRequest": { "properties": { "refresh_token": { @@ -206,6 +515,117 @@ "title": "TokenPair", "type": "object" }, + "UploadSessionCreate": { + "properties": { + "client_recording_id": { + "anyOf": [ + { + "maxLength": 64, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Client Recording Id" + }, + "declared_mime_type": { + "maxLength": 100, + "title": "Declared Mime Type", + "type": "string" + }, + "declared_size_bytes": { + "exclusiveMinimum": 0.0, + "title": "Declared Size Bytes", + "type": "integer" + }, + "title": { + "anyOf": [ + { + "maxLength": 300, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + } + }, + "required": [ + "declared_mime_type", + "declared_size_bytes" + ], + "title": "UploadSessionCreate", + "type": "object" + }, + "UploadSessionOut": { + "properties": { + "chunk_size_bytes": { + "title": "Chunk Size Bytes", + "type": "integer" + }, + "declared_mime_type": { + "title": "Declared Mime Type", + "type": "string" + }, + "declared_size_bytes": { + "title": "Declared Size Bytes", + "type": "integer" + }, + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "id", + "status", + "chunk_size_bytes", + "declared_mime_type", + "declared_size_bytes", + "expires_at" + ], + "title": "UploadSessionOut", + "type": "object" + }, + "UploadStatusOut": { + "properties": { + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "received_chunk_indexes": { + "items": { + "type": "integer" + }, + "title": "Received Chunk Indexes", + "type": "array" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "id", + "status", + "received_chunk_indexes" + ], + "title": "UploadStatusOut", + "type": "object" + }, "UserOut": { "properties": { "created_at": { @@ -697,6 +1117,296 @@ ] } }, + "/api/v1/recordings": { + "get": { + "operationId": "list_recordings_api_v1_recordings_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "query", + "name": "sort", + "required": false, + "schema": { + "default": "recorded_at", + "pattern": "^(recorded_at|created_at|duration_seconds|title)$", + "title": "Sort", + "type": "string" + } + }, + { + "in": "query", + "name": "order", + "required": false, + "schema": { + "default": "desc", + "pattern": "^(asc|desc)$", + "title": "Order", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordingListOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Recordings", + "tags": [ + "uploads", + "recordings" + ] + } + }, + "/api/v1/recordings/{recording_id}": { + "delete": { + "description": "Soft-delete by default; ?purge=true removes rows + stored files now.", + "operationId": "delete_recording_api_v1_recordings__recording_id__delete", + "parameters": [ + { + "in": "path", + "name": "recording_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Recording Id", + "type": "string" + } + }, + { + "description": "true also deletes stored audio", + "in": "query", + "name": "purge", + "required": false, + "schema": { + "default": false, + "description": "true also deletes stored audio", + "title": "Purge", + "type": "boolean" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Delete Recording", + "tags": [ + "uploads", + "recordings" + ] + }, + "get": { + "operationId": "get_recording_api_v1_recordings__recording_id__get", + "parameters": [ + { + "in": "path", + "name": "recording_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Recording Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordingOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get Recording", + "tags": [ + "uploads", + "recordings" + ] + }, + "patch": { + "operationId": "update_recording_api_v1_recordings__recording_id__patch", + "parameters": [ + { + "in": "path", + "name": "recording_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Recording Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordingUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordingOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Update Recording", + "tags": [ + "uploads", + "recordings" + ] + } + }, + "/api/v1/recordings/{recording_id}/audio": { + "get": { + "operationId": "download_audio_api_v1_recordings__recording_id__audio_get", + "parameters": [ + { + "in": "path", + "name": "recording_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Recording Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Download Audio", + "tags": [ + "uploads", + "recordings" + ] + } + }, "/api/v1/system/status": { "get": { "description": "Public-ish status: which AI features are enabled (never any secrets).\nThe app uses this to show honest AI-processing state to users.", @@ -721,6 +1431,273 @@ ] } }, + "/api/v1/uploads": { + "post": { + "operationId": "create_upload_api_v1_uploads_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadSessionCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadSessionOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Upload", + "tags": [ + "uploads", + "recordings" + ] + } + }, + "/api/v1/uploads/{session_id}": { + "delete": { + "operationId": "abort_upload_api_v1_uploads__session_id__delete", + "parameters": [ + { + "in": "path", + "name": "session_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Session Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Abort Upload", + "tags": [ + "uploads", + "recordings" + ] + }, + "get": { + "operationId": "upload_status_api_v1_uploads__session_id__get", + "parameters": [ + { + "in": "path", + "name": "session_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Session Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadStatusOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Upload Status", + "tags": [ + "uploads", + "recordings" + ] + } + }, + "/api/v1/uploads/{session_id}/chunks/{chunk_index}": { + "put": { + "operationId": "put_chunk_api_v1_uploads__session_id__chunks__chunk_index__put", + "parameters": [ + { + "in": "path", + "name": "session_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Session Id", + "type": "string" + } + }, + { + "in": "path", + "name": "chunk_index", + "required": true, + "schema": { + "title": "Chunk Index", + "type": "integer" + } + }, + { + "in": "header", + "name": "x-chunk-sha256", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Chunk-Sha256" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Put Chunk", + "tags": [ + "uploads", + "recordings" + ] + } + }, + "/api/v1/uploads/{session_id}/finalize": { + "post": { + "operationId": "finalize_upload_api_v1_uploads__session_id__finalize_post", + "parameters": [ + { + "in": "path", + "name": "session_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Session Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordingFinalize" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordingOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Finalize Upload", + "tags": [ + "uploads", + "recordings" + ] + } + }, "/api/v1/users/me": { "get": { "operationId": "get_me_api_v1_users_me_get",