from typing import AsyncIterator import anyio from fastapi import APIRouter, Depends, Header, Response, status from fastapi.responses import FileResponse, StreamingResponse from app.api.dependencies import get_file_service, get_repair_service from app.schemas.file import ( DeletedFileSummary, FileDetailResponse, FileMutationResponse, FileSummary, MoveFileRequest, PreviewArtifactSummary, RecycleBinResponse, RenameFileRequest, ) from app.services.file_service import FileService from app.schemas.repair import RepairFileResponse from app.services.repair_service import RepairService router = APIRouter() @router.get("/recycle-bin", response_model=RecycleBinResponse) async def list_recycle_bin( service: FileService = Depends(get_file_service), ) -> RecycleBinResponse: items = await service.list_deleted_files() return RecycleBinResponse( items=[DeletedFileSummary.model_validate(item, from_attributes=True) for item in items] ) @router.delete("/{file_id}", response_model=FileMutationResponse) async def delete_file( file_id: str, service: FileService = Depends(get_file_service), ) -> FileMutationResponse: file_entry = await service.delete_file(file_id) return FileMutationResponse(file=DeletedFileSummary.model_validate(file_entry, from_attributes=True)) @router.post("/{file_id}/rename", response_model=FileMutationResponse) async def rename_file( file_id: str, payload: RenameFileRequest, service: FileService = Depends(get_file_service), ) -> FileMutationResponse: file_entry = await service.rename_file(file_id=file_id, name=payload.name) return FileMutationResponse(file=FileSummary.model_validate(file_entry, from_attributes=True)) @router.post("/{file_id}/move", response_model=FileMutationResponse) async def move_file( file_id: str, payload: MoveFileRequest, service: FileService = Depends(get_file_service), ) -> FileMutationResponse: file_entry = await service.move_file(file_id=file_id, target_parent_id=payload.target_parent_id) return FileMutationResponse(file=FileSummary.model_validate(file_entry, from_attributes=True)) @router.post("/{file_id}/restore", response_model=FileMutationResponse) async def restore_file( file_id: str, service: FileService = Depends(get_file_service), ) -> FileMutationResponse: file_entry = await service.restore_file(file_id) return FileMutationResponse(file=FileSummary.model_validate(file_entry, from_attributes=True)) @router.post("/{file_id}/reconcile", response_model=RepairFileResponse) async def reconcile_file( file_id: str, service: RepairService = Depends(get_repair_service), ) -> RepairFileResponse: enqueued = await service.enqueue_repairs_for_file(file_id) return RepairFileResponse(file_id=file_id, enqueued_jobs=enqueued) @router.get("/{file_id}", response_model=FileDetailResponse) async def get_file_detail( file_id: str, service: FileService = Depends(get_file_service), ) -> FileDetailResponse: file_entry, artifacts = await service.get_file_detail(file_id) return FileDetailResponse( file=FileSummary.model_validate(file_entry, from_attributes=True), preview_artifacts=[ PreviewArtifactSummary.model_validate(artifact, from_attributes=True) for artifact in artifacts ], ) @router.get("/{file_id}/download") async def download_file( file_id: str, service: FileService = Depends(get_file_service), ) -> FileResponse: file_entry, file_path = await service.resolve_download(file_id) return FileResponse( path=file_path, filename=file_entry.name, media_type=file_entry.mime_type or "application/octet-stream", ) @router.get("/{file_id}/preview") async def preview_file( file_id: str, service: FileService = Depends(get_file_service), ) -> FileResponse: file_entry, file_path = await service.resolve_preview(file_id) return FileResponse( path=file_path, filename=file_entry.name, media_type=file_entry.mime_type or "application/octet-stream", content_disposition_type="inline", ) @router.get("/{file_id}/stream") async def stream_file( file_id: str, range_header: str | None = Header(default=None, alias="Range"), service: FileService = Depends(get_file_service), ) -> Response: stream_result = await service.resolve_stream(file_id, range_header) chunk_size = 64 * 1024 async def body_iter() -> AsyncIterator[bytes]: remaining = stream_result.end - stream_result.start + 1 async with await anyio.open_file(stream_result.file_path, "rb") as file_handle: await file_handle.seek(stream_result.start) while remaining > 0: chunk = await file_handle.read(min(chunk_size, remaining)) if not chunk: break remaining -= len(chunk) yield chunk headers = { "Accept-Ranges": "bytes", "Content-Length": str(stream_result.end - stream_result.start + 1), } status_code = status.HTTP_200_OK if range_header: headers["Content-Range"] = f"bytes {stream_result.start}-{stream_result.end}/{stream_result.file_size}" status_code = status.HTTP_206_PARTIAL_CONTENT return StreamingResponse( body_iter(), status_code=status_code, headers=headers, media_type=stream_result.file_entry.mime_type or "application/octet-stream", )