Files
bambuddy/backend/app/services/slice_dispatch.py
T
maziggy 6deaa513af ● feat(slicer): server-side slicing via OrcaSlicer / Bambu Studio sidecar
Adds an optional slicer-api/ Compose stack and wires Bambuddy's File
  Manager, Archives, and MakerWorld pages to a new server-side Slice flow.
  Slicing runs as an in-memory background job (POST returns 202 + job_id,
  polled via GET /api/v1/slice-jobs/{id}) so a multi-minute slice no
  longer pins the modal; result lands as a new .gcode.3mf in the same
  folder (or new archive for archive sources) with the embedded
  thumbnail extracted.

  Backend
  - New services: slice_dispatch (in-memory dispatcher, 30min retention
    sweep) and slicer_api (HTTP bridge with 4xx/5xx/connection error
    split that drives the 3MF embedded-settings fallback retry path).
  - New schemas: SliceRequest, SliceResponse, SliceArchiveResponse,
    SliceJobEnqueueResponse.
  - New routes: POST /library/files/{id}/slice,
    POST /archives/{id}/slice, GET /api/v1/slice-jobs/{id} (gated on
    LIBRARY_READ since job IDs are sequential and the body leaks source
    filenames and result IDs).
  - AppSettings + env defaults: use_slicer_api, orcaslicer_api_url,
    bambu_studio_api_url. DB-stored values override env defaults.

  Frontend
  - New SliceModal handles preset gating; enqueues then closes
    immediately.
  - New SliceJobTrackerProvider polls active jobs at app level, surfaces
    a single toast per job (queued -> running -> completed / failed)
    and invalidates library/archives queries on terminal status.
  - Settings -> Workflow -> Slicer card: preferred slicer dropdown,
    Use Slicer API toggle, contextual sidecar URL field.
  - File Manager / Archives / MakerWorld get a Slice button gated on
    the Use Slicer API setting.
  - gcode-viewer adapter learns ?library_file=<id> so sliced library
    files preview inline.

  i18n
  - New slice.* and settings.{useSlicerApi,slicerCard,orcaslicerApiUrl,
    bambuStudioApiUrl,slicerApiUrlDescription,useSlicerApiDescription}
    + fileManager.noPermissionSlice keys across all 8 locales (en, de,
    fr, it, ja, pt-BR, zh-CN, zh-TW). English fully translated, German
    fully translated, the other six seeded with English fallbacks
    pending native translation.

  Tests
  - 10 backend integration tests in test_library_slice_api.py covering
    validation (404/400), happy-path enqueue, sidecar-down, 3MF
    embedded-settings fallback, STL no-fallback, and preset-error ->
    failed job paths.
  - New unit tests in test_slicer_api.py for the HTTP bridge.
  - 5 new SliceModal frontend tests covering preset gating, library +
    archive enqueue paths, error surface, and preset-load failure.
  - Existing SettingsPage tests adjusted: slicer dropdown asserts now
    switch to the Workflow tab first; added a beforeEach URL reset so
    one test's tab click doesn't bleed into sibling tests.

  Sidecar
  - New slicer-api/ folder is self-contained and optional. Two services
    (orca-slicer-api on 3003, bambu-studio-api on 3001 behind --profile
    bambu) build via Docker git-build-context from
    maziggy/orca-slicer-api@bambuddy/profile-resolver. The fork patches
    the OrcaSlicer CLI's profile compatibility quirks (inherits-chain
    resolver, from:User -> system rewrite, '# ' clone-prefix strip,
    sentinel-value strip) empirically required to slice real GUI
    exports without segfaulting the CLI.

  Docs
  - CHANGELOG entry under [0.2.4b1] - Unreleased Added.
  - README File Manager bullet for the new server-side Slice button.
  - bambuddy-website features.html: new card under "Configurable Slicer".
  - bambuddy-wiki: new page features/slicer-api.md + nav entry +
    features index card.

  Notes
  - Opt-in: with Use Slicer API off, the existing "open in desktop
    slicer via URI" flow is the default and unchanged.
  - 3MF inputs that segfault the CLI on --load-settings transparently
    retry with embedded settings; the resulting job carries
    used_embedded_settings: true.
  - Sliced files always export as .gcode.3mf so File Manager picks up
    the embedded thumbnail; file_type is set to "gcode" (blue badge).
2026-04-27 15:28:37 +02:00

153 lines
5.1 KiB
Python

"""In-memory background dispatcher for slice jobs.
Mirrors the shape of `background_dispatch.py` (the print-upload dispatcher)
but tailored for slicing: jobs are independent (no printer-busy gating),
short-lived (typically 5-60s), and the result is a `LibraryFile` or
`PrintArchive` row rather than a printer-side dispatch.
The frontend kicks off a slice via `POST /library/files/{id}/slice` or
`POST /archives/{id}/slice`, gets back `{job_id, status_url}`, then polls
`GET /slice-jobs/{id}` until status is `completed` or `failed`.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal
logger = logging.getLogger(__name__)
SliceJobStatus = Literal["pending", "running", "completed", "failed"]
@dataclass(slots=True)
class SliceJob:
id: int
kind: Literal["library_file", "archive"]
source_id: int
source_name: str
status: SliceJobStatus = "pending"
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
started_at: datetime | None = None
completed_at: datetime | None = None
# On success: the body returned to the caller — usually a SliceResponse
# or SliceArchiveResponse dict.
result: dict[str, Any] | None = None
# On failure: HTTP status + error message.
error_status: int | None = None
error_detail: str | None = None
# Retention: keep finished jobs around for 30 minutes so the polling client
# always sees a terminal state on its next tick. After that, the next access
# sweep prunes them.
_RETENTION_SECONDS = 30 * 60
class SliceDispatchService:
def __init__(self) -> None:
self._jobs: dict[int, SliceJob] = {}
self._next_id: int = 1
self._lock = asyncio.Lock()
self._tasks: dict[int, asyncio.Task] = {}
async def enqueue(
self,
*,
kind: Literal["library_file", "archive"],
source_id: int,
source_name: str,
run: Callable[[], Awaitable[dict[str, Any]]],
) -> SliceJob:
"""Register a new slice job and start it on the event loop.
``run`` is an async callable that performs the actual slice + save
and returns the response body the caller will receive once status
flips to ``completed``.
"""
async with self._lock:
job = SliceJob(
id=self._next_id,
kind=kind,
source_id=source_id,
source_name=source_name,
)
self._next_id += 1
self._jobs[job.id] = job
self._sweep_locked()
task = asyncio.create_task(self._run_job(job, run), name=f"slice-job-{job.id}")
self._tasks[job.id] = task
return job
async def _run_job(
self,
job: SliceJob,
run: Callable[[], Awaitable[dict[str, Any]]],
) -> None:
job.started_at = datetime.now(timezone.utc)
job.status = "running"
try:
result = await run()
job.result = result
job.status = "completed"
except _SliceJobError as exc:
# Caller-controlled HTTP error — propagate status + detail.
job.status = "failed"
job.error_status = exc.status_code
job.error_detail = exc.detail
except Exception as exc:
logger.exception("Slice job %s failed unexpectedly", job.id)
job.status = "failed"
job.error_status = 500
job.error_detail = f"Unexpected error: {exc}"
finally:
job.completed_at = datetime.now(timezone.utc)
self._tasks.pop(job.id, None)
def get(self, job_id: int) -> SliceJob | None:
return self._jobs.get(job_id)
def _sweep_locked(self) -> None:
"""Drop finished jobs older than the retention window. Caller holds
the lock."""
now = datetime.now(timezone.utc)
stale_ids = [
jid
for jid, job in self._jobs.items()
if job.status in ("completed", "failed")
and job.completed_at is not None
and (now - job.completed_at).total_seconds() > _RETENTION_SECONDS
]
for jid in stale_ids:
self._jobs.pop(jid, None)
class _SliceJobError(Exception):
"""Raised inside a slice job's `run` callable to surface a specific
HTTP status + detail. The dispatcher catches these and stores them on
the job. Callers convert ``HTTPException`` to this on the boundary.
"""
def __init__(self, status_code: int, detail: str) -> None:
super().__init__(detail)
self.status_code = status_code
self.detail = detail
def http_exception_to_job_error(exc) -> _SliceJobError:
"""Convert a starlette ``HTTPException`` into the dispatcher's error
type. Handles the common case where slice helpers raise FastAPI's
``HTTPException`` for validation / sidecar failures.
"""
return _SliceJobError(exc.status_code, str(exc.detail))
# Module-level singleton, started/stopped by main.py's lifespan.
slice_dispatch = SliceDispatchService()