from __future__ import annotations import math import uuid from os import path from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.ext.asyncio import AsyncSession from cpv3.infrastructure.auth import get_current_user from cpv3.infrastructure.deps import get_storage from cpv3.infrastructure.storage.base import StorageService from cpv3.infrastructure.storage.utils import get_user_folder from cpv3.db.session import get_db from cpv3.modules.media.schemas import ( ArtifactMediaFileCreate, ArtifactMediaFileRead, ArtifactMediaFileUpdate, FrameItem, FrameRangeResponse, MediaConverterParams, MediaFileCreate, MediaFileRead, MediaFileUpdate, MediaProbeSchema, MediaSilencerParams, ) from cpv3.modules.media.service import ( convert_to_mp4, get_frames_folder, probe_media, read_frames_metadata, remove_silence, ) from cpv3.modules.media.repository import ArtifactRepository, MediaFileRepository from cpv3.modules.files.schemas import FileInfoResponse from cpv3.modules.users.models import User media_router = APIRouter(prefix="/api/media", tags=["media"]) mediafiles_router = APIRouter(prefix="/api/media", tags=["mediafiles"]) artifacts_router = APIRouter(prefix="/api/media", tags=["artifacts"]) @media_router.get("/get_meta/", response_model=MediaProbeSchema) async def get_meta( file_path: str = Query(...), current_user: User = Depends(get_current_user), storage: StorageService = Depends(get_storage), ) -> MediaProbeSchema: _ = current_user return await probe_media(storage, file_key=file_path) @media_router.post("/silence_remove", response_model=FileInfoResponse) async def silence_remove( body: MediaSilencerParams, current_user: User = Depends(get_current_user), storage: StorageService = Depends(get_storage), ) -> FileInfoResponse: user_folder = get_user_folder(current_user) resolved_folder = f"{user_folder}/{body.folder}" if body.folder else f"{user_folder}/output_files" info = await remove_silence( storage, file_key=body.file_path, out_folder=resolved_folder, min_silence_duration_ms=body.min_silence_duration_ms, silence_threshold_db=body.silence_threshold_db, padding_ms=body.padding_ms, ) return FileInfoResponse( file_path=info.file_path, file_url=info.file_url, file_size=info.file_size, filename=info.filename, ) @media_router.post("/convert", response_model=FileInfoResponse) async def convert( body: MediaConverterParams, current_user: User = Depends(get_current_user), storage: StorageService = Depends(get_storage), ) -> FileInfoResponse: user_folder = get_user_folder(current_user) resolved_folder = f"{user_folder}/{body.folder}" if body.folder else f"{user_folder}/output_files" info = await convert_to_mp4(storage, file_key=body.file_path, out_folder=resolved_folder) return FileInfoResponse( file_path=info.file_path, file_url=info.file_url, file_size=info.file_size, filename=info.filename, ) @media_router.get("/frames/", response_model=FrameRangeResponse) async def get_frames( file_key: str = Query(..., description="S3 key of the source video"), start: float = Query(0.0, ge=0, description="Start time in seconds"), end: float = Query(..., gt=0, description="End time in seconds"), current_user: User = Depends(get_current_user), storage: StorageService = Depends(get_storage), ) -> FrameRangeResponse: """Return presigned URLs for extracted frames within a time range.""" user_folder = get_user_folder(current_user) frames_folder = get_frames_folder(user_folder, file_key) metadata = await read_frames_metadata(storage, frames_folder=frames_folder) if metadata is None: return FrameRangeResponse(interval=1.0, frames=[]) interval = metadata.interval first_index = max(1, math.floor(start / interval) + 1) last_index = min(metadata.frame_count, math.ceil(end / interval) + 1) frames: list[FrameItem] = [] for i in range(first_index, last_index + 1): key = path.join(frames_folder, f"{i:06d}.jpg") timestamp = (i - 1) * interval url = await storage.url(key) frames.append(FrameItem(timestamp=timestamp, url=url)) return FrameRangeResponse(interval=interval, frames=frames) @mediafiles_router.get("/mediafiles/", response_model=list[MediaFileRead]) async def list_mediafiles( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> list[MediaFileRead]: repo = MediaFileRepository(db) items = await repo.list_all(requester=current_user) return [MediaFileRead.model_validate(m) for m in items] @mediafiles_router.post( "/mediafiles/", response_model=MediaFileRead, status_code=status.HTTP_201_CREATED ) async def create_mediafile( body: MediaFileCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> MediaFileRead: repo = MediaFileRepository(db) media_file = await repo.create(requester=current_user, data=body) return MediaFileRead.model_validate(media_file) @mediafiles_router.get("/mediafiles/{media_file_id}/", response_model=MediaFileRead) async def retrieve_mediafile( media_file_id: uuid.UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> MediaFileRead: repo = MediaFileRepository(db) media_file = await repo.get_by_id(media_file_id) if media_file is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") if not current_user.is_staff and media_file.owner_id != current_user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") return MediaFileRead.model_validate(media_file) @mediafiles_router.patch("/mediafiles/{media_file_id}/", response_model=MediaFileRead) async def patch_mediafile( media_file_id: uuid.UUID, body: MediaFileUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> MediaFileRead: repo = MediaFileRepository(db) media_file = await repo.get_by_id(media_file_id) if media_file is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") if not current_user.is_staff and media_file.owner_id != current_user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") media_file = await repo.update(media_file, body) return MediaFileRead.model_validate(media_file) @mediafiles_router.delete("/mediafiles/{media_file_id}/", status_code=status.HTTP_204_NO_CONTENT) async def delete_mediafile( media_file_id: uuid.UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Response: repo = MediaFileRepository(db) media_file = await repo.get_by_id(media_file_id) if media_file is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") if not current_user.is_staff and media_file.owner_id != current_user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") await repo.mark_deleted(media_file) return Response(status_code=status.HTTP_204_NO_CONTENT) @artifacts_router.get("/artifacts/", response_model=list[ArtifactMediaFileRead]) async def list_artifact_mediafiles( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> list[ArtifactMediaFileRead]: _ = current_user repo = ArtifactRepository(db) items = await repo.list_all() return [ArtifactMediaFileRead.model_validate(a) for a in items] @artifacts_router.post( "/artifacts/", response_model=ArtifactMediaFileRead, status_code=status.HTTP_201_CREATED ) async def create_artifact_mediafile( body: ArtifactMediaFileCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ArtifactMediaFileRead: _ = current_user repo = ArtifactRepository(db) artifact = await repo.create(body) return ArtifactMediaFileRead.model_validate(artifact) @artifacts_router.get("/artifacts/{artifact_id}/", response_model=ArtifactMediaFileRead) async def retrieve_artifact_mediafile( artifact_id: uuid.UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ArtifactMediaFileRead: _ = current_user repo = ArtifactRepository(db) artifact = await repo.get_by_id(artifact_id) if artifact is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") return ArtifactMediaFileRead.model_validate(artifact) @artifacts_router.patch("/artifacts/{artifact_id}/", response_model=ArtifactMediaFileRead) async def patch_artifact_mediafile( artifact_id: uuid.UUID, body: ArtifactMediaFileUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> ArtifactMediaFileRead: _ = current_user repo = ArtifactRepository(db) artifact = await repo.get_by_id(artifact_id) if artifact is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") artifact = await repo.update(artifact, body) return ArtifactMediaFileRead.model_validate(artifact) @artifacts_router.delete("/artifacts/{artifact_id}/", status_code=status.HTTP_204_NO_CONTENT) async def delete_artifact_mediafile( artifact_id: uuid.UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Response: _ = current_user repo = ArtifactRepository(db) artifact = await repo.get_by_id(artifact_id) if artifact is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") await repo.mark_deleted(artifact) return Response(status_code=status.HTTP_204_NO_CONTENT)