printer_media.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728
  1. """Helpers for matching and downloading printer-side video files."""
  2. from __future__ import annotations
  3. import asyncio
  4. import json
  5. import logging
  6. import re
  7. import secrets
  8. import shutil
  9. import tempfile
  10. import time
  11. import zipfile
  12. from collections.abc import Awaitable, Callable
  13. from dataclasses import dataclass, replace
  14. from datetime import datetime, timedelta, timezone
  15. from pathlib import Path, PurePosixPath
  16. from backend.app.core.config import settings
  17. from backend.app.core.tasks import spawn_background_task
  18. from backend.app.services.bambu_ftp import (
  19. DownloadCancelled,
  20. DownloadInsufficientSpace,
  21. DownloadLimitExceeded,
  22. download_file_async,
  23. )
  24. logger = logging.getLogger(__name__)
  25. VIDEO_SUFFIXES = (".mp4", ".avi", ".mkv")
  26. MAX_PRINTER_ZIP_BYTES = 10 * 1024**3
  27. PRINTER_ZIP_FREE_SPACE_RESERVE = 256 * 1024**2
  28. _STALE_BUNDLE_SECONDS = 60 * 60
  29. MAX_PRINTER_ZIP_PREPARE_SECONDS = 30 * 60
  30. MAX_OPEN_ARCHIVE_IPCAM_SECONDS = 24 * 60 * 60
  31. _BUNDLE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]{1,200}$")
  32. _JOB_KEY_RE = re.compile(r"^[A-Za-z0-9_-]{20,200}$")
  33. _LOCAL_JOB_TASKS: dict[str, asyncio.Task] = {}
  34. _cleanup_task: asyncio.Task | None = None
  35. _CLEANUP_INTERVAL_SECONDS = 15 * 60
  36. class PrinterFilesZipTooLargeError(ValueError):
  37. """The selected printer files exceed the bounded ZIP staging limit."""
  38. class PrinterFilesZipInsufficientSpaceError(OSError):
  39. """The app data volume cannot safely stage the selected files."""
  40. @dataclass(frozen=True)
  41. class PrinterFilesZipResult:
  42. """Result of staging one printer ZIP."""
  43. path: Path
  44. requested: int
  45. successful: int
  46. failed_paths: tuple[str, ...]
  47. total_bytes: int
  48. @dataclass(frozen=True)
  49. class PrinterFilesJobStatus:
  50. """Serializable state for an asynchronous browser preparation job."""
  51. job_id: str
  52. printer_id: int
  53. state: str
  54. requested: int
  55. successful: int = 0
  56. failed: int = 0
  57. token: str | None = None
  58. filename: str | None = None
  59. message: str | None = None
  60. class _FileCancelSignal:
  61. """Cross-worker cancellation signal checked by the FTP callback thread."""
  62. def __init__(self, path: Path):
  63. self.path = path
  64. self._last_check = 0.0
  65. self._cached = False
  66. def is_set(self) -> bool:
  67. if self._cached:
  68. return True
  69. now = time.monotonic()
  70. if now - self._last_check >= 0.25:
  71. self._last_check = now
  72. self._cached = self.path.exists()
  73. return self._cached
  74. def _job_status_path(job_id: str) -> Path:
  75. if not _JOB_KEY_RE.fullmatch(job_id):
  76. raise ValueError("Invalid printer download job id")
  77. return _printer_zip_root() / f"job-{job_id}.json"
  78. def _job_cancel_path(job_id: str) -> Path:
  79. if not _JOB_KEY_RE.fullmatch(job_id):
  80. raise ValueError("Invalid printer download job id")
  81. return _printer_zip_root() / f"job-{job_id}.cancel"
  82. def _write_job_status(status: PrinterFilesJobStatus) -> None:
  83. """Atomically publish job state for polling from any app worker."""
  84. path = _job_status_path(status.job_id)
  85. temp_path = path.with_suffix(".tmp")
  86. temp_path.write_text(json.dumps(status.__dict__, separators=(",", ":")), encoding="utf-8")
  87. temp_path.replace(path)
  88. def _read_job_status(job_id: str) -> PrinterFilesJobStatus | None:
  89. try:
  90. data = json.loads(_job_status_path(job_id).read_text(encoding="utf-8"))
  91. return PrinterFilesJobStatus(**data)
  92. except (FileNotFoundError, OSError, ValueError, TypeError, json.JSONDecodeError):
  93. return None
  94. def _naive_utc(value: datetime | None) -> datetime | None:
  95. if value is None:
  96. return None
  97. if value.tzinfo is not None:
  98. return value.astimezone(timezone.utc).replace(tzinfo=None)
  99. return value
  100. def match_ipcam_chunks(
  101. files: list[dict],
  102. started_at: datetime | None,
  103. completed_at: datetime | None,
  104. *,
  105. now: datetime | None = None,
  106. ) -> list[dict]:
  107. """Return `/ipcam` chunks whose completion time overlaps a print.
  108. Bambu's `ipcam-record.*.mp4` files are fixed-size chunks. On the tested X1C
  109. and H2D firmware, their FTP mtime is the chunk completion time in the same
  110. UTC-naive basis used by archive timestamps. Some firmware reports FTP LIST
  111. mtimes in printer-local time instead; LIST carries no timezone with which
  112. to correct those values reliably. A ten-minute tail includes the final
  113. chunk, whose mtime lands after the print-complete event.
  114. """
  115. start = _naive_utc(started_at)
  116. if start is None:
  117. return []
  118. live_end = _naive_utc(now) or datetime.now(timezone.utc).replace(tzinfo=None)
  119. # A crash can leave an archive in ``printing`` indefinitely. Do not turn
  120. # that stale row into a window covering every chunk created since then.
  121. end = _naive_utc(completed_at) or min(live_end, start + timedelta(seconds=MAX_OPEN_ARCHIVE_IPCAM_SECONDS))
  122. lower = start - timedelta(minutes=1)
  123. upper = max(start, end) + timedelta(minutes=10)
  124. matches: list[dict] = []
  125. for file in files:
  126. name = str(file.get("name") or "")
  127. mtime = file.get("mtime")
  128. if file.get("is_directory") or not name.lower().startswith("ipcam-record."):
  129. continue
  130. if not name.lower().endswith(VIDEO_SUFFIXES) or not isinstance(mtime, datetime):
  131. continue
  132. timestamp = _naive_utc(mtime)
  133. if timestamp is not None and lower <= timestamp <= upper:
  134. matches.append(file)
  135. matches.sort(key=lambda item: _naive_utc(item.get("mtime")) or datetime.min)
  136. return matches
  137. def _zip_arcname(remote_path: str, used: set[str]) -> str:
  138. """Return a safe, unique relative archive name for a printer path."""
  139. parts = [part for part in PurePosixPath(remote_path).parts if part not in ("/", "", ".", "..")]
  140. candidate = "/".join(parts) or "printer-file"
  141. stem = candidate
  142. suffix = ""
  143. if "." in PurePosixPath(candidate).name:
  144. suffix = "".join(PurePosixPath(candidate).suffixes)
  145. stem = candidate[: -len(suffix)] if suffix else candidate
  146. counter = 2
  147. while candidate in used:
  148. candidate = f"{stem}-{counter}{suffix}"
  149. counter += 1
  150. used.add(candidate)
  151. return candidate
  152. def _printer_zip_root() -> Path:
  153. """Return the dedicated staging root without doing event-loop I/O."""
  154. return settings.archive_dir / "temp" / "printer-file-downloads"
  155. def _ensure_printer_zip_root() -> Path:
  156. """Create and return the staging root on the persistent data volume."""
  157. root = _printer_zip_root()
  158. root.mkdir(parents=True, exist_ok=True)
  159. return root
  160. def _prune_stale_bundles(root: Path) -> None:
  161. """Remove abandoned bundles after token expiry, without touching archives."""
  162. cutoff = time.time() - _STALE_BUNDLE_SECONDS
  163. if not root.exists():
  164. return
  165. for child in root.iterdir():
  166. try:
  167. if child.is_dir() and child.stat().st_mtime < cutoff:
  168. shutil.rmtree(child, ignore_errors=True)
  169. elif child.is_file() and child.name.startswith("job-") and child.stat().st_mtime < cutoff:
  170. child.unlink(missing_ok=True)
  171. except OSError:
  172. continue
  173. async def prune_stale_printer_file_bundles() -> None:
  174. """Prune abandoned printer ZIPs without blocking the event loop."""
  175. root = await asyncio.to_thread(_ensure_printer_zip_root)
  176. await asyncio.to_thread(_prune_stale_bundles, root)
  177. async def _printer_download_cleanup_loop() -> None:
  178. while True:
  179. try:
  180. await asyncio.sleep(_CLEANUP_INTERVAL_SECONDS)
  181. await prune_stale_printer_file_bundles()
  182. except asyncio.CancelledError:
  183. break
  184. except Exception:
  185. logger.exception("Periodic printer-download cleanup failed")
  186. def start_printer_download_cleanup() -> None:
  187. global _cleanup_task
  188. if _cleanup_task is None:
  189. _cleanup_task = spawn_background_task(_printer_download_cleanup_loop(), name="printer-download-cleanup")
  190. async def stop_printer_download_cleanup() -> None:
  191. """Stop cleanup and cancel every in-process preparation before shutdown."""
  192. global _cleanup_task
  193. tasks: list[asyncio.Task] = []
  194. cleanup_task = _cleanup_task
  195. _cleanup_task = None
  196. if cleanup_task is not None:
  197. cleanup_task.cancel()
  198. tasks.append(cleanup_task)
  199. # Jobs can be inside an FTP worker thread. Publish the same cooperative
  200. # cancellation marker used by the DELETE endpoint before cancelling the
  201. # asyncio wrapper, then await every wrapper so no executor work is left
  202. # behind when the application event loop closes.
  203. for job_id, task in list(_LOCAL_JOB_TASKS.items()):
  204. if not task.done():
  205. await asyncio.to_thread(_job_cancel_path(job_id).touch)
  206. task.cancel()
  207. tasks.append(task)
  208. if tasks:
  209. await asyncio.gather(*tasks, return_exceptions=True)
  210. _LOCAL_JOB_TASKS.clear()
  211. def printer_files_zip_path(printer_id: int, token: str) -> Path | None:
  212. """Resolve the staged ZIP for a resource-bound browser token."""
  213. bundle_key = f"{printer_id}-{token}"
  214. if not _BUNDLE_KEY_RE.fullmatch(bundle_key):
  215. return None
  216. return _printer_zip_root() / bundle_key / "printer-files.zip"
  217. def bind_printer_files_zip_to_token(
  218. result: PrinterFilesZipResult,
  219. printer_id: int,
  220. token: str,
  221. ) -> PrinterFilesZipResult:
  222. """Move a prepared bundle to the path derived from its persisted token."""
  223. target = printer_files_zip_path(printer_id, token)
  224. if target is None:
  225. raise ValueError("Invalid printer ZIP token")
  226. result.path.parent.rename(target.parent)
  227. return replace(result, path=target)
  228. def _check_initial_space(root: Path, sizes: dict[str, int]) -> None:
  229. # These sizes are client-reported hints used only for an early rejection,
  230. # so this is a courtesy, not the bound. The real one is enforced per write
  231. # and per FTP callback below, against actual bytes and the live free space,
  232. # which is the only thing that can hold when several preparations run at
  233. # once -- and they do: nothing serializes them. Two concurrent jobs that
  234. # both pass here stop independently at the reserve, and the one that gets
  235. # there second fails with a message saying so.
  236. expected_total = sum(sizes.values())
  237. if expected_total > MAX_PRINTER_ZIP_BYTES:
  238. raise PrinterFilesZipTooLargeError(
  239. f"Selected files total {expected_total} bytes; the limit is {MAX_PRINTER_ZIP_BYTES} bytes"
  240. )
  241. largest_file = max(sizes.values(), default=0)
  242. # In the worst case the ZIP is as large as the inputs while the largest
  243. # source is still staged beside it. Keep a reserve for the database/logs.
  244. required = expected_total + largest_file + PRINTER_ZIP_FREE_SPACE_RESERVE
  245. free = shutil.disk_usage(root).free
  246. if free < required:
  247. raise PrinterFilesZipInsufficientSpaceError(
  248. f"The app data volume needs {required} bytes free to stage this selection; {free} bytes are available"
  249. )
  250. async def build_printer_files_zip(
  251. printer,
  252. paths: list[str],
  253. sizes: dict[str, int],
  254. *,
  255. bundle_key: str | None = None,
  256. preserve_paths: bool = True,
  257. allow_empty: bool = False,
  258. cancel_signal: _FileCancelSignal | None = None,
  259. progress_callback: Callable[[int, int], Awaitable[None]] | None = None,
  260. ) -> PrinterFilesZipResult:
  261. """Download printer files one at a time into a disk-backed ZIP.
  262. The previous implementation held every source file and the final ZIP in
  263. memory. Continuous `/ipcam` chunks are commonly ~250 MB each, so selecting
  264. only a few could exhaust both server and browser memory.
  265. """
  266. root = await asyncio.to_thread(_ensure_printer_zip_root)
  267. await asyncio.to_thread(_prune_stale_bundles, root)
  268. await asyncio.to_thread(_check_initial_space, root, sizes)
  269. bundle_dir: Path | None = None
  270. try:
  271. if bundle_key is None:
  272. bundle_dir = Path(await asyncio.to_thread(tempfile.mkdtemp, prefix="bundle-", dir=root))
  273. else:
  274. if not _BUNDLE_KEY_RE.fullmatch(bundle_key):
  275. raise ValueError("Invalid printer ZIP bundle key")
  276. bundle_dir = root / bundle_key
  277. await asyncio.to_thread(bundle_dir.mkdir, mode=0o700)
  278. zip_path = bundle_dir / "printer-files.zip"
  279. successful = 0
  280. total_bytes = 0
  281. failed_paths: list[str] = []
  282. used_names: set[str] = set()
  283. archive = await asyncio.to_thread(zipfile.ZipFile, zip_path, "w", allowZip64=True)
  284. try:
  285. for index, remote_path in enumerate(paths):
  286. if cancel_signal is not None and cancel_signal.is_set():
  287. raise asyncio.CancelledError
  288. if not isinstance(remote_path, str) or not remote_path.startswith("/") or "\x00" in remote_path:
  289. logger.warning("Skipping invalid printer file path: %r", remote_path)
  290. failed_paths.append(remote_path)
  291. continue
  292. staged_path = bundle_dir / f"download-{index}"
  293. try:
  294. expected_size = sizes.get(remote_path)
  295. if expected_size is not None:
  296. free = (await asyncio.to_thread(shutil.disk_usage, root)).free
  297. if free < expected_size + PRINTER_ZIP_FREE_SPACE_RESERVE:
  298. raise PrinterFilesZipInsufficientSpaceError(
  299. "The app data volume lacks space for the next selected file"
  300. )
  301. downloaded = await download_file_async(
  302. printer.ip_address,
  303. printer.access_code,
  304. remote_path,
  305. staged_path,
  306. timeout=600,
  307. socket_timeout=60,
  308. printer_model=printer.model,
  309. expected_size=expected_size,
  310. max_bytes=MAX_PRINTER_ZIP_BYTES - total_bytes,
  311. cancel_event=cancel_signal,
  312. min_free_bytes=PRINTER_ZIP_FREE_SPACE_RESERVE,
  313. # Outside the per-printer download gate (#2957), in both
  314. # directions. A selection of ~250 MB /ipcam chunks holds
  315. # the printer for as long as it legitimately takes, and
  316. # nothing else should be made to wait that out; equally,
  317. # each file here must not stall behind a thumbnail.
  318. serialize=False,
  319. )
  320. if not downloaded:
  321. failed_paths.append(remote_path)
  322. continue
  323. # Deliberately no second size comparison here. The transfer
  324. # was already checked against the printer's own SIZE, which
  325. # download_to_file treats as the authority precisely because
  326. # it beats a hint the browser round-tripped; re-judging the
  327. # result against that hint would overrule the better number
  328. # with the worse one. The hint goes stale in exactly the case
  329. # this feature exists for -- an /ipcam chunk or a timelapse
  330. # still being written when the listing was taken -- and a
  331. # complete file would then be dropped as "truncated".
  332. file_size = (await asyncio.to_thread(staged_path.stat)).st_size
  333. if total_bytes + file_size > MAX_PRINTER_ZIP_BYTES:
  334. raise PrinterFilesZipTooLargeError(
  335. f"Downloaded files exceed the {MAX_PRINTER_ZIP_BYTES}-byte limit"
  336. )
  337. free = (await asyncio.to_thread(shutil.disk_usage, root)).free
  338. if free < file_size + PRINTER_ZIP_FREE_SPACE_RESERVE:
  339. raise PrinterFilesZipInsufficientSpaceError(
  340. "The app data volume ran out of safe staging space while building the ZIP"
  341. )
  342. compression = (
  343. zipfile.ZIP_STORED if remote_path.lower().endswith(VIDEO_SUFFIXES) else zipfile.ZIP_DEFLATED
  344. )
  345. arc_source = remote_path if preserve_paths else PurePosixPath(remote_path).name
  346. await asyncio.to_thread(
  347. archive.write,
  348. staged_path,
  349. _zip_arcname(arc_source, used_names),
  350. compress_type=compression,
  351. )
  352. successful += 1
  353. total_bytes += file_size
  354. except DownloadLimitExceeded as exc:
  355. raise PrinterFilesZipTooLargeError(
  356. f"Downloaded files exceed the {MAX_PRINTER_ZIP_BYTES}-byte limit"
  357. ) from exc
  358. except DownloadInsufficientSpace as exc:
  359. raise PrinterFilesZipInsufficientSpaceError(
  360. "The app data volume ran out of safe staging space during transfer"
  361. ) from exc
  362. except DownloadCancelled as exc:
  363. raise asyncio.CancelledError from exc
  364. except (PrinterFilesZipTooLargeError, PrinterFilesZipInsufficientSpaceError):
  365. raise
  366. except Exception as exc:
  367. logger.warning("Failed to add %s to printer ZIP: %s", remote_path, exc)
  368. failed_paths.append(remote_path)
  369. finally:
  370. await asyncio.to_thread(staged_path.unlink, missing_ok=True)
  371. if progress_callback is not None:
  372. await progress_callback(successful, len(failed_paths))
  373. finally:
  374. await asyncio.shield(asyncio.to_thread(archive.close))
  375. except BaseException:
  376. if bundle_dir is not None:
  377. await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
  378. raise
  379. if successful == 0 and not allow_empty:
  380. await asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True)
  381. raise FileNotFoundError("No files could be downloaded")
  382. return PrinterFilesZipResult(
  383. path=zip_path,
  384. requested=len(paths),
  385. successful=successful,
  386. failed_paths=tuple(failed_paths),
  387. total_bytes=total_bytes,
  388. )
  389. def printer_file_path(printer_id: int, token: str) -> Path | None:
  390. """Resolve a prepared native single-file download."""
  391. bundle_key = f"{printer_id}-{token}"
  392. if not _BUNDLE_KEY_RE.fullmatch(bundle_key):
  393. return None
  394. return _printer_zip_root() / bundle_key / "printer-file"
  395. def bind_printer_file_to_token(result: PrinterFilesZipResult, printer_id: int, token: str) -> PrinterFilesZipResult:
  396. target = printer_file_path(printer_id, token)
  397. if target is None:
  398. raise ValueError("Invalid printer file token")
  399. result.path.parent.rename(target.parent)
  400. return replace(result, path=target)
  401. async def build_printer_file(
  402. printer,
  403. remote_path: str,
  404. expected_size: int | None,
  405. *,
  406. bundle_key: str,
  407. cancel_signal: _FileCancelSignal | None = None,
  408. ) -> PrinterFilesZipResult:
  409. """Stage one printer file on disk for a browser-native download.
  410. Also the read path for the 3MF preview in the file browser, which is why
  411. nothing here waits on a shared lock: a preview must not queue behind
  412. somebody else's ten-gigabyte selection for as long as that takes.
  413. """
  414. if not remote_path.startswith("/") or "\x00" in remote_path:
  415. raise FileNotFoundError("Invalid printer file path")
  416. root = await asyncio.to_thread(_ensure_printer_zip_root)
  417. size_hints = {remote_path: expected_size} if expected_size is not None else {}
  418. await asyncio.to_thread(_check_initial_space, root, size_hints)
  419. bundle_dir = root / bundle_key
  420. try:
  421. await asyncio.to_thread(bundle_dir.mkdir, mode=0o700)
  422. local_path = bundle_dir / "printer-file"
  423. downloaded = await download_file_async(
  424. printer.ip_address,
  425. printer.access_code,
  426. remote_path,
  427. local_path,
  428. timeout=600,
  429. socket_timeout=60,
  430. printer_model=printer.model,
  431. expected_size=expected_size,
  432. max_bytes=MAX_PRINTER_ZIP_BYTES,
  433. cancel_event=cancel_signal,
  434. min_free_bytes=PRINTER_ZIP_FREE_SPACE_RESERVE,
  435. # The lock-free promise in this function's docstring, kept: a preview
  436. # must not queue behind somebody else's selection (#2957).
  437. serialize=False,
  438. )
  439. if not downloaded:
  440. raise FileNotFoundError("The selected printer file could not be downloaded")
  441. file_size = (await asyncio.to_thread(local_path.stat)).st_size
  442. return PrinterFilesZipResult(
  443. path=local_path,
  444. requested=1,
  445. successful=1,
  446. failed_paths=(),
  447. total_bytes=file_size,
  448. )
  449. except DownloadLimitExceeded as exc:
  450. await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
  451. raise PrinterFilesZipTooLargeError(f"Downloaded file exceeds the {MAX_PRINTER_ZIP_BYTES}-byte limit") from exc
  452. except DownloadInsufficientSpace as exc:
  453. await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
  454. raise PrinterFilesZipInsufficientSpaceError(
  455. "The app data volume ran out of safe staging space during transfer"
  456. ) from exc
  457. except DownloadCancelled as exc:
  458. await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
  459. raise asyncio.CancelledError from exc
  460. except BaseException:
  461. await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
  462. raise
  463. async def _run_printer_files_job(
  464. printer,
  465. job_id: str,
  466. paths: list[str],
  467. sizes: dict[str, int],
  468. filename: str,
  469. as_zip: bool,
  470. ) -> None:
  471. from backend.app.core.auth import create_slicer_download_token
  472. cancel_signal = _FileCancelSignal(_job_cancel_path(job_id))
  473. status = PrinterFilesJobStatus(job_id, printer.id, "preparing", len(paths), filename=filename)
  474. await asyncio.to_thread(_write_job_status, status)
  475. async def report_progress(successful: int, failed: int) -> None:
  476. await asyncio.to_thread(
  477. _write_job_status,
  478. PrinterFilesJobStatus(
  479. job_id,
  480. printer.id,
  481. "preparing",
  482. len(paths),
  483. successful=successful,
  484. failed=failed,
  485. filename=filename,
  486. ),
  487. )
  488. try:
  489. async with asyncio.timeout(MAX_PRINTER_ZIP_PREPARE_SECONDS):
  490. if as_zip:
  491. result = await build_printer_files_zip(
  492. printer,
  493. paths,
  494. sizes,
  495. bundle_key=f"job-{job_id}",
  496. cancel_signal=cancel_signal,
  497. progress_callback=report_progress,
  498. )
  499. else:
  500. result = await build_printer_file(
  501. printer,
  502. paths[0],
  503. sizes.get(paths[0]),
  504. bundle_key=f"job-{job_id}",
  505. cancel_signal=cancel_signal,
  506. )
  507. if cancel_signal.is_set():
  508. await asyncio.to_thread(remove_printer_files_zip, result.path)
  509. raise asyncio.CancelledError
  510. token = await create_slicer_download_token("printer-files", printer.id)
  511. if as_zip:
  512. result = await asyncio.to_thread(bind_printer_files_zip_to_token, result, printer.id, token)
  513. else:
  514. result = await asyncio.to_thread(bind_printer_file_to_token, result, printer.id, token)
  515. await asyncio.to_thread(
  516. _write_job_status,
  517. PrinterFilesJobStatus(
  518. job_id,
  519. printer.id,
  520. "ready",
  521. len(paths),
  522. successful=result.successful,
  523. failed=len(result.failed_paths),
  524. token=token,
  525. filename=filename,
  526. ),
  527. )
  528. except asyncio.CancelledError:
  529. await asyncio.shield(
  530. asyncio.to_thread(
  531. _write_job_status,
  532. PrinterFilesJobStatus(job_id, printer.id, "cancelled", len(paths), filename=filename),
  533. )
  534. )
  535. except PrinterFilesZipTooLargeError as exc:
  536. await asyncio.to_thread(
  537. _write_job_status,
  538. PrinterFilesJobStatus(job_id, printer.id, "failed", len(paths), filename=filename, message=str(exc)),
  539. )
  540. except PrinterFilesZipInsufficientSpaceError as exc:
  541. await asyncio.to_thread(
  542. _write_job_status,
  543. PrinterFilesJobStatus(job_id, printer.id, "failed", len(paths), filename=filename, message=str(exc)),
  544. )
  545. except TimeoutError:
  546. await asyncio.to_thread(
  547. _write_job_status,
  548. PrinterFilesJobStatus(
  549. job_id,
  550. printer.id,
  551. "failed",
  552. len(paths),
  553. filename=filename,
  554. message="Printer download preparation exceeded the 30-minute limit",
  555. ),
  556. )
  557. except FileNotFoundError as exc:
  558. await asyncio.to_thread(
  559. _write_job_status,
  560. PrinterFilesJobStatus(job_id, printer.id, "failed", len(paths), filename=filename, message=str(exc)),
  561. )
  562. except Exception:
  563. logger.exception("Printer download job %s failed", job_id)
  564. await asyncio.to_thread(
  565. _write_job_status,
  566. PrinterFilesJobStatus(
  567. job_id,
  568. printer.id,
  569. "failed",
  570. len(paths),
  571. filename=filename,
  572. message="Printer download preparation failed",
  573. ),
  574. )
  575. finally:
  576. await asyncio.to_thread(_job_cancel_path(job_id).unlink, missing_ok=True)
  577. async def start_printer_files_job(
  578. printer,
  579. paths: list[str],
  580. sizes: dict[str, int],
  581. filename: str,
  582. *,
  583. as_zip: bool,
  584. ) -> PrinterFilesJobStatus:
  585. """Start a bounded background preparation and return immediately."""
  586. if not paths:
  587. raise ValueError("No files specified")
  588. root = await asyncio.to_thread(_ensure_printer_zip_root)
  589. await asyncio.to_thread(_prune_stale_bundles, root)
  590. await asyncio.to_thread(_check_initial_space, root, sizes)
  591. job_id = secrets.token_urlsafe(24)
  592. status = PrinterFilesJobStatus(job_id, printer.id, "queued", len(paths), filename=filename)
  593. await asyncio.to_thread(_write_job_status, status)
  594. task = spawn_background_task(
  595. _run_printer_files_job(printer, job_id, paths, sizes, filename, as_zip),
  596. name=f"printer-download-{printer.id}-{job_id}",
  597. )
  598. _LOCAL_JOB_TASKS[job_id] = task
  599. task.add_done_callback(lambda _task: _LOCAL_JOB_TASKS.pop(job_id, None))
  600. return status
  601. async def get_printer_files_job(job_id: str, printer_id: int) -> PrinterFilesJobStatus | None:
  602. status = await asyncio.to_thread(_read_job_status, job_id)
  603. if status is None or status.printer_id != printer_id:
  604. return None
  605. return status
  606. async def cancel_printer_files_job(job_id: str, printer_id: int) -> bool:
  607. status = await get_printer_files_job(job_id, printer_id)
  608. if status is None:
  609. return False
  610. await asyncio.to_thread(_job_cancel_path(job_id).touch)
  611. task = _LOCAL_JOB_TASKS.get(job_id)
  612. if task is not None and not task.done():
  613. task.cancel()
  614. if status.state == "ready" and status.token:
  615. zip_path = printer_files_zip_path(printer_id, status.token)
  616. prepared = (
  617. zip_path
  618. if zip_path is not None and await asyncio.to_thread(zip_path.is_file)
  619. else printer_file_path(printer_id, status.token)
  620. )
  621. if prepared is not None:
  622. await asyncio.to_thread(remove_printer_files_zip, prepared)
  623. await asyncio.to_thread(
  624. _write_job_status,
  625. replace(status, state="cancelled", token=None),
  626. )
  627. return True
  628. def remove_printer_files_zip(zip_path: Path) -> None:
  629. """Remove a completed download bundle after FileResponse finishes."""
  630. shutil.rmtree(zip_path.parent, ignore_errors=True)