diff --git a/CHANGELOG.md b/CHANGELOG.md
index 830197566..ae3a1ac58 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Slice modal: pick the build plate (#1337, reported by @digitalskies)** — Slicing a plain STL through the integrated slicer always defaulted to whatever `curr_bed_type` lived in the chosen process preset (typically `Cool Plate`), which the slicer CLI then rejected for high-temp filaments with `Plate 1: Cool Plate does not support filament 1`. The user had no way to switch plates short of cloning the process preset in BambuStudio, which defeats the point of the in-app slicer. The Slice modal now exposes a `Build plate` dropdown with the six canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate, Smooth PEI Plate) plus an explicit `Auto (use process preset)` option that preserves the previous behavior. The dropdown sits between Process profile and Filament rows so it stays visible regardless of how many filament slots the picked plate uses (a long filament list would otherwise push it off the modal's `max-h-[85vh]` scroll viewport) and is **always enabled** — including when the user picks a Printer Preset Bundle from the top BundlePicker. When the user picks a specific plate, the new `bed_type` field on `SliceRequest` ([`backend/app/schemas/slicer.py`](backend/app/schemas/slicer.py)) flows through the dispatcher via two paths: (1) **resolved-preset path** — the route helper `_patch_process_bed_type` in [`backend/app/api/routes/library.py`](backend/app/api/routes/library.py) overwrites `curr_bed_type` on the resolved process JSON before forwarding to the sidecar (no preset cloning required); (2) **bundle dispatch path** — `slice_with_bundle` in [`backend/app/services/slicer_api.py`](backend/app/services/slicer_api.py) adds a `bedType` form field to the sidecar multipart so the sidecar can pass `--curr_bed_type` through to the CLI, which lets the override take effect even though Bambuddy can't patch the bundle's process JSON locally (the sidecar materialises it from the stored .bbscfg). Sidecar versions that don't recognise the field silently no-op — the slice still runs, just with the bundle's default plate; the slicer-API fork at maziggy/orca-slicer-api will need the matching change for the bundle path to take full effect. **i18n parity:** 8 new keys (`slice.bedType.{label,auto,coolPlate,coolPlateSuperTack,engineering,highTemp,texturedPEI,smoothPEI}`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Regression tests:** 4 in [`test_slice_request_bed_type.py`](backend/tests/unit/test_slice_request_bed_type.py) (`bed_type` defaults to None, accepts the six canonical strings, rejects overlong input via the schema's `max_length=64`; `_patch_process_bed_type` overwrites an existing value, adds the field when missing, and returns the input unchanged for malformed JSON or non-dict roots), 4 in [`test_library_slice_api.py`](backend/tests/integration/test_library_slice_api.py) (resolved-preset path: with `bed_type` set, the sidecar receives `"curr_bed_type": "Textured PEI Plate"` in the presetProfile multipart part; without it, `curr_bed_type` stays out of the body entirely. bundle dispatch path: `bedType` form field carries the override through to the sidecar; omitting `bed_type` keeps the form field out of the request so the bundle's own `curr_bed_type` is preserved), 2 in [`SliceModal.test.tsx`](frontend/src/__tests__/components/SliceModal.test.tsx) (dropdown selection puts `bed_type` on the request; leaving it on Auto omits the field). 59 backend slice tests + 34 SliceModal tests pass; build and i18n parity script clean.
### Fixed
+- **Print log no longer 404-storms on the thumbnail endpoint for entries whose archive was deleted or whose print failed before a thumbnail was extracted** ([#1348](https://github.com/maziggy/bambuddy/issues/1348) follow-up) — Symptom in DevTools when opening Archives → Print Log: a 404 per orphaned entry per render. Visually clean (the `
` handler in [`frontend/src/pages/ArchivesPage.tsx:3763`](frontend/src/pages/ArchivesPage.tsx) hides the broken image), but noisy and wasteful. **Root cause** in [`backend/app/api/routes/print_log.py:91-113`](backend/app/api/routes/print_log.py): `PrintLogEntry.thumbnail_path` is copied by value from `archive.thumbnail_path` at write-time ([`main.py:3615`](backend/app/main.py)) but the FK on `archive_id` is `ON DELETE SET NULL` (#1378) — so log entries survive archive deletion to preserve stats history, but the cached thumbnail_path string keeps pointing at a file that was removed when the archive's directory was deleted. Same shape for failed prints that recorded an expected thumbnail path before the extractor wrote (or skipped writing) the file. **Two-part fix:** (1) `get_print_log_thumbnail` self-heals when the file is missing — it NULLs `thumbnail_path` on the entry and commits before returning 404, so the frontend's `entry.thumbnail_path &&
` gate keeps the next page render from re-requesting. (2) New helper `_null_print_log_thumbnail_paths(db, archive_id)` in [`backend/app/services/archive.py`](backend/app/services/archive.py) is called from `soft_delete_archive` and `delete_archive` before the on-disk files are removed — eager clear so future deletes don't cause the one-time storm at all. The route handler covers stragglers (failed prints, files manually moved, etc.). **Regression tests** in [`backend/tests/integration/test_archives_api.py`](backend/tests/integration/test_archives_api.py): `test_soft_delete_clears_thumbnail_path_on_linked_log_entries` pins the eager clear on the soft-delete route, `test_hard_delete_clears_thumbnail_path_before_fk_cascade` pins it on `ArchiveService.delete_archive` (used by the auto-purge sweeper), `test_print_log_thumbnail_route_lazy_nulls_missing_file` pins the route's self-heal for failed-print orphans where the file was never written. All 4 new tests + 61 in the archives/print-log sweep pass; ruff clean.
+
- **Camera stream no longer freezes every ~30 s when Obico fault detection is enabled on the same printer the user is viewing** ([#1348](https://github.com/maziggy/bambuddy/issues/1348), reported by @SL666) — Symptom on an X1-class printer running firmware that allows only one concurrent camera connection: opening the live camera worked initially, then the stream hung within seconds and "cancelled" — clicking the in-UI refresh restored it for another few seconds before it hung again, ad infinitum. Disabling Obico fault detection made the stream stable. **Root cause** in `_capture_frame` at [`backend/app/services/obico_detection.py:209-220`](backend/app/services/obico_detection.py): the buffer-reuse path that was supposed to make Obico polling free (#1271) only worked when `_last_frames[printer_id]` was populated. In every race window where `_active_streams` had a registered entry but the JPEG buffer was empty — stream startup before the first frame lands (1–3 s on RTSP), or any moment the upstream ffmpeg was mid-reconnect after a 30 s read timeout — `try_get_active_buffered_frame(printer_id)` returned `None` and the caller fell through to `capture_camera_frame_bytes(...)`, which spawned its own ffmpeg + TLS proxy and opened a competing RTSP socket on the printer. On firmwares that only allow one camera connection, that second socket forced the printer to drop the live fan-out connection — viewers' ffmpeg hit its 30 s read timeout, looped through 30 reconnect attempts at 0.2 s each (all racing the next Obico poll 10 s later), exhausted retries, and the broadcaster pump exited. The user's viewer disconnected with no obvious cause in the log because the Obico capture itself looked successful (`Successfully captured camera frame bytes: 83484 bytes`). **Fix:** the buffer-reuse gate was widened from "do we have a frame in the buffer?" to "is any fan-out stream registered for this printer?" — even when the buffer is momentarily empty. New helper `is_stream_active(printer_id) -> bool` at [`backend/app/api/routes/camera.py:82`](backend/app/api/routes/camera.py) checks `_active_streams` / `_active_chamber_streams` independently of buffer state. `_capture_frame` now consults `is_stream_active` first: if True, it returns the buffered frame when available or `None` (skip this poll cycle) when not — it **never** opens a competing socket while a viewer is attached, regardless of buffer state. The poll loop retries 10 s later, by which time the buffer is virtually guaranteed to be populated. The fresh-socket path still fires unchanged when no viewer is connected (Obico's primary use case: detection on idle/headless prints). Cost of the fix: at most one missed Obico detection cycle per viewer-attach (~10 s lag); benefit: zero competing-socket events while any viewer is connected. `try_get_active_buffered_frame` was refactored to delegate to `is_stream_active` so the two helpers stay in lockstep — its `/camera/snapshot` caller at [`camera.py:859`](backend/app/api/routes/camera.py) is unchanged behaviorally (snapshot is user-initiated single-shot; falling through to a fresh capture if buffer is momentarily empty is the desired behavior there). **Regression tests** in [`backend/tests/unit/test_obico_detection.py`](backend/tests/unit/test_obico_detection.py): new `test_skips_poll_when_stream_active_but_buffer_empty` reproduces the exact race (viewer registered, buffer empty) and pins that `_capture_frame` returns `None` and `capture_camera_frame_bytes` is NOT called; existing `test_returns_buffered_frame_when_stream_active` and `test_falls_back_to_fresh_capture_when_no_stream` were updated to patch the new `is_stream_active` helper alongside `try_get_active_buffered_frame`. All 31 obico tests + 113 in the wider camera/obico sweep pass; ruff clean.
- **Reprints (including failed and cancelled ones) no longer overwrite the source archive's statistics; Quick Stats now adds per-print events** ([#1378](https://github.com/maziggy/bambuddy/issues/1378), reported by @IndividualGhost1905) — Symptom: after reprinting a model from the Archive page, the reprint contributed nothing to Quick Stats / Statistics; worse, if the reprint failed at 10g while the original print used 100g, the archive card and the totals both flipped from 100g to 10g — losing the original print's data. **Root cause** in [`backend/app/main.py:1973`](backend/app/main.py) (`_handle_print_start`): every reprint shared the source archive's row via the `register_expected_print`/`expected_archive_id` path, and statistics (`GET /archives/stats` in [`backend/app/api/routes/archives.py`](backend/app/api/routes/archives.py), Prometheus `/metrics` in [`backend/app/api/routes/metrics.py`](backend/app/api/routes/metrics.py)) summed PrintArchive columns — so a single archive row was the only contribution per file regardless of how many times the user pressed Reprint. The cost overwrite at [`usage_tracker.py:633`](backend/app/services/usage_tracker.py) and energy overwrite at [`main.py:3625`](backend/app/main.py) compounded it: each run's actuals replaced the previous run's values on the source row. **Architectural fix** — statistics are now event-based, not file-based. The existing `PrintLogEntry` table (one row per print event, written at print completion; lives in [`backend/app/models/print_log.py`](backend/app/models/print_log.py) and already powered the cross-archive /print-log page) gains six columns: `archive_id` (nullable FK, `ON DELETE SET NULL` so log entries survive archive deletion — preserving the #1343 soft-delete-vs-stats decoupling), `cost`, `energy_kwh`, `energy_cost`, `failure_reason`, `created_by_id`. Idempotent SQLite + Postgres migrations in [`backend/app/core/database.py`](backend/app/core/database.py). `/archives/stats` and `/metrics` now sum/count from `PrintLogEntry` joined to `PrintArchive` for time-accuracy comparisons and user-scope filters. The `_run_reprint_archive` flow still keys to the source archive (so the archive list stays one-card-per-file rather than ballooning into one card per print), but every print completion writes a new `PrintLogEntry` row with the run's actual filament / time / cost / energy / status / failure_reason. The cost overwrite in `usage_tracker.py` now only fires on the first run (counts existing `PrintLogEntry` rows for the archive); the energy background task at `main.py:3625` similarly preserves the source archive's energy_kwh on reprints while backfilling the run's energy on the matching `PrintLogEntry` row (it runs after `write_log_entry`, so it fetches and updates the latest log row for the archive). **New UX surface** — archive list responses gain four aggregate fields (`run_count`, `last_run_at`, `total_filament_actual_grams`, `successful_run_count`, `failed_run_count`) computed via a single batch query (`_load_run_aggregates` in [`backend/app/api/routes/archives.py`](backend/app/api/routes/archives.py)); the archive card renders an orange "N prints" badge when `run_count > 1`, with a tooltip breaking down successful vs failed runs. New endpoint `GET /archives/{archive_id}/runs` returns every PrintLogEntry for the archive (newest first), powering a new Print Log section at the top of the Edit Archive modal — date / status / duration / filament / cost columns plus failure_reason text under failed runs (new component [`frontend/src/components/PrintLogTable.tsx`](frontend/src/components/PrintLogTable.tsx)). i18n keys added under `archives.card.runsBadge*` and `archives.runLog.*` (en/de/ja translated; other 5 locales fall back to English per project convention). **Soft-delete contract preserved** — the `purge_stats=true` hard-delete path now also `DELETE`s linked PrintLogEntry rows so the archive's contribution truly disappears from totals; soft-delete (the default) leaves the log entries intact so #1343 stats-preservation still works. **Partial-print accuracy** — failed / cancelled / stopped reprints would have over-counted in the new stats path if PrintLogEntry just recorded the source archive's slicer estimate (100g for a print that stopped at 10g). The write_log_entry call site now uses a partial-aware filament value via the new `_compute_run_filament_grams` helper at [`backend/app/main.py`](backend/app/main.py): completed prints record the estimate (since the print finished), and partial prints prefer sum of tracked spool deltas from `usage_results` if inventory was set up, falling back to `estimate × progress%` from the MQTT push, and finally to None if no signal exists. The per-run cost write uses the same precedence: prefer the usage_results sum (raw — without the topup-to-estimate inflation that `usage_tracker.update_archive_usage` applies for archive.cost, which assumes the print completed), and only fall back to archive.cost for completed prints. **Test fixture update** — the `archive_factory` conftest fixture now synthesizes one `PrintLogEntry` per completed test archive (since stats moved to that table, the previous "create archive only" pattern would silently produce 0-stat tests); pass `with_run=False` to skip for the "archived but never printed" case. 3 integration tests in [`backend/tests/unit/test_archive_run_aggregation.py`](backend/tests/unit/test_archive_run_aggregation.py) pin the reporter's exact scenario (100g completed + 10g failed reprint → stats show 110g total / 2 prints / 1 successful / 1 failed), the archive-list aggregates wiring, and the `/runs` endpoint ordering. 14 unit tests in [`backend/tests/unit/test_run_filament_helper.py`](backend/tests/unit/test_run_filament_helper.py) lock the partial-print math across completed / failed / cancelled / stopped statuses, both inventory-tracked and untracked paths, multi-filament sums, the >100% progress clamp, and the None-fallback cases. The full backend test suite — 4921 tests across unit + integration — passes; ruff clean; frontend build clean.
diff --git a/backend/app/api/routes/print_log.py b/backend/app/api/routes/print_log.py
index b3a0ec8b9..e2e681f2e 100644
--- a/backend/app/api/routes/print_log.py
+++ b/backend/app/api/routes/print_log.py
@@ -97,6 +97,13 @@ async def get_print_log_thumbnail(
"""Get the thumbnail for a print log entry.
Requires a stream token query param (?token=xxx) when auth is enabled.
+
+ Self-heals stale entries: when thumbnail_path points to a file that no
+ longer exists on disk (archive was deleted, or print failed before the
+ thumbnail was ever written), NULL the path on the entry so subsequent
+ page renders skip the request entirely. The frontend's
tag is
+ gated on entry.thumbnail_path being truthy, so the next fetch of the
+ log list will simply not request this thumbnail again.
"""
entry = await db.get(PrintLogEntry, entry_id)
if not entry or not entry.thumbnail_path:
@@ -104,6 +111,8 @@ async def get_print_log_thumbnail(
thumb_path = settings.base_dir / entry.thumbnail_path
if not thumb_path.exists():
+ entry.thumbnail_path = None
+ await db.commit()
raise HTTPException(404, "Thumbnail file not found")
return FileResponse(
diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py
index 08c524638..d709c91ec 100644
--- a/backend/app/services/archive.py
+++ b/backend/app/services/archive.py
@@ -827,6 +827,24 @@ class ProjectPageParser:
return False
+async def _null_print_log_thumbnail_paths(db: AsyncSession, archive_id: int) -> None:
+ """NULL thumbnail_path on PrintLogEntry rows linked to *archive_id*.
+
+ Called from both soft- and hard-delete paths before the archive's files
+ leave disk. The FK on PrintLogEntry.archive_id is ON DELETE SET NULL so
+ log rows survive the archive — without this clear, their cached
+ thumbnail_path would still point at a deleted file and the print-log
+ view would 404-storm on every render (#1348 follow-up). Lazy-NULL on
+ the GET route self-heals stragglers (e.g. failed prints that never had
+ a thumbnail written), but eager clear here avoids the one-time storm.
+ """
+ from sqlalchemy import update as sa_update
+
+ from backend.app.models.print_log import PrintLogEntry
+
+ await db.execute(sa_update(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id).values(thumbnail_path=None))
+
+
class ArchiveService:
"""Service for archiving print jobs."""
@@ -1252,6 +1270,7 @@ class ArchiveService:
dir_to_delete = self._resolve_archive_dir_for_delete(archive)
+ await _null_print_log_thumbnail_paths(self.db, archive_id)
archive.deleted_at = datetime.now(timezone.utc)
await self.db.commit()
@@ -1342,6 +1361,13 @@ class ArchiveService:
f"file_path is empty or invalid: '{archive.file_path}'"
)
+ # NULL stale thumbnail_path on linked PrintLogEntries before the FK
+ # SET-NULL cascade fires. The on-disk file is about to be removed by
+ # the rmtree below, so the path on any surviving log entry (archive_id
+ # gets SET NULL by the FK) would otherwise point at a missing file
+ # and produce 404 storms in the print-log view (#1348-followup).
+ await _null_print_log_thumbnail_paths(self.db, archive_id)
+
# Delete database record FIRST — if the commit fails (e.g. database locked
# during concurrent bulk deletes), the files stay on disk and nothing is lost.
await self.db.delete(archive)
diff --git a/backend/tests/integration/test_archives_api.py b/backend/tests/integration/test_archives_api.py
index a893964b1..2ad11cd16 100644
--- a/backend/tests/integration/test_archives_api.py
+++ b/backend/tests/integration/test_archives_api.py
@@ -243,6 +243,115 @@ class TestArchivesAPI:
assert post["total_filament_grams"] == 150.0
assert post["total_cost"] == 4.50
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_soft_delete_clears_thumbnail_path_on_linked_log_entries(
+ self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+ ):
+ """#1348 follow-up: soft-deleting an archive removes its files from disk;
+ the cached thumbnail_path on linked PrintLogEntry rows must be NULLed
+ in the same transaction so the print-log view doesn't 404-storm on the
+ now-deleted thumbnail file."""
+ from sqlalchemy import select
+
+ from backend.app.models.print_log import PrintLogEntry
+
+ printer = await printer_factory()
+ archive = await archive_factory(
+ printer.id,
+ status="completed",
+ thumbnail_path="archives/test/test_print/thumbnail.png",
+ )
+ # The factory's auto-PrintLogEntry doesn't copy thumbnail_path; set it
+ # manually to mirror what the production write_log_entry path stores.
+ run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+ run = run_query.scalar_one()
+ run.thumbnail_path = "archives/test/test_print/thumbnail.png"
+ await db_session.commit()
+ assert run.thumbnail_path is not None
+
+ resp = await async_client.delete(f"/api/v1/archives/{archive.id}")
+ assert resp.status_code == 200
+ assert resp.json()["purged_from_stats"] is False
+
+ await db_session.refresh(run)
+ assert run.thumbnail_path is None, "soft-delete must NULL thumbnail_path on linked log entry"
+ # The log entry itself survives the soft delete (its filament/cost
+ # contribution still needs to flow into stats per #1343).
+ assert run.id is not None
+ assert run.archive_id == archive.id
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_hard_delete_clears_thumbnail_path_before_fk_cascade(
+ self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+ ):
+ """#1348 follow-up: the auto-purge sweeper (and any caller of
+ ArchiveService.delete_archive) hard-deletes the archive row but leaves
+ PrintLogEntry rows alive via ON DELETE SET NULL. The eager
+ thumbnail_path clear must run inside delete_archive so even orphaned
+ log entries don't surface stale paths."""
+ from sqlalchemy import select
+
+ from backend.app.models.print_log import PrintLogEntry
+ from backend.app.services.archive import ArchiveService
+
+ printer = await printer_factory()
+ archive = await archive_factory(
+ printer.id,
+ status="completed",
+ thumbnail_path="archives/test/test_print/thumbnail.png",
+ )
+ run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+ run = run_query.scalar_one()
+ run.thumbnail_path = "archives/test/test_print/thumbnail.png"
+ await db_session.commit()
+ run_id = run.id
+
+ service = ArchiveService(db_session)
+ assert await service.delete_archive(archive.id) is True
+
+ # Log entry survives the hard-delete (the FK is ON DELETE SET NULL
+ # in production; SQLite test config doesn't enable foreign_keys=ON
+ # by default so archive_id may still be set, but the row itself
+ # remains for audit). The thumbnail_path was cleared eagerly by
+ # _null_print_log_thumbnail_paths before db.delete(archive).
+ refetch = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.id == run_id))
+ survivor = refetch.scalar_one()
+ assert survivor.thumbnail_path is None, (
+ "delete_archive must NULL thumbnail_path before removing the archive row"
+ )
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_print_log_thumbnail_route_lazy_nulls_missing_file(
+ self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+ ):
+ """#1348 follow-up: GET /print-log/{id}/thumbnail self-heals when the
+ thumbnail_path on a log entry points at a missing file (failed print
+ whose thumbnail was never written, or a stale path that escaped the
+ delete-time cleanup)."""
+ from sqlalchemy import select
+
+ from backend.app.models.print_log import PrintLogEntry
+
+ printer = await printer_factory()
+ archive = await archive_factory(printer.id, status="failed")
+ run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+ run = run_query.scalar_one()
+ # Path points at a file that never existed (failed-print case where
+ # archive.thumbnail_path was set but the extractor never produced one).
+ run.thumbnail_path = "archives/missing/never_written/thumbnail.png"
+ await db_session.commit()
+
+ # Auth is disabled in the integration test config, so the stream-token
+ # guard is bypassed — the route runs the lazy-NULL branch directly.
+ resp = await async_client.get(f"/api/v1/print-log/{run.id}/thumbnail")
+ assert resp.status_code == 404
+
+ await db_session.refresh(run)
+ assert run.thumbnail_path is None, "missing file must self-heal to NULL"
+
@pytest.mark.asyncio
@pytest.mark.integration
async def test_purge_stats_drops_archive_from_quick_stats(