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, FilePlacementBackendSummary, FileMutationResponse, FileReplicaSummary, 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, replicas, decision = await service.get_file_detail(file_id) replica_items = [ FileReplicaSummary( id=replica.id, blob_id=replica.blob_id, backend_id=backend.id, backend_name=backend.name, backend_type=backend.type, backend_stability_class=backend.stability_class, backend_enabled=backend.is_enabled, storage_key=replica.storage_key, status=replica.status, size_bytes=replica.size_bytes, checksum=replica.checksum, etag=replica.etag, last_verified_at=replica.last_verified_at, created_at=replica.created_at, updated_at=replica.updated_at, ) for replica, backend in replicas ] ready_replica_backend_ids = sorted({item.backend_id for item in replica_items if item.status == "ready"}) desired_backend_ids = [backend.id for backend in (decision.backends if decision is not None else [])] return FileDetailResponse( file=FileSummary.model_validate(file_entry, from_attributes=True), preview_artifacts=[ PreviewArtifactSummary.model_validate(artifact, from_attributes=True) for artifact in artifacts ], replicas=replica_items, placement_file_class=decision.file_class if decision is not None else None, desired_backends=[ FilePlacementBackendSummary.model_validate(backend, from_attributes=True) for backend in (decision.backends if decision is not None else []) ], ready_replica_backend_ids=ready_replica_backend_ids, missing_backend_ids=[backend_id for backend_id in desired_backend_ids if backend_id not in ready_replica_backend_ids], ) @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", )