You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

189 lines
6.9 KiB

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",
)