mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 05:31:31 +02:00
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).
153 lines
5.1 KiB
Python
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()
|