feat: download - fase 02
This commit is contained in:
Binary file not shown.
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from backend.app.api.schemas import DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, UploadResponse, ViewResponse
|
||||
from backend.app.dependencies import get_file_ops_service
|
||||
@@ -63,15 +64,18 @@ async def info(
|
||||
|
||||
@router.get("/download")
|
||||
async def download(
|
||||
path: str,
|
||||
path: list[str] = Query(...),
|
||||
service: FileOpsService = Depends(get_file_ops_service),
|
||||
) -> StreamingResponse:
|
||||
prepared = service.prepare_download(path=path)
|
||||
return StreamingResponse(
|
||||
prepared = service.prepare_download(paths=path)
|
||||
response = StreamingResponse(
|
||||
prepared["content"],
|
||||
headers=prepared["headers"],
|
||||
media_type=prepared["content_type"],
|
||||
)
|
||||
if prepared.get("cleanup"):
|
||||
response.background = BackgroundTask(prepared["cleanup"])
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/video")
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from io import BytesIO
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.api.errors import AppError
|
||||
@@ -353,31 +357,18 @@ class FileOpsService:
|
||||
height=metadata["height"],
|
||||
)
|
||||
|
||||
def prepare_download(self, path: str) -> dict:
|
||||
resolved_target = self._path_guard.resolve_existing_path(path)
|
||||
|
||||
if resolved_target.absolute.is_dir():
|
||||
def prepare_download(self, paths: list[str]) -> dict:
|
||||
if not paths:
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Source must be a file",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
if not resolved_target.absolute.is_file():
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Unsupported path type for download",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
code="invalid_request",
|
||||
message="At least one path is required",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
return {
|
||||
"content": self._filesystem.stream_file(resolved_target.absolute),
|
||||
"headers": {
|
||||
"Content-Disposition": f'attachment; filename="{resolved_target.absolute.name}"',
|
||||
},
|
||||
"content_type": self._content_type_for(resolved_target.absolute) or "application/octet-stream",
|
||||
}
|
||||
resolved_targets = [self._path_guard.resolve_existing_path(path) for path in paths]
|
||||
if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_file():
|
||||
return self._prepare_single_file_download(resolved_targets[0])
|
||||
return self._prepare_zip_download(resolved_targets)
|
||||
|
||||
def save(self, path: str, content: str, expected_modified: str) -> SaveResponse:
|
||||
resolved_target = self._path_guard.resolve_existing_path(path)
|
||||
@@ -660,9 +651,104 @@ class FileOpsService:
|
||||
|
||||
@staticmethod
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def _prepare_single_file_download(self, resolved_target) -> dict:
|
||||
_, _, lexical_source, _ = self._path_guard.resolve_lexical_path(resolved_target.relative)
|
||||
if lexical_source.is_symlink():
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Source must not be a symlink",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
return {
|
||||
"content": self._filesystem.stream_file(resolved_target.absolute),
|
||||
"headers": {
|
||||
"Content-Disposition": f'attachment; filename="{resolved_target.absolute.name}"',
|
||||
},
|
||||
"content_type": self._content_type_for(resolved_target.absolute) or "application/octet-stream",
|
||||
}
|
||||
|
||||
def _prepare_zip_download(self, resolved_targets: list) -> dict:
|
||||
archive_names: set[str] = set()
|
||||
for resolved_target in resolved_targets:
|
||||
self._validate_download_target(resolved_target)
|
||||
archive_name = resolved_target.absolute.name
|
||||
if archive_name in archive_names:
|
||||
raise AppError(
|
||||
code="invalid_request",
|
||||
message="Selected items must have distinct top-level names",
|
||||
status_code=400,
|
||||
)
|
||||
archive_names.add(archive_name)
|
||||
|
||||
if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_dir():
|
||||
download_name = f"{resolved_targets[0].absolute.name}.zip"
|
||||
else:
|
||||
download_name = f"kodidownload-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.zip"
|
||||
|
||||
buffer = BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for resolved_target in resolved_targets:
|
||||
self._write_download_target_to_zip(archive, resolved_target)
|
||||
payload = buffer.getvalue()
|
||||
|
||||
async def _stream_zip():
|
||||
yield payload
|
||||
|
||||
return {
|
||||
"content": _stream_zip(),
|
||||
"headers": {
|
||||
"Content-Disposition": f'attachment; filename="{download_name}"',
|
||||
},
|
||||
"content_type": "application/zip",
|
||||
}
|
||||
|
||||
def _validate_download_target(self, resolved_target) -> None:
|
||||
_, _, lexical_source, _ = self._path_guard.resolve_lexical_path(resolved_target.relative)
|
||||
if lexical_source.is_symlink():
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Source must not be a symlink",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
if resolved_target.absolute.is_file():
|
||||
return
|
||||
if resolved_target.absolute.is_dir():
|
||||
for root, dirnames, filenames in os.walk(resolved_target.absolute, followlinks=False):
|
||||
root_path = Path(root)
|
||||
for name in [*dirnames, *filenames]:
|
||||
entry = root_path / name
|
||||
if entry.is_symlink():
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Source directory must not contain symlinks",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
return
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
message="Unsupported path type for download",
|
||||
status_code=409,
|
||||
details={"path": resolved_target.relative},
|
||||
)
|
||||
|
||||
def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target) -> None:
|
||||
root_name = resolved_target.absolute.name
|
||||
if resolved_target.absolute.is_file():
|
||||
archive.write(resolved_target.absolute, arcname=root_name)
|
||||
return
|
||||
|
||||
archive.writestr(f"{root_name}/", b"")
|
||||
for child in sorted(resolved_target.absolute.rglob("*")):
|
||||
arcname = f"{root_name}/{child.relative_to(resolved_target.absolute).as_posix()}"
|
||||
if child.is_dir():
|
||||
archive.writestr(f"{arcname}/", b"")
|
||||
else:
|
||||
archive.write(child, arcname=arcname)
|
||||
@staticmethod
|
||||
def _parse_range_header(range_header: str, file_size: int) -> tuple[int, int]:
|
||||
def invalid_range() -> AppError:
|
||||
|
||||
Reference in New Issue
Block a user