When the user clicked Print Anyway on a filament-deficit warning, the
acknowledgement was one-shot. The route cleared manual_start and
filament_short, then the next scheduler tick re-ran
compute_deficit_for_queue_item against identical spool state, found
the same deficit, and re-set both flags. The item bounced between
"user said anyway" and "scheduler re-blocked" — every Play click
returned 409, every confirm got rolled back on the next tick.
Add a persistent acknowledgement flag on the queue item:
- New column `skip_filament_check` on print_queue. SQLite + Postgres
migration branched on is_sqlite() so Postgres doesn't reject
DEFAULT 0 on BOOLEAN.
- PrintQueueItemCreate + PrintQueueItemResponse schemas + the
TypeScript types carry the field.
- POST /print-queue/{id}/start with skip_filament_check=true now
ALSO sets item.skip_filament_check = True (not just clearing
manual_start / filament_short).
- PrintScheduler._block_on_filament_deficit short-circuits to
False — no compute, no flag-setting, no notification — when
item.skip_filament_check is True. We trust the operator's
decision and stop fighting them.
- PrintModal at queue-creation time threads
skip_filament_check=true into the create payload when the user
clicks Print Anyway on the frontend deficit warning, so a print
that was warned-then-acknowledged at add-to-queue time goes in
pre-acknowledged — scheduler never blocks it on first tick.
Flag is not auto-cleared on spool swap by design: if remaining is
now sufficient, the check returns no deficit anyway, so the flag
is moot. Auto-clearing would add lifecycle complexity without
changing behaviour.
AMS Backup awareness (the other half of the discussion) intentionally
NOT included — verified the H2D's bit-26 of print.cfg toggles with
the printer-side AMS Backup setting, but the X1C's cfg has a
different shape entirely and verifying every model family isn't
realistic. Silently under-warning would be worse than always
per-slot. The check stays single-slot for now.
Bambuddy's project_file MQTT payload hardcoded "nozzle_offset_cali": 2 (skip),
giving users on H2D / H2D Pro / H2C / X2D no way to control the same toggle
BambuStudio exposes. Critical for diamond-nozzle setups that must keep the
calibration off.
start_print() now takes a nozzle_offset_cali kwarg; the value is encoded as
1 (run) or 2 (skip) and gated on is_dual_nozzle so single-nozzle machines
always send 2 even if a stale flag arrives. The kwarg threads through
printer_manager, both background_dispatch sites, and print_scheduler so
every dispatch path respects the per-item setting.
print_queue gains a nozzle_offset_cali column (DEFAULT TRUE, is_sqlite()
branch for Postgres BOOLEAN). Settings default key default_nozzle_offset_cali
defaults to TRUE to match BambuStudio. Schemas updated across print_queue,
library FilePrintRequest, archive ReprintRequest, settings.
PrintModal renders the new toggle only when the selected printer is dual-
nozzle (printer-mode: nozzle_count===2; model-mode: DUAL_NOZZLE_MODELS).
SettingsPage default-print-options row + QueuePage bulk-edit tri-state both
hide unless any registered printer is dual-nozzle. Labels reuse the existing
settings.default* keys so the only new i18n strings are
settings.defaultNozzleOffsetCali / Desc and queue.bulkEdit.nozzleOffsetCali
- real translations in all 11 locales.
Two bugs in PrintScheduler._check_previous_success:
- Lookback excluded 'cancelled' so user cancellations were walked past
- Lookback included 'skipped', so one skip cascaded indefinitely
Swap to ['completed', 'failed', 'cancelled', 'aborted'] and accept
both 'completed' and 'cancelled' as predecessor success. Real
'failed' / 'aborted' still gate.
One-shot migration in run_migrations resets only the skipped items
whose true predecessor was cancelled — surgical reversal of the exact
bug fingerprint, leaves genuine failure-gated skips alone. Portable
across SQLite and Postgres, idempotent on re-run.
Non-proxy VPs (Archive / Review / Queue) with a target printer set up
a live-mirror bridge that forwards the slicer's MQTT and RTSPS auth
bytes through to the real printer. The slicer holds one code in its
profile (the one it bound the VP with), and that code has to satisfy
both the VP listener and the real printer at the far end of the
bridge. If the codes diverge the bridge silently fails at the second
hop — slicer reaches .49:8883, FINs before sending a ClientHello,
retries identically. The wiki framed the code-match requirement as a
camera-only concern; it isn't, all bridged protocols inherit.
Fix removes the foot-gun instead of re-documenting it. When a target
is selected on a non-proxy VP the access-code field switches to a
read-only display showing the target's code with an Eye-toggle
reveal; the backend auto-inherits on every create / update (any
explicit access_code submitted alongside a target is silently
overridden as belt-and-braces for non-UI clients). The required-when-
enabling check now treats target-set as satisfying the access-code
requirement. Standalone (no-target) non-proxy VPs still get the
editable input + Save button.
One-shot startup migration corrects any pre-existing mismatched
rows: SELECTs diverged VPs and logs one INFO line per row for the
audit trail, then UPDATEs via correlated subquery. Idempotent and
portable between SQLite and Postgres.
Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's
live-camera grab captured the bed already dropped, ruining the photo
framing. Source the photo from a brief Bambu timelapse instead —
firmware stops timelapse recording AFTER toolhead parks but BEFORE
bed-drop runs, so the last frame frames the finished print correctly.
When capture_finish_photo is on AND the user did not opt in to
timelapse for this print, force timelapse=True at dispatch + mark the
new PrintArchive.bambuddy_forced_timelapse column. After extraction
(success or failure), cleanup deletes the locally-attached file,
clears archive.timelapse_path, and walks the four scanner directories
(/timelapse, /timelapse/video, /record, /recording) trying FTP DELE
against the original filename. User-opted-in timelapses pass through
unchanged.
Resolver lives at services/background_dispatch.py::resolve_effective_timelapse
(module-level so the print queue can reuse it). Both dispatch paths
wired: background_dispatch.py (Print Now / Reprint) AND
print_scheduler.py:_start_print (the queue). Field testing caught the
scheduler gap on the first round — AST regression test now asserts
start_print(timelapse=...) references effective_timelapse, not the raw
item.timelapse, so a future refactor can't silently drop it.
Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded
frames overwrite the same output file, so the file left on disk is the
literal last frame regardless of duration. Bambu records one frame per
layer-change, so a 16-layer cube produces a 0.6 s timelapse — the
original -sseof -1.0 approach seeked before the start of the file and
returned frame 0 (empty bed). Decoding every frame is fine; Bambu
timelapses are short by construction even on hours-long prints.
Migration adds bambuddy_forced_timelapse branched on is_sqlite()
(DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN).
Verified live on postgres:16-alpine.
Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active
so the notification carries the bed-up photo instead of falling back
to the live-cam grab on slow links.
Scope limit, documented in the camera wiki: prints started directly
on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass
both dispatch paths, so the override doesn't fire there. Future
option: mid-print M981 S1 P20000 MQTT toggle in on_print_start.
Setting description rewritten in all 11 locales to drop the "only
works when timelapse enabled" caveat (Bambuddy now forces it) and
explain the kept-or-deleted behaviour.
files + unify file_type classification across ingest paths
#1600: external-folder sliced outputs landed
with no thumbnail. Cause: four backend ingest paths classified
LibraryFile.file_type differently for the same .gcode.3mf family.
upload / ZIP-extract / in-process used os.path.splitext()[1] which
returns .3mf for foo.gcode.3mf and stored file_type="3mf", matching
the thumbnail-extraction gate at library.py:1467 (file_type == "3mf").
External-folder scan explicitly detected the compound and stored
file_type="gcode.3mf" — preserving "sliced output" identity — but
then skipped both the "3mf" gate and the "gcode" gate, so the file
landed with thumbnail_path = None. Same compound-extension drift that
bit #1543 in the 3D preview, in a surface that audit didn't trace
back to.
Unified fix:
- New classify_file_type(filename) helper in routes/library.py is the
single source of truth. Returns "gcode.3mf" for sliced outputs and
ext[1:] otherwise.
- Applied to every ingest path: upload (line 1704), ZIP-extract
(1998), external-folder scan (the bug site — the manual compound
check is replaced), and in-process save_3mf_from_bytes (471, used
by MakerWorld import).
- External-scan thumbnail gate widened to
`if file_type in ("3mf", "gcode.3mf"):` — a .gcode.3mf IS a 3MF zip
with Metadata/plate_1.png; ThreeMFParser doesn't care about the
trailing extension.
- gcode-download endpoint at GET /library/files/{id}/gcode had the
same drift in reverse: gate was `elif file.file_type == "3mf":` so
a row stored with file_type="gcode.3mf" (the external-scan path's
pre-unification behaviour, and the canonical going forward) got
rejected with HTTP 400. Widened to the same compound-aware tuple.
One-shot DB migration in core/database.py::run_migrations backfills
existing legacy rows:
UPDATE library_files
SET file_type = 'gcode.3mf'
WHERE file_type = '3mf'
AND LOWER(filename) LIKE '%.gcode.3mf'
Idempotent (post-update rows no longer match the file_type='3mf'
predicate, so re-runs at every boot are no-ops) and dialect-neutral
(LOWER + LIKE are identical under SQLite and Postgres). Without the
backfill, users would have a permanent split state: old uploads at
'3mf', new uploads at 'gcode.3mf' — which would double-bucket sliced
outputs in the dashboard stats query at line 4615 and show two
entries in the file-manager filter dropdown for the same conceptual
type.
Frontend untouched. FileManagerPage.tsx and ProjectDetailPage.tsx
already accept both '3mf' and 'gcode.3mf' per the #1543 fix. After
the migration the DB only contains canonical values, so the legacy
'3mf' branches in the frontend become dead code for sliced files —
they stay as defence-in-depth in case any future ingest path I
missed reverts to the legacy classifier.
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN
that rules out subnet / mDNS-reflector theories): with the physical printer
off the slicer's "Send" landed in Bambuddy's archive; once the printer
powered on every subsequent "Send" went straight to the printer's SD card
and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP
receive + archive lines, mape6-after had zero FTP attempts to Bambuddy
once the printer was online.
Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le /
_vp_ip_uint32_le ONLY on client-identity change and early-returned on
every refresh tick when the same client object was still bound. If
target_client.ip_address was empty at first bind (DB row stale, or client
constructed before SSDP refresh filled it in), the encoding stayed None,
the net.info[*].ip rewrite block was skipped, the cache filled with the
real printer IP, sticky-key preservation kept the poisoned net value
alive across every subsequent incremental push, and the slicer followed
the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the
workaround both reporters independently arrived at. Same shape on
multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries
whose ip equalled _target_ip_uint32_le, so a secondary interface IP
Bambuddy never saw would leak through unchanged.
Bridge fix:
- _resolve_client calls a new _refresh_ip_encoding() on every refresh
tick, even when client identity is unchanged; self-heals once
ip_address becomes valid.
- _refresh_ip_encoding() sweeps the existing _latest_print_state when
encoding becomes valid for the first time. Without the sweep,
sticky-key preservation keeps the pre-arm poisoned cache alive
forever — incremental pushes that don't include net carry the bad
value forward.
- _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry
that doesn't already equal the VP IP, not just entries matching
_target_ip_uint32_le. Multi-NIC printers stop leaking secondary
interfaces. Zero-IP placeholders are left alone so "active interface"
detection still works.
- INFO logging on encoding arm/update and on cache sweep so future
bundles directly answer "did the rewrite fire?".
Mode wire-value rename (#1429 follow-up, separate confusion source):
- Both reporters' support bundles showed mode: immediate while the UI
said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says
immediate in the support-info.json file. In the webui the printer is
set to archive". UI button "Archive" had always saved immediate, and
"Queue" had always saved print_queue. Canonical wire values are now
archive / review / queue / proxy, matching the button labels 1:1.
- New normalize_vp_mode() + VP_MODE_* constants in
models/virtual_printer.py; manager.py normalises on construction so
a legacy row read pre-migration still dispatches correctly.
- core/database.py::run_migrations rewrites existing virtual_printers
and settings rows; idempotent (re-runs are no-ops); identical SQL
under SQLite and Postgres.
- API routes accept both legacy and canonical on input, normalise
before storage. GET /settings/virtual-printer normalises on read so
the frontend's mode-button highlight works for stale legacy values.
- Three frontend VP components (VirtualPrinterSettings,
VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers
and type aliases to canonical; each got its own normalizeMode()
helper so a stale-cached settings payload still highlights the right
button. Two pre-existing `printer.mode === 'queue' ? 'review'`
legacy mappings in VirtualPrinterCard were the source of a test
failure caught mid-implementation where the new canonical 'queue'
was being mis-aliased back to 'review' and hiding the auto-dispatch
+ force-color-match toggles.
mode handler is NOT the dispatch bug: manager.py::_archive_file (the
handler for archive mode) doesn't dispatch to the physical printer.
The "files end up on the printer's SD card" symptom was the IP-leak
from the bridge cache. The mode rename is purely clarity / support-
bundle accuracy.
API-key permission gates went from a 17-entry admin denylist with the three
documented scope flags (can_read_status / can_queue / can_control_printer)
enforced only inside /api/v1/webhook/* to an explicit per-Permission
allowlist consulted by every dependency:
- core/auth.py: _APIKEY_SCOPE_BY_PERMISSION maps every non-admin
Permission to one scope flag on APIKey; unmapped = 403.
_check_apikey_permissions now takes the api_key and checks the flag.
- require_any_permission_if_auth_enabled + require_ownership_permission
were returning None for any valid key with zero scope check; both now
invoke _check_apikey_permissions and fail closed.
- Two new scope flags on api_keys: can_manage_library (LIBRARY_UPLOAD /
UPDATE_OWN / DELETE_OWN / MAKERWORLD_IMPORT) and can_manage_inventory
(INVENTORY_CREATE / UPDATE / DELETE / FORECAST_WRITE — required by
SpoolBuddy kiosks). Default TRUE, backfilled from can_queue so existing
"queue-only" keys keep working and hardened "read-only" keys do not
silently gain writes.
- CLOUD_AUTH now routed through can_access_cloud for defence-in-depth
alongside the existing _cloud_api_key_gate.
- Migration column-existence check (_api_keys_column_exists) gates the
backfill so user-edited values are never overwritten on restart.
Structural drift backstop: test_every_permission_has_a_classification fails
CI on any new Permission added without an explicit scope mapping —
prevents the denylist-shape regression that grew the prior surface.
Backend 5469 tests green; ruff clean. Frontend build green; i18n parity
green across 9 locales (5005 leaves each, +6 new keys). Wiki permissions
table + allowlist callout + upgrade notes updated.
The pre-print deficit warning from #720 only ran inside the PrintModal
submit flow. Both the green ▶ button on a staged queue row (POST
/queue/{id}/start) and the Virtual Printer queue-mode intake bypassed
it — auto_dispatch=True VP intakes would dispatch unsupervised onto
spools that physically can't complete the print.
Extracted the deficit check into backend/app/services/filament_deficit.py
(single source of truth, both internal inventory and Spoolman modes).
POST /queue/{id}/start returns 409 with a structured deficit payload
unless ?skip_filament_check=true. The dispatch scheduler runs the same
check before each _start_print; a deficit promotes the item to
manual_start + sets a new filament_short flag (idempotent migration on
print_queue). The flag clears automatically on the next tick when the
operator swaps a spool to one with enough material.
Frontend ▶ catches the 409 and opens a confirm modal showing each
shorted slot's required vs remaining grams; the row now renders a
yellow "Insufficient filament" badge when filament_short is set.
Translated across all 9 locales.
File Manager cards, search and sort keyed off file_metadata.print_name,
which ThreeMFParser lifts from the 3MF's <metadata name="Title">. That
title is the in-app project title — generic "Exported 3D Model" for any
Bambu Studio "Save As", a marketing title for a MakerWorld download —
and almost never the filename the user saved as. A card for
Whatever.3mf showed "Exported 3D Model"; correcting it needed a rename
round-trip, since the Rename dialog disables Save while the name is
unchanged.
The slicer-output write path already dropped print_name for this exact
reason; the four other paths that store parsed 3MF metadata onto a
LibraryFile did not — external-folder scan, managed multipart upload,
the multi-file ZIP-upload branch, and MakerWorld import.
Add a shared _without_print_name() helper and apply it at all four
import paths; switch the slicer path to it so there is one rule. A
LibraryFile's display name is its filename — only PrintArchive carries
a real print_name, which is untouched. Remove the now-redundant
filename->print_name mirroring in the rename route.
Add a one-time idempotent data migration (_migrate_drop_library_print_name,
SQLite json_remove / PostgreSQL jsonb key-removal branched on
is_sqlite()) so libraries imported before the fix correct themselves
without the rename workaround. No frontend change: print_name || filename
yields the filename once print_name is gone.
Tests: 6 new in test_library_print_name.py cover _without_print_name and
the migration (incl. idempotency, siblings preserved, null metadata).
SQLite migration branch verified by test; PostgreSQL branch verified
against a real Postgres instance.
Reporter saw a 544 g spool jump to 1000 g after pressing the eraser.
"Spools and remaining weights are not changed" - the dialog promised
this; the implementation did the opposite. Root cause was an
architectural conflation: `weight_used` did double duty as the
resettable "consumed since tracking started" counter AND as the basis
for the displayed remaining (`label_weight - weight_used`), so zeroing
it correctly cleared the stat but unavoidably reset remaining to full.
Spoolman has separate `used_weight` and `remaining_weight` fields, so
the API call there was correct - but Bambuddy's frontend was also
computing remaining as `label_weight - weight_used` for Spoolman
spools (ignoring Spoolman's real `remaining_weight` field), so the
same visual bug bit there too. Inventory-mode parity required fixing
both halves in one drop.
Internal mode
- New `weight_used_baseline` column (Float DEFAULT 0) on `spool`.
- Reset stamps `baseline = weight_used` and leaves `weight_used` alone.
- Displayed consumed = `weight_used - baseline`; remaining =
`label_weight - weight_used` (unchanged).
- Subsequent prints continue to grow `weight_used`, so the resettable
counter naturally tracks post-reset delta and remaining keeps
decrementing across the reset.
Spoolman mode
- `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field
and returns a synthetic `weight_used = label - remaining` so the
frontend's remaining calc matches Spoolman's real stored value;
`weight_used_baseline = synthetic - real_used_weight` so the consumed
counter (`weight_used - baseline`) matches Spoolman's `used_weight`.
- Fallback path (no `remaining_weight` set) preserves the old behavior.
- Related fix: `update_spool` (Spoolman PATCH) was deriving the default
`weight_used` from `used_weight`, so editing unrelated fields AFTER
a reset would patch Spoolman with `remaining_weight = label - 0 =
label`, trampling the real value. Now derives from
`remaining_weight` so non-weight edits preserve physical state.
Frontend
- `InventoryPage` `totalConsumed` aggregate switched to
`Math.max(0, weight_used - (weight_used_baseline ?? 0))`.
- `ForecastPanel` `computeDeltaRate`, `totalUsedG`, and the per-spool
"consumed" table cell got the same treatment so forecast and
inventory aggregates stay coherent across a reset.
- `?? 0` keeps pre-migration installs rendering correctly until
`init_db()` runs the idempotent ALTER TABLE.
Migration
- `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0`
via `_safe_execute` - SQLite and Postgres both accept it; verified
end-to-end on Postgres 16.
Reporter Kyobinoyo asked for the equivalent of the existing
print-finish auto-off but triggered when AMS drying ends.
Two new SmartPlug columns: auto_off_after_drying (default false),
off_delay_after_drying_minutes (default 10 — AMS chamber is hot
post-cycle so longer cooldown than the print-finish default of 5).
SQLite + Postgres migrations both idempotent.
Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times
tracks the dry_time > 0 → 0 falling edge and fires a new
on_drying_complete(ams_id) callback. Plumbed through
PrinterManager.set_drying_complete_callback to
SmartPlugManager.on_drying_complete(printer_id, db), which walks
linked plugs and respects the per-plug toggle. Catches queue,
ambient and manual drying identically because it observes firmware
state, not scheduler intent.
Frontend: single "Auto Off After Drying" toggle + delay input on
the smart plug card, next to the existing print-finish auto-off
section.
Per-AMS plug routing (separate plug for AMS only, per-AMS targeting
on dual-AMS printers) deferred — Bambuddy's plug model is
plug→printer, so the trigger fires whenever any AMS on the linked
printer finishes a cycle.
Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the
per-event aggregation rewrite from #1378) and saw Quick Stats split
between consistent values (Total Prints, Print Time, Filament Used,
Energy, Success Rate matched the archive list) and zero-or-empty ones
(Filament Cost, Time Accuracy).
Root cause: #1378's migration added six columns to print_log_entries
- archive_id, cost, energy_kwh, energy_cost, failure_reason,
created_by_id - but never backfilled them. Pre-upgrade rows kept NULL
on all six. The new Quick Stats query sums PrintLogEntry.cost (gets 0
on legacy data); the time-accuracy query JOINs PrintArchive ON
archive_id (drops every legacy run from the average). Counts and the
pre-existing per-row fields (status, duration_seconds,
filament_used_grams) kept working - which is why some panels looked
right and others didn't.
Two-step backfill added inside run_migrations next to the existing
column-add block, as DML inside begin_nested() (not _safe_execute,
which is documented DDL-only):
Step 1: link each orphan log entry to its archive via
print_name + printer_id (highest archive id wins on
tiebreak - newest matches the overwrite-then-stop shape
pre-#1378 reprints left behind).
Step 2: copy archive.cost / energy_kwh / energy_cost onto the
latest matching log entry per archive, BUT only for
archives where no log entry yet carries a cost. That
second clause is the idempotency anchor and the
double-count guard for users running this after #1378
has already written cost-bearing rows for new runs -
those archives are left untouched.
Earlier reprints stay NULL, matching the "first/latest writes, rest
stay NULL" convention #1378 introduced for new prints. Sum across the
legacy reprint chain reproduces sum-of-archive-cost exactly, so Quick
Stats Filament Cost matches the pre-upgrade total instead of dropping
to zero.
SQL is plain ANSI - correlated UPDATE with LIMIT 1 in the SET
subquery, WHERE id IN (SELECT MAX(id) ... GROUP BY archive_id HAVING
SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0). Verified
end-to-end on SQLite (4 new unit tests in
test_print_log_backfill_migration.py: link-via-name, latest-run-gets-
cost, idempotent, skip-archives-with-any-costed-run) and against a
live postgres:16-alpine + asyncpg container (first-pass and second-
pass produce identical state).
The other widgets the reporter listed (Printer Stats, Filament
Trends, By Material, Success by Material, Color Distribution) iterate
the archives list on the frontend rather than calling /stats - they
read consistent pre-upgrade data and aren't part of this fix; the
inconsistency between them and Quick Stats resolves once the backfill
brings Quick Stats in line.
Statistics now aggregate over PrintLogEntry (one row per print event,
the same table backing the global Print Log) rather than PrintArchive
(one row per file). A reprint creates a new PrintLogEntry instead of
overwriting the source archive's runtime fields, so:
- a 100 g successful print + a 10 g failed reprint correctly sums to
110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
Prometheus /metrics endpoint (previously the failed reprint silently
replaced the source archive's data; totals dropped from 100 g to 10 g)
- the archive's card cost/energy_kwh are preserved on reprints (only
the first run writes them); per-run actuals live on PrintLogEntry
- failed/cancelled/stopped reprints record partial-aware filament: sum
of tracked spool deltas when inventory is set up, else estimate
scaled to progress%, else None — prevents the full slicer estimate
from inflating totals on a print that stopped at 10 % progress
PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
SET NULL so log entries survive archive deletion preserving #1343
soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.
New per-archive surface:
- archive list response carries run_count / last_run_at /
total_filament_actual_grams / successful_run_count / failed_run_count
via a single batch JOIN, no N+1
- new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
the archive (ARCHIVES_READ permission, newest-first ordering)
- archive cards render an orange "N prints" badge for archives with
more than one run; clicking the badge opens a dedicated PrintLogModal
with date/status/duration/filament/cost columns plus failure_reason
under failed runs. Also reachable via the context menu's new "Print
Log" entry (works for single-run archives too), and embedded at the
top of the Edit Archive modal for context.
The purge_stats=true delete path now hard-deletes linked PrintLogEntry
rows up front so the archive's contribution truly leaves the totals;
without it, ON DELETE SET NULL would orphan the runs and leave them
counting toward stats.
Reporter @maziggy followed the Energy Tracking wiki literally - "create a
key with Write Settings permission, PATCH /api/v1/settings with
{energy_cost_per_kwh: ...}" - and hit:
{"detail":"API keys cannot be used for administrative operations"}.
Triage showed three independent drifts:
1. Wiki listed nine fictional API-key permissions (Read Printers / Write
Settings / Admin / ...) but the UI only ever exposed four toggles
(Read Status, Manage Queue, Control Printer, Allow Cloud Access).
There was no Write Settings toggle to tick.
2. Even if it had existed, the backend hard-denies SETTINGS_UPDATE for
every API key via _APIKEY_DENIED_PERMISSIONS - intentional protection
because PATCH /settings can rewrite SMTP/LDAP/MQTT credentials and the
HA access token. Wider surface than any documented use case needs.
3. So the wiki had been promising a workflow that was never deliverable.
Fix: introduce a narrowly-scoped door rather than relax the deny list.
- New column can_update_energy_cost (default FALSE - existing keys
never silently gain settings-write capability on upgrade).
- New route POST /api/v1/settings/electricity-price accepting
{"energy_cost_per_kwh": <float >= 0>}. Field name matches what the
wiki already documented so the HA rest_command example needs only a
URL+method change, not a payload change.
- Custom dependency require_energy_cost_update() bypasses
_APIKEY_DENIED_PERMISSIONS for this one route for API keys with the
flag set. JWT users still go through standard SETTINGS_UPDATE.
- General PATCH /settings remains denied for API keys - flipping the
narrow flag does NOT widen general settings-write access. Pinned by
test_patch_settings_still_denied_with_energy_flag.
Frontend: fifth "Update electricity price" toggle on the create-API-key
card + amber "Energy" badge on existing keys with the flag set. Three
new i18n keys across all 8 locales (German translated, English fallbacks
elsewhere).
Reported by @IndividualGhost1905: printing the same model ten times and
then deleting nine archive entries (to keep the file list tidy) silently
rewound the totals on the Statistics page — total prints, filament,
cost, and per-print energy all dropped back to whatever the surviving
row contributed, as if the other nine prints had never happened.
Root cause: every metric in get_archive_stats is recomputed live from
PrintArchive rows via COUNT / SUM, so removing a row removes its
contribution. Energy in the default "Total" mode already survived
deletion because it reads the smart-plug lifetime counters — that's
the architectural shape we now generalise to the rest.
Fix: soft delete with opt-in hard purge.
Backend:
- New nullable, indexed deleted_at column on print_archives, dialect-
conditional migration (DATETIME on SQLite, TIMESTAMP on PostgreSQL).
- ArchiveService.soft_delete_archive flips deleted_at and removes the
files from disk (still reclaims storage); the path-safety checks were
extracted into _resolve_archive_dir_for_delete so soft and hard delete
share the rules.
- DELETE /archives/{id} accepts ?purge_stats=true; default is soft.
- Listings filter deleted_at IS NULL: list_archives, search FTS + LIKE
fallback, GET /{id} (404 on soft-deleted), tag listing, duplicate
detection (so a 1-live + 9-soft-deleted group no longer marks the
survivor as a duplicate), and ArchiveComparisonService's "similar"
suggestions. GET /stats and GET /slim deliberately do NOT filter so
Quick Stats and the dashboard widgets keep counting deleted prints.
Frontend:
- ConfirmModal gained an optional children slot.
- ArchivesPage (both card and detail views) own a per-instance
deletePurgeStats boolean and render an opt-in checkbox in the delete
dialog; resets to off on every close so the destructive option is
never sticky.
- api.deleteArchive(id, purgeStats?) appends ?purge_stats=true only
when the box is ticked.
- One new i18n key archives.modal.deletePurgeStats added across all 8
locales (full German, English fallbacks elsewhere).
* feat(auth): proxy OIDC provider icons server-side (#1333)
Strict img-src CSP blocked external OIDC icon hosts on the login page.
Loosening CSP was rejected via the MakerWorld precedent, so icons are
proxied: admin sets icon_url, backend fetches and caches the bytes in a
deferred BLOB column, the SPA renders from a same-origin
/api/v1/auth/oidc/providers/{id}/icon endpoint.
fix(auth): cleanup orphan OIDC/MFA rows on user delete (#1285)
Three User-FK tables (user_oidc_links, user_totp, user_otp_codes)
declare ON DELETE CASCADE in their models, but SQLite ships with
PRAGMA foreign_keys=OFF (the project's existing pattern, mirrored
for APIKey in PR #1182). Without explicit DELETEs, deleting a user
on SQLite leaves orphan rows behind:
H2C / H2D AMS-HT units report ams_id 128+ (one ams_id per unit, single
tray), but spoolman_slot_assignments.ck_ams_id_range only admitted 0-7
and 255. Every attempt to link a Spoolman spool to an AMS-HT slot died
with `CHECK constraint failed: ck_ams_id_range`. The internal
spool_assignment table has no such constraint and works fine.
Widen the formula to (0-7) OR (128-191) OR 255 in the model, the
CREATE TABLE DDL, and an idempotent in-place migration for existing
installs (Postgres: DROP/ADD CONSTRAINT; SQLite: detect stale formula
in sqlite_master, rebuild via _v2 rename pattern).
Show an OrcaSlicer-style bed icon in the archive card's printer-name row
indicating which build plate the print was sliced for (Cool /
Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI),
with the full plate name in the hover tooltip. Closes the gap where
users had to remember which plate matched a re-print or open the
source 3MF in a slicer just to read the bed setting.
Card row also unified: archives with a real Bambuddy-printer
association used to render "H2D-1 GCODE ..." while slicer-only uploads
rendered "Sliced for X1C GCODE ..." -- same line, two different shapes.
Drop the "Sliced for " prefix so both render as a uniform
"<name-or-model> [bed-icon] GCODE <hash>" row, scanning identically
regardless of provenance.
Backend: new bed_type column on print_archives (idempotent ALTER TABLE
migration; SQLite + Postgres safe). Populated from curr_bed_type in
Metadata/slice_info.config (per-plate, authoritative -- that's what
got sent to the printer for the exported plate) with a fallback to
project_settings.config for older 3MF shapes. Wired through both
archive_to_response() (the hand-rolled dict converter that bypasses
from_attributes -- easy to miss) and the /rescan endpoint, so old
archives can be re-parsed via the existing per-archive Rescan button.
Backfill script (scripts/backfill_archive_bed_type.py, --dry-run
supported) re-opens every NULL archive's 3MF on disk to populate the
column. Auto-loads .env from project root before importing backend
modules (config.py reads DATABASE_URL from os.environ at import time,
not from pydantic-settings at Settings() time) and prints the resolved
DB URL with credentials redacted, so operators can confirm they're
hitting the intended database -- Postgres or SQLite.
Frontend: 6 OrcaSlicer-style PNGs ship in frontend/public/img/bed/ --
under /img/ because that path is already statically mounted; a
toplevel /bed-icons/ tried first hit the SPA catch-all and returned
index.html as text/html. New utils/bedType.ts maps slicer strings
case-insensitively, covering both Bambu Studio and OrcaSlicer naming
variants for the same physical plate. Unmapped or NULL bed_type
simply omits the icon, so cards stay clean for pre-feature archives.
The kiosk's Settings -> Update Daemon button returned "API keys cannot
be used for administrative operations" because POST /spoolbuddy/devices/
{id}/update was gated on Permission.SETTINGS_UPDATE, and SETTINGS_UPDATE
is in the _APIKEY_DENIED_PERMISSIONS deny-list introduced by PR #1241.
Every kiosk-side request tripped the deny-list before the API key's
scope set (Read / Print Queue / Control / Legacy) was even consulted.
Same root cause as the four QuickMenu System buttons fixed in 0.2.4b3
(Restart Daemon / Restart Browser / Reboot / Shutdown). Missed /update
in that audit on the reasoning "replaces the daemon binary, different
threat surface" — but that's wrong: restart_daemon already replaces
the running daemon process, so daemon-replacement is not a step up in
blast radius. The SSH update is also strictly scoped to the one device
the operator physically controls (git fetch + pip install + systemctl
restart on that host) — same threat profile as the system commands
already running on INVENTORY_UPDATE.
Lower /spoolbuddy/devices/{id}/update from SETTINGS_UPDATE to
INVENTORY_UPDATE so it aligns with the rest of the kiosk-scoped routes
(calibration/tare, display, cancel-write, system/command,
system/command-result, update-status). The main Bambuddy in-app updater
at POST /api/v1/updates/apply keeps SETTINGS_UPDATE — that one runs on
the Bambuddy host and is correctly fenced behind the deny-list.
feat(spoolman-inventory): squashed feature work for rebase onto dev
Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
chore(i18n): extend parity gate to all locales with strict/info tiers
Previously the script only inspected en/zh-CN/zh-TW, leaving de/fr/it/ja/pt-BR
drift invisible. Now locales are auto-discovered from src/i18n/locales/, and a
STRICT list (de, zh-CN, zh-TW — currently in parity) gates CI while the rest
report informationally until their drift is caught up. ja notably has 27 real
placeholder bugs worth fixing before promotion to strict.
feat(spoolman-inventory): squashed feature work for rebase onto dev
Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
go2rtc and several IP cameras still emit a warm-up / black frame on every
fresh MJPEG connection — even with the v0.2.4b2 warm-up-skip fix it
slipped through intermittently for @nkm8's setup. His own bisect named
the clean solution: go2rtc exposes /api/frame.jpeg as a dedicated
single-frame endpoint that never returns the encoder's stale keyframe.
Adds an optional external_camera_snapshot_url column on printers. When
set, every single-frame capture path (snapshot endpoint, [SNAPSHOT]
notification thumbnails, [PHOTO-BG] finish photo, layer timelapse,
Obico ML, plate-detect / calibrate-plate) routes through _capture_snapshot
on the override URL via plain HTTP GET, bypassing the warm-up dance.
Live view stays on the configured stream URL — only single-frame
captures use the override. Override is camera-type-agnostic. SSRF guard
applies (existing _sanitize_camera_url allowlist). Empty string treated
as unset.
Settings UI: new "Snapshot URL (optional)" input + Test button under
External Cameras, hidden for camera_type=snapshot since the live URL is
already a single-frame source. en + de fully translated; 6 other locales
seeded with English copy.
5 backend tests pin the routing contract; 3 frontend tests pin the
input + debounced PATCH. Documented in
bambuddy-wiki/docs/features/camera.md with the go2rtc example.
fix(oidc): use preferred_username/name claim for auto-created username
When auto-creating an OIDC user without a valid email claim, derive the
username from preferred_username or name IdP claims instead of falling
back to the opaque provider_sub[:30].
Edward's diagnosis was exact: the manual /print-queue/ POST extracts
filament requirements from the 3MF and writes
required_filament_types + filament_overrides + ams_mapping onto the
queue item, but the VP queue-mode write path skipped all of that.
Net effect: scheduler reached its model-only-matching fallback and
auto-dispatched onto whatever printer was free regardless of loaded
colour.
Extract the scheduler's existing _get_filament_requirements 3MF
parser into a shared helper so the VP path can reuse it. VP's
_add_to_print_queue now populates required_filament_types
unconditionally (cheap; helps the scheduler reject obvious type
mismatches) and writes filament_overrides with force_color_match:
true per consumed slot when a new per-VP queue_force_color_match
toggle is on. Default off to preserve current behaviour for
upgraders.
UI: new toggle on VirtualPrinterCard, mode-gated to print_queue,
mirroring the existing auto-dispatch toggle. i18n: en + de
translated, other 6 locales seeded with English copy.
Schema: one nullable column on virtual_printers
(queue_force_color_match BOOLEAN, default 0/FALSE).
11 new backend tests (8 for the extracted parser, 3 for the VP
write path) + 6 new frontend tests (toggle render gating, default
state, click posts queue_force_color_match in update body).
Existing scheduler tests pass against the refactored helper.
README, CHANGELOG, website features page, and wiki virtual-printer
page all updated.
@smandon retested the original #1152 fix on the latest daily and surfaced
two distinct holes:
1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's
default ``Plate_1.gcode.3mf`` exports landed in the archive UI as
``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected.
2. The pending-uploads review card always showed the raw FTP filename,
while the eventual ``PrintArchive.print_name`` resolved from the 3MF's
embedded title (or, with the toggle on ``filename``, the stripped stem).
Net effect: same upload showed two different names depending on which
view you were looking at, with no way for the toggle to flip both
views in lockstep.
Three changes:
- ``resolve_display_stem`` helper in ``services/archive.py`` strips
``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at
the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for
every flow that produces a ``PrintArchive`` row.
- ``PendingUpload.metadata_print_name`` (new nullable column) is
populated at FTP-receive time by peeking at the 3MF's embedded title
via the existing ``ThreeMFParser``. Read happens once per upload —
the list endpoint then doesn't have to reopen each 3MF on every
render. Parser failures are swallowed and the column stays NULL;
the response model gracefully falls back to the stripped filename.
- ``PendingUploadResponse.display_name`` is a computed field that
mirrors ``archive_print``'s exact precedence — ``filename`` toggle
→ stripped stem; ``metadata`` toggle (default) → cached title or
stripped stem. The frontend's review card reads it (with
``upload.filename`` as a defensive fallback) and surfaces the raw
FTP filename via tooltip so users can still inspect what arrived.
Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN
metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration
rows have NULL and degrade to filename-stem behaviour without any
operator action.
Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the
canonical normalisation rules (Bambu Studio default name, mixed case,
dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path
inputs); 6 integration tests in ``test_pending_upload_display_name.py``
pinning the response contract (default toggle uses metadata title when
present, falls back to stripped stem when absent, ``filename`` toggle
overrides metadata, ``filename`` toggle still strips the double suffix,
``GET /{id}`` exposes the same field, whitespace-only metadata behaves
like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx``
pinning the review card's render path (resolved name shown, fallback
to filename when display_name is empty, raw filename available via
tooltip). Full backend suite: 3598 passed; frontend build clean; no
regressions in any flow that previously processed ``.3mf`` /
``.gcode`` / non-3D filenames.
Tim (@turulix) is building a fully automated headless slicing pipeline
against Bambuddy's API and hit the wall flagged in #665: /cloud/* routes
resolve cloud_token per-user from User.cloud_token, but the auth gate
returned None for API-keyed requests, so the route fell back to the
global Settings-table token, which only carries a value in auth-disabled
deployments. Net effect on auth-enabled deployments: API keys reached
the gate just fine, then /cloud/filaments always saw user=None and
returned 401 / empty results — no path to read slicer presets or the
filament catalogue that a CLI workflow needs.
Make API keys carry an owner and route /cloud/* lookups through that
owner; gate the new capability behind an explicit opt-in scope so
existing automation doesn't gain cloud-read access on upgrade.
- APIKey gains user_id (FK to users.id, ON DELETE CASCADE) and
can_access_cloud (BOOLEAN DEFAULT 0). User-delete route also runs an
explicit DELETE FROM api_keys WHERE user_id = ? since SQLite ships
FK enforcement off — same pattern as the existing created_by_id
cleanup blocks.
- New cloud_caller dep on /cloud/* routes resolves to the JWT user OR
the API-key owner stashed by a router-level gate. The auth gate itself
continues to return None for API keys so #1182's surface stays bounded
to /cloud/* — without that bound, any route that fences API keys via
`if current_user is None: raise 403` (e.g. long-lived-token
management) would silently start accepting them.
- The /cloud/* router-level dep enforces three independent fences for
API-keyed callers: user_id IS NOT NULL (legacy keys → 401 with
recreate copy), can_access_cloud=True (otherwise 403), and owner has
cloud_token (existing fence, unchanged). Two extra one-shot fence
errors at create/update time refuse can_access_cloud=True when auth
is disabled or the key is ownerless.
- Frontend: APIKey list shows "Cloud" badge on cloud-enabled keys and
"Legacy" badge on ownerless rows; create form gains an "Allow cloud
access" toggle, default off. New i18n keys in all 8 locales (en + de
fully translated, others seeded with English fallbacks pending native
translation — matches the project's flow for newly-added features).
Migration: two idempotent ALTER TABLE statements + an index on user_id
for the auth gate's owner→keys lookup. Postgres-safe.
Tests: 9 backend integration tests in test_api_key_cloud_access.py
covering creation flags, the three /cloud/* fences, JWT no-op, and
deletion CASCADE; 2 frontend SettingsPage tests pinning the badge
matrix and the create-form contract; 5 daemon unit tests for the
related SpoolBuddy ssh-key sync work that landed in the same branch.
Full backend suite: 3578 passed; full frontend suite: 1597 passed; no
regressions.
Permission semantics for existing keys: keys created before this
release become "legacy" and are rejected at /cloud/* with the recreate
message. Every other endpoint they were used against — queue, status,
control — is untouched.
Spool and color_catalog rows carry extra_colors (comma-separated hex
stops) and effect_type (14 visual variants: surface effects, sheen,
structural). The shared FilamentSwatch component renders gradient,
conic, effect overlay, and alpha-checkerboard consistently across the
inventory grid, table, group banner, card, ColorSection preview, and
catalog editor. Catalog hex_color accepts #RRGGBBAA so catalog entries
can carry transparency too.
The paste field accepts the exact format 3dfilamentprofiles.com puts on
its filament details pages, so users can copy a multi-colour combo
directly. The effect dropdown spans the full filament-variant
vocabulary -- surface effects (sparkle/wood/marble/glow/matte), sheen
variants (silk/galaxy/rainbow/metal/translucent), and structural
variants (gradient/dual-color/tri-color/multicolor). None of these
fields touch MQTT/firmware -- pure visual hint.
Spool group-key extended to include extra_colors + effect_type so
"Group similar" no longer collapses visually distinct spools.
Migrations: 4 idempotent ALTER TABLE ADD COLUMN (Postgres-safe), plus
ALTER COLUMN hex_color TYPE VARCHAR(9) on Postgres only (SQLite ignores
VARCHAR length).
Tests: 42 new backend (35 unit + 7 integration), 20 new frontend (14
FilamentSwatch + 3 ColorCatalogSettings + 3 InventoryPageGrouping
regression). 3522 backend + 1582 frontend tests pass; ruff clean.
Localised across all 8 UI locales.
Two new project fields: a free-text URL rendered as a one-click
external-link button beside the project name on every card (opens in a
new tab, click is e.stopPropagation()-guarded so it doesn't enter the
project), and a cover photo that replaces the status-icon box with a
square thumbnail.
URL is plumbed through ProjectCreate/Update/Response/ListResponse,
including from-template + create-template flows so it inherits between
a project and its template. Cover photo is not inherited because the
file would be shared on disk between source and copy.
Schema validator rejects anything other than http:// or https://
prefixes -- <a href> rendering would otherwise execute javascript:
/ data: / file: URLs even with React's default escaping. PATCH uses
model_fields_set for the URL field so users can clear it by sending
{"url": null}.
Cover image storage: Project.cover_image_filename references a file
Cover image storage: Project.cover_image_filename references a file
inside the existing archives/projects/{id}/attachments/ dir, but it's
tracked separately from the attachments JSON list so swap/delete on
the cover doesn't perturb the user's other attachments. Three routes
(POST/GET/DELETE /projects/{id}/cover-image) accept only .jpg/.jpeg/
.png/.gif/.webp (no SVG -- SVG can carry script payloads), replace in
place (prior file deleted before the new one lands so repeat uploads
can't accumulate orphans), and self-heal when a DB reference points at
a vanished disk file by clearing the column and 404'ing.
GET cover-image is gated by RequireCameraStreamTokenIfAuthEnabled
(accepts ?token=... query string) -- not the bearer-token gate -- so
<img src> requests work in both auth-on and auth-off configurations.
The frontend wraps getProjectCoverImageUrl with withStreamToken(),
matching the existing pattern from getArchiveThumbnail.
Permissions: PROJECTS_UPDATE for upload/delete/PATCH, PROJECTS_READ
gate is implicit via the stream-token credential. Migration: 2
idempotent ALTER TABLE projects ADD COLUMN. Localised across all 8
UI languages.
@Carter3DP's support package showed bambuddy.log filling with two
distinct cascades on long uploads:
ERROR sqlalchemy.pool Exception terminating connection ...
CancelledError: Cancelled via cancel scope
... by starlette.middleware.base
.BaseHTTPMiddleware.__call__.call_next
ERROR sqlalchemy.pool The garbage collector is trying to clean up
non-checked-in connection ... will be
terminated.
WARN backend.app.main Runtime tracking commit failed:
(sqlite3.OperationalError) database is locked
Single root cause. Starlette's BaseHTTPMiddleware (used under the hood
by every @app.middleware("http") decorator) cancels the inner task
scope when a client disconnects mid-request — common on long
multipart uploads where the client times out before the server's
response. Pre-fix get_db only caught Exception, but CancelledError
is BaseException, so cancellation skipped the rollback path entirely.
The SQLite write lock stayed held until GC reclaimed the connection
ages later, blocking every other writer in the meantime. On Postgres
the leak shape is identical; the symptom would be "QueuePool limit
... overflow" instead of "database is locked".
(1) get_db now catches BaseException so CancelledError triggers
rollback. Both rollback() and close() are wrapped in
asyncio.shield so the cleanup completes even when the await
itself is being cancelled by the same cancel scope. SQLite write
lock is released promptly; connection returns to the pool instead
of leaking until GC.
(2) CancelledPoolNoiseFilter (new filter on sqlalchemy.pool) drops
the residual records that pre-existing pools still emit during
their own cleanup. Two patterns suppressed:
- "Exception terminating connection ..." with a CancelledError
anywhere in the exc_info chain (walks __cause__/__context__
with a seen-set guard against pathological cycles)
- "The garbage collector is trying to clean up non-checked-in
connection ..." (always symptomatic of cancellation; never
independently actionable)
Real pool problems — broken connections, OSError on terminate,
pool exhaustion — keep flowing because they carry a different
exception chain or a different message prefix.
13 regression tests across test_get_db_cancel_safety.py (commit on
clean exit, rollback on regular Exception, rollback on CancelledError,
close runs even if rollback raises, close failure on clean exit
doesn't propagate, rollback + close both go through asyncio.shield)
and test_cancelled_pool_filter.py (drops cancellation-driven
terminate, drops GC-cleanup, keeps real OSError terminate, keeps
terminate without exc_info, keeps unrelated pool messages, drops
chained-cause CancelledError, defensive guard against self-referential
cause chains).
Applies to SQLite and PostgreSQL — get_db is dialect-agnostic and
the filtered messages come from base sqlalchemy.pool not from any
specific dialect.
feat(oidc): add Azure Entra ID support with configurable email claim resolution
Adds two new OIDC provider fields: email_claim and require_email_verified.
Two new optional fields on Spool: free-text `category` (max 50) and
`low_stock_threshold_pct` (1-99). Powers the "differentiate critical
spools from prototype spools and alert at different thresholds" use
case from #729 without taking on the full multi-tag taxonomy + auto-
apply rules + per-tag alert system the ticket originally proposed.
Form gains:
- Category input with datalist autocomplete sourced from categories
already in use, so casing/spelling stays consistent.
- Per-spool low-stock threshold input. Empty = global default; the
global value renders as the placeholder.
Inventory page:
- New category filter chip (hidden until at least one spool carries
a category — keeps the chip row uncluttered).
- Stat-card "Low Stock" count and the "Low Stock" filter both honour
the per-spool override.
Plus: rename "Delete Tag" button to "Clear RFID Tag" (the original
ticket reporter mistook it for a taxonomy-tag delete; the button
actually clears the RFID UID/UUID off the spool record). Toast key
renamed from `tagDeleted` to `rfidCleared`.
i18n: full translations across all 8 locales.
Tests: 9 new backend schema tests (defaults, partial-update, range
rejection, max-length); 2 new frontend tests (per-spool threshold
pulls extra spools into low-stock count, filter chip hidden when no
categories exist).
#1108 — Long-lived camera-stream tokens for HA / Frigate / kiosks. Camera-only
V1, hard 365-day cap (no infinite tokens), pbkdf2 hashed at rest, plaintext
shown to user exactly once on creation. New "Camera API Tokens" panel under
Settings → API Keys with self-service create/revoke, styled confirm modal,
admin "All users" view for leak triage. Auth path: /camera/stream tries the
existing 60-min ephemeral table first, falls through to the long-lived path.
Indexed lookup_prefix keeps verify O(1) per token.
Permission audit: gated the existing API-keys-CRUD + Webhook docs + API
Browser content behind api_keys:read so non-admins with camera:view land on
the API Keys tab and see only the Camera Tokens panel they actually have
permission to use. Grid layout collapses to single column for non-admins.
Tests: 29 new backend (15 service + 14 integration covering create/list/
revoke ownership rules, the auth fall-through, scope enforcement, prefix
collisions) + 6 new frontend tests for the section UI including the new
modal flow. All 77 backend tests + 21 frontend camera tests pass. Ruff
clean (lint + format).
Docs: README updated with fan-out + long-lived-token bullets. Wiki gets a
new "Long-Lived Camera Tokens" section under features/camera.md (HA YAML
example, security model, permission requirements, revoke flow). Website
features.html gets the bullet under Camera Streaming.
Also includes #1089 follow-up tweaks already merged in this branch:
_stream_start_times.setdefault for accurate stream_uptime, subscribe()
RuntimeError retry to close the grace-vs-subscribe race, atomic
unsubscribe count via the iter_subscriber on_unsubscribe callback.
feat(inventory): replace Spoolman iframe with internal inventory UI
When Spoolman is enabled, the Inventory page now uses the same internal
UI (spool list, create/edit modal, archive, delete, weight sync) backed
by a new proxy layer instead of opening an iframe.
Legacy SQLite installs created the `settings` table without a UNIQUE
constraint on `key`. The seed loop's `INSERT OR IGNORE` silently
degraded to a plain INSERT, so every `systemctl restart` added another
row of `advanced_auth_enabled` / `smtp_auth_enabled`. After a handful
of restarts, `scalar_one_or_none()` in is_advanced_auth_enabled() and
similar sites blew up with `MultipleResultsFound`, 500'ing the login
flow.
Run-migrations now deletes dup rows (keeping MIN(id) per key) and
creates the missing `ix_settings_key` unique index before the seed
loop. Both ops are idempotent — fresh installs and Postgres already
have the index, so they no-op.
Adds an archive counterpart to the library trash sweeper shipped in the
previous commit. Unlike the library flow, archives are hard-deleted —
print history is a decaying timeline, so there is no trash intermediate;
download or favourite anything you want to keep first.
Backend
- New ArchivePurgeService (backend/app/services/archive_purge.py) with
its own 15-minute scheduler loop and a 24h throttle on actual purge
runs. Delegates every delete to the existing safety-checked
ArchiveService.delete_archive so the 3MF, thumbnail, timelapse, source
3MF, F3D, and photo folder all get cleaned up together with the DB
row. Per-row session via async_session() avoids commit-per-row churn
on any caller-passed session.
- New /archives/purge/{preview,settings} + POST /archives/purge routes
gated on a dedicated archives:purge permission (not archives:delete_all)
so admins can delegate bulk-delete to a role without granting
per-archive delete on other users' rows.
- seed_default_groups() now backfills both library:purge and
archives:purge on the Administrators group for upgraded installs —
the original library:purge was added after Administrators was first
seeded so the "create if not exists" path skipped existing DBs and
left admins without the permission.
- 8 new integration tests (defaults, settings roundtrip, bound
validation, preview, manual purge, auto-purge enabled path, 24h
throttle, disabled skip).
Frontend
- Settings → Archives card gains an auto-purge toggle + age input (7d
floor, 10y ceiling, 365d default), with a save-toast on every change.
The bulk "Purge old" button lives on the Archives page header
(rightmost, after Upload 3MF) to match the File Manager pattern —
configuration in Settings, one-shot action on the page.
- New PurgeArchivesModal mirrors PurgeOldFilesModal: live preview (count
+ total size freed + sample filenames) debounced at 300ms, amber
"hard-delete, no undo" warning.
- Admin-only UI gates on archives:purge via the standard hasPermission
hook; Permission TS union updated.
- i18n blocks across all 8 locales (en/de full, other 6 English
fallback per project convention).
Docs
- CHANGELOG entry under 0.2.4b1 following the existing library-trash
entry.
- bambuddy-wiki archiving.md gains a new "Auto-Purge" section.
- bambuddy-website features.html gets a matching bullet.
Verification: python -m ruff check backend/app/ clean; 25 integration
tests pass (8 archive_purge + 17 library_trash regression); npm run
build clean.