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
+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: