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.
157 lines
5.4 KiB
157 lines
5.4 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,
|
|
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",
|
|
)
|
|
|