feat: download - fase 02

This commit is contained in:
kodi
2026-03-14 12:40:41 +01:00
parent 610a648fd1
commit dab87878cc
9 changed files with 215 additions and 45 deletions
+8 -4
View File
@@ -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")
+110 -24
View File
@@ -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: