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