github maziggy/bambuddy v0.2.5b1-daily.20260625
Daily Beta Build v0.2.5b1-daily.20260625

pre-release4 hours ago

Note

This is a daily beta build (2026-06-25). It contains the latest fixes and improvements but may have undiscovered issues.

Docker users: Update by pulling the new image:

docker pull ghcr.io/maziggy/bambuddy:daily

or

docker pull maziggy/bambuddy:daily


**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.

Added

  • Autologin via SSO + disable local login (#1589, requested by einstux) — Two related additions for operators who run their own OIDC SSO and want exactly one auth path. Global setting local_login_enabled (default True, preserves pre-#1589 behaviour) — when False, POST /api/v1/auth/login rejects username + password credentials with HTTP 401 (same wording as wrong-password to avoid leaking "local disabled" to credential-stuffing tools), POST /api/v1/auth/forgot-password rejects with HTTP 403 (the reset wouldn't grant access anyway), and the LoginPage hides the credentials form + Forgot Password link, leaving only the OIDC provider buttons. Env-var recovery path BAMBUDDY_LOCAL_LOGIN=true (also accepts 1 / yes, case-insensitive) bypasses the gate on both routes and flips the reported local_login_enabled flag on /auth/advanced-auth/status back to True so the LoginPage matches what the route actually accepts — a server admin whose SSO provider is unreachable can recover the install with one env var, no DB editing. LDAP keeps its own ldap_enabled switch and is not affected by this gate — a delegated directory has its own policy and lockouts and is closer to SSO than to local credentials. Per-OIDC-provider is_autologin flag — when set on an enabled provider, the LoginPage redirects unauthenticated visitors directly to that provider's authorize URL on mount instead of rendering the login form. At most one provider can carry the flag at a time (app-layer invariant enforced in both create and update routes: setting it on one provider clears it on every other). Two-layer fallback for autologin — the LoginPage races getOIDCAuthorizeUrl against a 5-second timeout; on success the browser navigates to the IdP, on timeout or fetch error the redirect is aborted, the page renders normally, and a sticky amber banner explains "Autologin to failed, pick a provider". A bookmarkable /login?fallback=local query param always skips the autologin redirect — paired with the BAMBUDDY_LOCAL_LOGIN=true env-var on the server, this is the documented "SSO is broken, let me back in" path. Two safety refusals on disabling local login: settings PUT returns HTTP 400 ("no OIDC provider is enabled") when no enabled OIDC provider exists, and HTTP 400 ("you would lock yourself out") when the calling admin has no UserOIDCLink row. Either failure mode would otherwise lock everyone out of the install. Backend. local_login_enabled: bool = True added to AppSettings + AppSettingsUpdate schemas and to the _BOOL_KEYS allowlist in routes/settings.py. OIDCProvider.is_autologin: bool column via _safe_execute(ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT ...) — SQLite DEFAULT 0, Postgres DEFAULT false per the project's existing boolean-migration pattern. New OIDCProviderResponse.is_autologin field threaded through from_attributes=True. _local_login_env_bypass() reads at call time (not import time) so tests can monkeypatch the env between cases. /auth/advanced-auth/status extended with local_login_enabled and autologin_provider_id so the LoginPage decides UI in one query — autologin_provider_id filters on is_enabled=True AND is_autologin=True so disabling a provider stops the autologin redirect even if the flag stays set. Frontend. LoginPage.tsx adds the autologin useEffect (skips redirect when ?fallback=local is in the URL, when an OIDC token is already in the fragment, or when an oidc_error query param is present from a previous round trip), the autologin-failed banner, and a "Local sign-in disabled" notice that replaces the form when the flag is off. SettingsPage.tsx exposes the local_login_enabled toggle in the OIDC tab card above the existing provider list; OIDCProviderSettings.tsx adds the per-provider Autologin toggle in the form's flags row. AppSettings, AdvancedAuthStatus, OIDCProvider, and OIDCProviderCreate TypeScript interfaces extended to match. i18n. 6 new keys (login.autologinFailed, login.localDisabledNotice, settings.localLogin.disable, settings.localLogin.disableHint, settings.oidc.form.autologin, settings.oidc.form.autologinDesc) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5375 leaves per locale, no English fallback. Tests. 6 new integration cases in test_local_login_gate.py: login default allows local, login rejected when flag off and no env bypass (with generic 401 wording asserted), env-var bypasses the gate, forgot-password rejected when flag off, status surfaces both new fields, env bypass flips the reported flag back to True. Full nearby suites green: test_auth_api.py 44/44, test_mfa_api.py + test_oidc_relogin.py + test_settings_ui_preferences.py 159/159. Backend ruff check clean. Frontend npm run build clean. Scope. No new permission — the existing SETTINGS_UPDATE permission gates the toggle. The migration is a single ADD COLUMN per backend; the local_login_enabled setting lives in the existing settings key-value table and needs no migration. Default behaviour is unchanged: fresh installs and upgrades see no difference until an admin explicitly enables the toggle or sets a provider as autologin.
  • Cam Wall view on the Printers page — New view toggle next to the card-size selector flips the entire printers list into a responsive grid of live camera tiles (CardsCam wall). Reuses the existing per-printer FTP / RTSPS proxy on /api/v1/printers/{id}/camera/stream, so the backend ffmpeg fan-out is the same one EmbeddedCameraViewer already drives — no new server-side state machine. Bandwidth ceiling matters on the RPi installs ([[bambuddy-install-base-2026-06-20]] documents that the median deployment is a Pi 4): each live tile is one TLS pull + one MJPEG fan-out. To stay sustainable on a Pi 4 with 8+ printers, only the tiles currently on-screen are live, and only up to Max live streams (default 4) at any moment — everything else falls back to per-tile snapshot polling against /api/v1/printers/{id}/camera/snapshot at a configurable interval (default 8 s). Tiles that scroll off-screen pause entirely. Architecture. frontend/src/components/CameraTile.tsx is the leaf — three modes (live / snapshot / paused), a single <img> element with loading="lazy", an onError no-signal fallback, and a useEffect cleanup that POSTs /camera/stop (with keepalive: true) on mode-out-of-live AND on unmount so the backend releases the transcoder slot. Same /camera/stop discipline EmbeddedCameraViewer uses, so a tile that scrolls off the wall is byte-identical to closing a floating viewer. frontend/src/components/CameraWall.tsx is the scheduler — an IntersectionObserver (threshold 0.4 to avoid flicker at scroll boundaries) tracks visibility, then a useMemo walks the printer list in sort order and assigns the first N visible tiles to live, the rest of the visible set to snapshot, and off-screen tiles to paused. The walker is stable on a given render (no LRU eviction churn) which avoids the "tile flickers between live and snapshot every frame" failure mode. Reuses the same ['printerStatus', id] React Query cache each PrinterCard already populates, so flipping between Cards and Cam Wall is instant and the wall doesn't open a second status fetch fan-out. Clicking a tile honours the existing Settings → camera_view_mode preference — opens the floating EmbeddedCameraViewer when set to embedded, otherwise pops the /camera/:id window with the saved size/position from cameraWindowState. Settings. Both knobs are per-user, persisted in localStorage (camWallMaxLive, camWallSnapshotSec) — not a global backend setting, since a Pi 4 user and a NUC user looking at the same install want different caps. Bounded [1, 16] for max live and [2, 60] seconds for snapshot interval, both rendered as an inline gear-icon popover above the grid with click-outside dismiss. The Cam Wall button is permission-gated on camera:view; viewers without the permission see it disabled. The card-size selector goes opacity-40 + pointer-events-none in cam-wall mode (tile size is governed by the responsive grid, not the cardSize knob). i18n. 13 new keys (printers.pageView.cards, printers.pageView.camWall, printers.camWall.{noPrinters,noSignal,live,snap,off,summary}, printers.camWall.settings.{title,maxLive,maxLiveHint,snapshotInterval,snapshotIntervalHint}) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) — no English fallback. Parity check 5369 leaves per locale. Tests. 5 new vitest cases in frontend/src/__tests__/components/CameraTile.test.tsx cover live URL emission with fps=8, snapshot URL emission with the cache-bust counter advancing on the interval, offline placeholder for disconnected printers, paused placeholder rendering, and the /camera/stop POST firing when the tile transitions out of live. Scope. No backend change. No DB migration. No new permission. The existing EmbeddedCameraViewer is untouched — Cam Wall is purely additive. The printerPageView toggle defaults to cards, so installs see no behaviour change until a user picks Cam Wall.
  • AMS drying badge now shows the active cycle's filament + target temperature — During an active drying cycle the AMS card on the printers page renders Drying · PETG 65°C · 11h 35m left (the loaded-filament line under the slots) instead of the bare Drying · 11h 35m left. Bambu's per-tick AMS push only carries the dry_time countdown — the chosen filament name and target temperature are never echoed on the wire, so the badge had no source of truth for them. BambuMQTTClient.send_drying_command(mode=1, ...) now caches {ams_id: {filament, temp}} on the client; the cache is cleared on mode=0 and on the per-AMS dry_time falling-edge to 0 (same detector that drives the smart-plug-after-drying callback). PrinterManager.get_drying_targets(printer_id) exposes it, printer_state_to_dict and routes/printers.py::get_printer_status thread it onto each AMS dict as dry_target_temp + dry_filament, the AMS schema gains both fields, and the AMS-HT compact badge gets the same render. Falls back to the first loaded tray's tray_type + RFID-recommended drying_temp when no cached target (drying started before backend launch, backend restarted mid-cycle, or cycle started from another source) — the same heuristic the popover already uses to seed defaults. New i18n key printers.drying.targetSummary = {{filament}} {{temp}}°C, translated in all 11 locales (parity check 5356 leaves per locale). 5 new backend tests in TestSupportsDryingCommand (cache populated on mode=1, overwrite on second start, cleared on mode=0, per-AMS isolation across stop) and 4 new tests in TestDryingTargetExposure (cached target wins over fallback, fallback derives from loaded tray, both fields None when no cache + empty trays, targets don't leak across AMS ids). Note about Bambu's printer display. A user reported that with PLA loaded in AMS-A slot 1 and a Bambuddy-initiated PETG 65°C drying cycle, the H2D's own screen showed "PLA" — Bambuddy's wire payload was confirmed correct via journalctl (filament: "PETG" sent, result: success, filament: PETG, temp: 65 ACKed back). The display behaviour is the Bambu firmware labelling the active cycle by the loaded tray's filament rather than the filament field of the command. This Bambuddy change makes our own UI reflect what we actually sent, independent of the firmware's display choice.
  • Continue auto-drying while a print is running on capable hardware — Bambu shipped "Print While Drying" firmware-side on H2D (01.03.00.00+), H2C / H2S / P2S / H2D Pro (01.02.00.00+), X2D / A2L (01.01.00.00+), and X1C (01.11.02.00+). The existing Queue Auto-Drying loop only fires on idle printers — when a print starts, drying stops or never starts, even though the spools may still be wet. New Settings → Print Queue → "Continue drying while printing" toggle (default OFF) lets the same scheduler evaluator also run on the busy printer set. Backend: supports_drying_while_printing(model, firmware) in printer_manager.py is a strict allowlist verified against Bambu's wiki release-notes phrasing ("printing while filament is drying" / "Print While Drying" — every matrix-confirmed model carries that wording verbatim; P1P / P1S / A1 / A1 Mini / X1 (non-C) / X1E are intentionally excluded because the wiki is silent for them, and on those models the firmware would reject the command anyway via dry_sf_reason=[0] (TaskOccupied)). The capability is gated on both display names ("H2D", "X1C", ...) and internal SSDP / MQTT model codes ("O1D", "O1E", "O2D", "O1C", "O1C2", "O1S", "N6", "BL-P001", "N7", "N9") — the printer's model field can carry either, the existing supports_drying precedent uses both. _check_auto_drying in print_scheduler.py now resolves model + firmware up front for every printer and computes mid_print = busy AND toggle_on AND supports_drying_while_printing; when mid_print is True the busy-skip, queue-only-skip, and idle-skip gates are bypassed and the existing humidity / dry_sf_reason / drying-presets / mode-1 send path takes over. Safety: drying temp is capped at max(40, preset_temp - 5) for mid-print drying — Bambu's own release notes for H2D and P2S spell out "Lower drying temperature during printing" / "The drying temperature must not exceed the filament's softening temperature", so a 5 degC offset from the idle preset (floor 40) protects spools inside a hot enclosure during an active print. The early-return guard that short-circuits the evaluator when "only queue mode is on AND nothing scheduled" was also extended to skip the short-circuit when print_drying_enabled is on — otherwise busy printers would never be reached. The manual drying button on the AMS card needs no UI change: routes/printers.py::start_drying has no Bambuddy-side is_idle gate; the "printer busy" rejection comes from firmware dry_sf_reason=[0], which simply won't appear on supported firmware mid-print. The new capability flag is also surfaced on PrinterStatus.supports_drying_while_printing so the frontend can light up the AMS card affordances correctly. Settings. New print_drying_enabled: bool = False in schemas/settings.py, added to the boolean allowlist in routes/settings.py (_BOOL_KEYS), and threaded through the existing dirty-detection / save call in SettingsPage.tsx. i18n. 2 new keys (settings.printDryingEnabled, settings.printDryingEnabledDescription) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5354 leaves per locale, no English fallback. Tests. 7 new cases in TestSupportsDryingWhilePrinting cover every supported display name + internal code, below-min firmware, excluded models (P1*, A1, A1 MINI, X1, X1E), missing firmware, None model, case-insensitivity, and the strict unknown-model default (False — unlike supports_drying which leniently allows unknowns). 4 new scheduler integration cases in TestMidPrintDrying cover: toggle ON + capable hardware fires drying at the 40 degC cap for PLA, PETG caps to 60, toggle OFF still skips busy printers, and toggle ON with too-old firmware / excluded model still skips. Full pytest -n 30 green (4251/4251 in 49 s). Backend ruff clean. Frontend npm run build clean. Scope. No DB migration. No new permission. The new toggle is opt-in (default OFF) — existing installs see no behaviour change until a user enables it, and the firmware is the ultimate arbiter via dry_sf_reason so being too permissive here costs nothing.
  • Batch / mass edit on the Filament tab (#1795, requested by RoBoT24-web) — Bulk operations land on the Inventory page in both built-in and Spoolman modes. Reporter wanted "ten of the same spool, set a pressure advance value, save once" — the existing flow forced ten round-trips through the per-spool editor. Frontend. A new checkbox column anchors the leftmost slot of every row in the table view (header checkbox toggles every visible row; group rows expose a single checkbox that selects every member). As soon as one row is selected, a sticky toolbar appears above the list with Edit / Print labels / Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear selection. The selection clears automatically on any filter or tab change so the toolbar count can never drift from what's on screen. A new BulkEditSpoolsModal is the entry point for the bulk-edit action: a three-state-per-field form (untouched / set-to-value) over the flat spool attributes — material, subtype, brand, color name + RGBA, storage location, slicer filament name + ID, cost / kg, note, label weight, core weight, category, low-stock threshold %. The reporter's pressure-advance use case (K-profile) stays per-spool because K-profiles are scoped per (printer, extruder, nozzle_diameter) and bulk-applying a single K-value across heterogeneous printers would create wrong calibration — they're handled in the existing per-spool K-profile editor instead. Clearing fields in bulk is intentionally NOT supported (user decision on #1795): bulk-set lets you only WRITE non-empty values; emptying ten notes by mistake is a one-click disaster the dialog doesn't expose. The per-spool editor remains the path for clearing. Same dropdown controls the per-spool editor uses. Material, sub-type, brand, category, slicer preset name, and slicer filament are all rendered through a new SearchableSelect component matching the per-spool form's pattern (text input + chevron + filtered list of buttons, click-outside + Escape close). No native <select> anywhere in the modal. Material / sub-type / brand options merge the canonical MATERIALS / KNOWN_VARIANTS / DEFAULT_BRANDS constants from spool-form/constants.ts with whatever's already in inventory. Slicer-preset dropdowns fetch the same sources as the per-spool form (Bambu Cloud presets when signed in, Orca Cloud profiles, local presets, built-in filaments) via three useQuery calls gated on isOpen so closed modal pays no fetch cost; results pipe through the shared buildFilamentOptions(...) helper so the option list is byte-identical to what the per-spool editor shows. Storage location is a searchableClosed SearchableSelect over actual api.getLocations() rows mapped to location_id (the FK), matching the per-spool form's behaviour (rather than the legacy free-text storage_location column, which would have written to a different column than the per-spool editor). Backend. Four new endpoints per inventory mode, eight total: POST /api/v1/inventory/spools/bulk-update, bulk-delete, bulk-archive, bulk-restore (built-in) and the matching /api/v1/spoolman/inventory/spools/bulk-* (Spoolman). All gated on the existing INVENTORY_UPDATE / FILAMENTS_UPDATE permissions used by the per-spool routes. The built-in update endpoint runs the same prepare_internal_spool_payload(...) path as the per-spool PATCH (location resolution, weight-lock auto-stamp on explicit weight_used — both inherited identically). The Spoolman update endpoint loops the existing per-spool update_spool route function so the complex filament re-linking / extra-dict / extra-lock / shared-filament-detection rules stay byte-identical to single-spool edits — the bulk route is just a fan-out, not a parallel reimplementation. Per-spool failures inside the loop are collected and returned as {updated, errors: [{id, status, detail}]} so one bad ID never aborts the batch. The built-in archive endpoint reports {archived, already_archived, not_found} so the UI can distinguish "no-op because already archived" from "missing row." Both modes broadcast a single inventory_changed WS event at the end of the batch instead of one per row, so the table refresh is a single re-fetch. Spoolman bulk-delete / archive / restore now also catch non-HTTPException mid-batch — earlier these three caught only HTTPException; a mid-batch httpx.ConnectError / TimeoutError / KeyError aborted the route with a 500, the loop's accumulated state was lost, and the inventory_changed broadcast was skipped so the table didn't refresh past the partial state. bulk_update_spools got this right out the gate; the audit pass added the same except Exception arm to the other three so a transient Spoolman blip surfaces in the per-row errors array instead of obliterating the whole batch. All-failed and partial-failure are surfaced to the user. The first cut of the four onSuccess mutation handlers only read the success count, so a response of {updated: 0, errors: [50 entries]} (e.g. every selected ID was deleted by another user before the click landed) showed a green "0 spools updated" toast and silently cleared the selection. The handlers now branch on three outcomes — all-succeeded (existing success toast), partial ({ok, failed} warning toast), and all-failed (red error toast + selection preserved + modal stays open so the user can retry). Same shape for delete / archive / restore. bulkResetConsumedCounterMutation.onSuccess now closes the confirm modal + clears the selection — earlier inconsistency with the other three bulk mutations left the confirm dialog open after the action. Invalid RGBA hex is now flagged inline instead of being silently dropped from the patch. Typing "RED" or "FF00" in the colour field now paints the input red with helper text and disables the Apply button via a new hasDroppedTickedField guard that detects any ticked field whose value gets normalised away — without this guard the user clicked Apply, the rgba was silently omitted, and the success toast still fired for the other fields. Backend tests. 17 new integration cases. 10 in test_inventory_bulk.py covering update applying to multiple rows, unknown IDs reported in not_found, empty update body rejected with 400, weight-lock auto-stamp parity with per-spool PATCH, empty ids rejected with 422, bulk delete with mixed valid/invalid IDs, archive setting archived_at on multiple rows + skipping already-archived, restore the symmetric inverse. 7 in test_spoolman_inventory_bulk.py covering the Spoolman update calling update_spool_full once per ID with the same payload, per-spool exception collected without aborting the batch (404 on one ID + 2 successes returns {updated: 2, errors: [{id, status: 404, ...}]}), empty update rejected, empty ids rejected, bulk delete fan-out, bulk archive calling set_spool_archived(spool_id, archived=True) for each ID, bulk restore the inverse. Full pytest -n 30 green (6384/6384 in 68 s). Frontend behaviour. Selection state is per-page-session — leaving the Inventory tab and coming back clears the set, mirroring the existing label-printer scope. The action toolbar collapses into the existing ConfirmModal for destructive operations (Delete is variant: 'danger'; Archive / Restore / Reset usage are 'warning'). Errors surface via the existing useToast. API client. Added bulkUpdateSpools / bulkDeleteSpools / bulkArchiveSpools / bulkRestoreSpools and the four bulkXSpoolmanInventorySpools equivalents — matches the per-mode pattern already used for bulkResetSpoolConsumedCounter. i18n. 42 new keys under the new inventory.bulk.* namespace (33 toolbar / modal / confirm + 4 partial-failure toasts × 4 actions + invalid-hex inline helper + 1 useCustom autocomplete affordance), translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5345 leaves per locale, no English fallback. Scope. No DB migration. No new permission. SQLite + Postgres parity verified — the bulk endpoints use the same model + ORM paths as the per-spool routes. Grid (card) view does NOT get checkboxes in this drop — the reporter explicitly requested the Filament-tab list (table view); adding card checkboxes can ship as a follow-up if asked.
  • "Auto-add unknown RFID spools" toggle + global confirmation modal for unknown spools (requested by maziggy after a wave of duplicate-inventory reports) — New setting under Settings → Filament → Filament Tracking. Default is ON (current behaviour preserved); turning it OFF stops Bambuddy from auto-creating an inventory entry when an unknown RFID tag is read by the AMS. Use case: users who manually pre-register new spools on delivery (so the inventory record carries their notes / weight / cost) were getting silent duplicates the first time they loaded each spool — the auto-matcher requires exact material+colour+subtype+brand, and pre-created records rarely match strictly enough. Backend gates the auto-create in BOTH inventory modes (backend/app/main.py for the built-in inventory loop and backend/app/services/spoolman.py::sync_ams_tray for the Spoolman path; auto-sync + both manual sync routes in backend/app/api/routes/spoolman.py thread the flag). When suppressed, the existing unknown_tag WS event fires so the frontend can surface the slot. Confirmation modal. A new global modal pops up on the next page render whenever an unknown RFID is detected — shows the printer / AMS-X label / slot number, the spool's material + colour swatch, and asks the user whether to add it now ("Add to Inventory" / "Cancel"). Mounted in Layout.tsx (inside <ProtectedRoute>) so the prompt appears regardless of which page the user is on, but never on SpoolBuddy kiosk / login / setup routes. Multiple concurrent unknown spools queue and present one-at-a-time; the frontend won't double-queue the same slot. Backed by two new explicit endpoints: POST /api/v1/inventory/spools/from-slot (built-in inventory) and POST /api/v1/spoolman/spools/from-slot (Spoolman), gated on INVENTORY_UPDATE / FILAMENTS_UPDATE respectively. Both look up the slot's current tray data server-side and create + auto-assign the spool atomically. SpoolBuddy frontend is unchanged — its existing handleQuickAddToInventory flow already covers the kiosk's separate path. Backend dedup that prevents nag and survives a failed broadcast. _unknown_tag_last_broadcast: dict[printer_id, dict[(ams_id, tray_id), (tag_uid, tray_uuid)]] in main.py ensures the same (slot, tag) pair only broadcasts ONCE per MQTT-push cycle, no matter how often the firmware re-asserts the slot state. The slot's empty-tray-data MQTT push clears that slot's entry, so remove-then-reinsert reliably re-prompts. Successful matches (get_spool_by_tag, find_matching_untagged_spool, auto-create) also clear the entry so a future tag swap on the same slot re-prompts. The dedup-set runs AFTER await ws_manager.broadcast(...) completes, so a crash mid-await doesn't poison the dict and permanently silence the slot (an earlier draft set the dedup before the await — bit on a NameError regression during development). Tray data shipped with the event, not looked up. The WS payload now carries tray_type, tray_color, tray_sub_brands, and tray_count straight from the live MQTT message, so the modal renders the correct material / colour without depending on the React Query printerStatus cache (which lagged the WS event by several seconds during the first end-to-end test and showed PLA / #FF0000 instead of the actual filament). Frontend hook useUnknownTagPrompt reads them out of the event detail directly. Shared getAmsLabel. Moved from PrintersPage.tsx (and a near-duplicate in ConfigureAmsSlotModal.tsx) to frontend/src/utils/amsHelpers.ts. Both consumers now import the shared version; the canonical implementation produces AMS-A / AMS-B / HT-A / External rather than the bare AMS 3 my first draft of the modal emitted. Spoolman from-slot no longer reports success when the slot binding fails. Earlier in this audit pass a swallowed try/except Exception: rollback + log left the route returning {"success": True} even when the slot-assignment INSERT was rolled back; the user saw the "Spool added" toast while the modal re-fired on the next MQTT push. Now raises HTTP 500 with the underlying error so the frontend surfaces it. AppSettings TypeScript interface. Added spoolman_enabled: boolean, auto_add_unknown_rfid: boolean, and spoolman_url: string — the backend has always returned these on /api/v1/settings/ (verified by test_settings_api.py:144 which asserts result["spoolman_enabled"] is True), but the TS type omitted them and required a runtime cast; now strictly typed. i18n. 9 new keys (settings.autoAddUnknownRfid, settings.autoAddUnknownRfidDesc, inventory.addToInventory, inventory.addToInventoryPending, inventory.addToInventorySuccess, inventory.addToInventoryFailed, inventory.unknownSpoolTitle, inventory.unknownSpoolMessage, inventory.unknownSpoolSlot) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5301 leaves per locale. Scope. No DB migration (setting lives in the existing settings key-value table; same shape on SQLite and Postgres). No new permission. Manual Sync AMS actions also honour the setting — the skipped-spool result list reports Auto-add disabled; add to inventory manually so the user knows the slot was intentionally skipped, not failed. Verification. Full backend pytest -n 30: 6367/6367 in 79 s. Focused integration suite (settings / Spoolman / slot-assignments / slot-concurrency): 122/122. Frontend npm run build clean. Backend ruff clean. End-to-end smoke-tested live on an H2D + dual AMS setup: insert → modal pops with the correct AMS-B Slot 4 + real material / colour; Cancel → no re-prompt; remove + reinsert → modal returns; Add → spool lands in inventory + slot tile shows it.
  • Spoolman weight tracking for no-3MF "Untitled" prints (#1820, requested by ojimpo) — Closes a long-standing parity gap between Bambuddy's two inventory modes. When a Bambu print starts that Bambuddy can't fetch a .gcode.3mf for — typically an unsaved BambuStudio project, where the printer reports subtask_name: 名称未設定 ("Untitled") and FTP returns 550 for every candidate path — the existing flow created a fallback archive but Spoolman saw no weight change for that print. The internal-inventory side already handles this via the Path 2 AMS remain%-delta fallback in usage_tracker.on_print_complete (line 517). Spoolman now mirrors the same shape. store_print_data now captures tray_remain_start (per-slot remain% + tray_uuid at print start) on every print — keyed "<ams_id>-<tray_id>", slots with invalid remain (e.g. -1, AMS hasn't read the spool yet) silently dropped, VT external trays encoded as ams_id=255 to match internal — and no longer early-returns when the 3MF is missing: it creates an ActivePrintSpoolman row with filament_usage=None carrying only the snapshot, so the completion path has something to work with. report_usage keeps its 3MF path as the primary writer and adds _report_remain_delta_for_slots for any slot the 3MF path didn't cover (no-3MF entirely OR partial coverage where slice_info omitted a slot). The fallback resolves each slot to its Spoolman spool via the existing spoolman_slot_assignments table, looks up the curated Filament.weight from the spool's filament record, and writes (start_remain - current_remain) × weight / 100 grams via client.use_spool(...). No tray_weight from MQTT — the failure mode #1119 documented (non-RFID spools have no MQTT tray_weight, so remain% × tray_weight gave garbage and silently mis-tracked) is dodged the same way internal inventory dodges it: by reading the user-curated reference weight from the inventory store rather than trusting MQTT's raw field. RFID gate not needed — Spoolman's curated Filament.weight is present for RFID and non-RFID spools alike. Mid-print spool swap detection — when tray_uuid differs between start snapshot and completion read, the slot is skipped rather than mis-attributed. We don't know how much of the print went to which spool; preserving correctness is better than guessing. Double-charge guard — slots already written by the 3MF path land in a handled_global_tray_ids set that the fallback consults before charging, so a 3MF-covered slot can't also pick up a remain delta. #1119 invariant preserved — the deprecated AMS-remain%-based GLOBAL writer is still gone. This is per-slot, per-print, gated on a valid start/current remain AND a resolvable Spoolman spool. No new setting, no toggle: the parity rule [[feedback_inventory_modes_parity]] applies — same shape as internal inventory, which is unconditional. No-op default — installs with no Spoolman slot assignments, no RFID-readable AMS, or no print-time remain% (printer offline at start, AMS still loading) see no behaviour change. DB. active_print_spoolman gets a new nullable tray_remain_start TEXT column via _safe_execute(ALTER TABLE … ADD COLUMN), and the existing filament_usage TEXT NOT NULL is relaxed to nullable — for SQLite via writable_schema = ON + sqlite_master patch + schema_version bump (same surgical pattern used for users.password_hash NULL relaxation a few hundred lines below), for Postgres via ALTER COLUMN … DROP NOT NULL. SQLite + Postgres parity verified. CREATE TABLE updated to emit the new shape on fresh installs. Tests. 11 new unit cases in test_spoolman_no3mf_remain_fallback.py: 5 for _snapshot_tray_remain (valid remain captured, invalid remain skipped, VT tray encoding, empty raw_data, missing uuid defaulted to ""), 3 for store_print_data no-3MF behaviour (row created with snapshot when no 3MF + valid remain; no row when neither 3MF nor remain; 3MF path also captures snapshot for partial-coverage fallback), 3 for report_usage remain-delta (writes (start-end) × Filament.weight / 100 to resolved spool; skips swapped spool when tray_uuid changed; skips slots already handled by 3MF). Full pytest -n 30 green on the Spoolman + tracking + archive + on-print suites (1205/1205). Backend ruff clean.
  • NTP-gate state exposed on the appliance endpointGET /api/v1/system/appliance gains a time_synced field returning "ok", "warning", or null. Source: /run/bambuddy/time-synced, written by the appliance's ntp-gate.sh once chronyd reports sync (or after a 3-minute timeout with a "warning" marker). The RPi 5 has no battery-backed RTC, so on a fresh boot the system clock is wrong until NTP catches up — JWT expiries and TLS certificate validity windows depend on this being right. New backend/app/core/local_config.py::read_ntp_gate is defensive on every failure mode (file absent → None, OSError → None + warning log, empty / unknown content → None, binary garbage survives via errors="replace"). The endpoint stays no-auth; the SPA can use the field to render a "time not synced" badge on a fresh appliance before swapping to normal status once "ok" comes through. 8 new unit cases for read_ntp_gate (absent / ok / warning-suffixed / warning-only / empty / unknown-marker / leading-whitespace / binary-garbage) and 3 new integration cases for the endpoint field (ok / warning / absent). On Docker / manual installs the gate file doesn't exist so this is a no-op (time_synced is null) — the appliance is the only consumer for now.
  • Appliance locale defaults endpointGET /api/v1/system/appliance returns the hostname/timezone/locale the Bambuddy Appliance setup wizard collects into /etc/bambuddy/local.toml during firstboot. New backend/app/core/local_config.py::read_local_toml parses the file defensively (missing file → empty dict, invalid TOML → empty dict + warning, non-string values dropped with a warning), so a malformed file never blocks startup. Endpoint returns {hostname, timezone, locale} with null for any field not present, requires no auth (the frontend i18n bootstrap fetches it before auth might be set up, and the contents are user-set defaults, not secrets). On the frontend, i18n/index.ts runs a one-shot applyApplianceLocale() hook after init: gated by a bambuddy_appliance_locale_consumed localStorage flag so it runs exactly once per appliance, fetches the endpoint, and i18n.changeLanguage(...)s if the returned locale is in the supported set. Non-appliance installs (Docker, manual) silently no-op when the file or endpoint is absent. The appliance writes the file via its setup wizard (separate repo: bambuddy-appliance); this PR closes the loop for the locale field — hostname and timezone are still applied by the appliance's firstboot.sh via hostnamectl/timedatectl and don't need a main-app reader. Backend test coverage: 9 unit cases for the reader (missing/empty/comment-only/full/partial/invalid/non-string/unknown-keys/escaped-quotes), 4 integration cases for the endpoint (nulls when no file, full values, partial values, no-auth-required).

Security

  • Vite 7 → 8 major bump — Bambuddy's frontend now builds with Vite 8 (^7.3.2^8.0.16) and the matching plugin-react release (vitejs/plugin-react ^5.1.1^5.2.0). Headline architectural change: Vite 8 swaps Rollup for Rolldown as the default bundler — same plugin contract, Rust-backed core, slightly different chunk layout / output bytes (no functional regression). The bump also lifts the transitive esbuild floor to 0.28.1, which closes the last open advisory in the audit chain. Bambuddy-side surface audited: vite.config.ts uses only stable contracts that survived the v8 cut — defineConfig, the Connect type, the custom serveGcodeViewer configureServer middleware plugin (proxies /gcode-viewer/* to the repo's sibling gcode_viewer/ directory in dev), the server.proxy with WebSocket upgrade for /api/v1/ws, build.outDir/emptyOutDir/chunkSizeWarningLimit, and resolve.alias for ``. base: '/' regression guard from #1221 is unaffected. No SSR, no library mode, no CSS preprocessors, no exotic plugins. `vitest4.1.8` already accepts vite 8 in its peer range (`^6 || ^7 || ^8`); no test-runner bump required. Node: vite 8 requires `^20.19.0 || >=22.12.0`; CI Node 20.x line satisfies this. What this is NOT: plugin-react v6 — that line requires `babel-plugin-react-compiler` + `rolldown/plugin-babel` as peers and is a separate scope. `npm run build`, `npm run lint`, `npx vitest run` all clean; `npm audit` clean.
  • Frontend dependency bumps — Routine version updates across the runtime, build, and test dependency surface. Runtime: dompurify 3.4.0 → 3.4.10. package.json floor raised from ^3.4.0 to ^3.4.10 so fresh installs cannot land on the deprecated 3.4.4 release. Three call sites use string-output sanitisation (frontend/src/pages/MakerworldPage.tsx, frontend/src/pages/ProjectDetailPage.tsx, frontend/src/components/ProjectPageModal.tsx); release notes 3.4.1 → 3.4.10 reviewed for behavioural changes — 3.4.4 widened the default allow-list with selectedcontent + command + commandfor (all valid modern HTML, harmless for our two default-allow-list call sites), and ProjectPageModal is unaffected anyway because it sets an explicit ALLOWED_TAGS / ALLOWED_ATTR whitelist. Build / lint / test tooling (transitive, dev-only): babel/core 7.29.0 → 7.29.7 (pulled by vitejs/plugin-react and eslint-plugin-react-hooks), vite 7.3.2 → 7.3.5, markdown-it 14.1.1 → 14.2.0 (pulled by tiptap/extension-linktiptap/pmprosemirror-markdown; Bambuddy never calls markdown-it.render directly so the change is transparent), js-yaml 4.1.1 → 4.2.0 (pulled by eslint), form-data 4.0.5 → 4.0.6 + ws 8.20.1 → 8.21.0 (both pulled by jsdom in the test runtime). All bumps inside existing semver ranges except dompurify. No source changes required.
  • dompurify 3.4.10 → 3.4.11 — Follow-up patch closes a moderate-severity advisory affecting setConfig() callers: the previous hook clone-guard added in 3.4.7 could be bypassed via setConfig(), leaving a permanent ALLOWED_ATTR pollution that the next sanitize() call inherited. Bambuddy's exposure is nilgit grep DOMPurify.setConfig returns zero hits across the entire codebase; all three sanitisation sites (frontend/src/pages/MakerworldPage.tsx, frontend/src/pages/ProjectDetailPage.tsx, frontend/src/components/ProjectPageModal.tsx) call DOMPurify.sanitize(html) or DOMPurify.sanitize(html, {ALLOWED_TAGS, ALLOWED_ATTR}) directly, never through setConfig(). The bump is taken as defence-in-depth to keep XSS-sensitive surface area current and to silence npm audit so future audit-fix runs don't auto-bundle unintended changes. Mechanical lockfile bump only: the existing ^3.4.10 range already permitted 3.4.11, so package.json is unchanged; package-lock.json updates the resolved URL + integrity hash for the one entry. Verification: npm audit reports 0 vulnerabilities, MakerworldPage.test.tsx's 12 DOMPurify sanitisation cases pass, npm run build clean.
  • Precautionary floor pins for pydantic-settings 2.14.2 + msgpack 1.2.1 — pip-audit surfaced two advisories that are not reachable in shipped Bambuddy but were flooring at vulnerable versions. pydantic-settings 2.0.0 → 2.14.2 in requirements.txt clears GHSA-4xgf-cpjx-pc3j (NestedSecretsSettingsSource with secrets_nested_subdir=True follows symbolic links pointing outside the configured secrets_dir, reading out-of-tree files into settings values and bypassing the documented secrets_dir_max_size cap; affected >=2.12.0,<2.14.2). Exposure: nil. grep -rn "NestedSecretsSettingsSource\|secrets_nested_subdir\|secrets_dir" backend/ returns zero hits — Bambuddy uses pydantic-settings only for env-var-backed config, never for the secrets-dir loader. msgpack 1.2.1 floor-pinned in requirements-dev.txt next to pip-audit>=2.7.0 to clear GHSA-6v7p-g79w-8964 (an Unpacker instance reused after catching an error can crash with SEGV; under repeated unpacking of untrusted input from an external source, this is a DoS vector). Exposure: nil. grep -rn "import msgpack\|from msgpack" backend/ returns zero hits — msgpack enters Bambuddy's tree only as a transitive of CacheControl, which is itself pulled by pip-audit (the very tool that produced the report). Not a runtime dep of the shipped app. Both pins are taken as defence-in-depth / audit hygiene so the next pip-audit run is clean and a future reachable advisory in either package isn't masked by the existing noise. No code change, no behavioural change, no test change.
  • Backend dependency security floor raises (cryptography / python-multipart / starlette) — pip-audit December 2026 cycle surfaced six advisories across three direct deps; floors in requirements.txt lifted to the documented fix releases, plus one transitive co-bump for resolver compatibility. cryptography 46.0.7 → 48.0.1 floor (resolver picks 49.0.0 within the new floor) — clears GHSA-537c-gmf6-5ccf (non-contiguous Python buffer handling that could overflow on APIs accepting buffer protocol input). Release-notes audit (done before bump): v47.0.0 dropped Python 3.8 + OpenSSL 1.1.x + binary elliptic curves (SECT*) + Camellia + CFB/OFB/CFB8 modes (moved to cryptography_decrepit); v48.0.0 dropped PUBLIC_KEY_TYPES / PRIVATE_KEY_TYPES type aliases. Bambuddy's grep is clean across every one of those: core/encryption.py uses Fernet (AES-128-CBC + HMAC), services/spoolbuddy_ssh.py uses ed25519, services/virtual_printer/certificate.py uses RSA + x509 + ExtendedKeyUsageOID. Python 3.13 + OpenSSL 3.x on container, so the version-floor bumps are no-ops for us. python-multipart 0.0.27 → 0.0.31 floor (resolver picks 0.0.32) — clears CVE-2026-53538/53539/53540 in the multipart parser surface (boundary length capped at 256 bytes, RFC 2231 continuation handling, Content-Length non-negative validation, bounded header field name size before validation). Behavioural changes audited: 0.0.30 stopped recognising RFC 2231/5987 extended filename* / name* parameters in incoming bodies — Bambuddy emits these on outgoing Content-Disposition response headers (utils/http.py:17) but doesn't parse them on the request side, and clients that include both filename= and filename*= keep working via the plain filename= fallback (slight cosmetic difference for non-ASCII filenames in uploads). 0.0.30 also tightened form-urlencoded parsing to treat only & as field separator — every Bambuddy client (browser, BambuStudio, OrcaSlicer) already uses &. starlette 1.1.0 → 1.3.1 floor — clears CVE-2026-54282/54283 (FormParser max_part_size / max_fields limits now actually enforced after being declared-but-ignored in earlier releases; StaticFiles.lookup_path rejects absolute paths; FileResponse clamps oversized suffix range requests; URL.replace() IndexError fix). Critical pre-bump check: the newly-enforced max_part_size=1MB default would have broken every file upload (UploadFile = File(...) in inventory.py:1127, projects.py:886/1053/1780, library.py:1787, local_presets.py:82, external_links.py:166, local_backup.py) if it applied to file streams. Inspected the MultiPartParser.on_part_data source: the size check at if self._current_part.file is None: only fires for text form fields, not file streams — so file uploads of arbitrary size still pass through unaffected. Text form bodies in Bambuddy are login credentials and similar small values, well under the 1MB ceiling. Side rename: backend/app/api/routes/mfa.py:470/1364/1428 replaces 3 references of status.HTTP_422_UNPROCESSABLE_ENTITY (deprecated in starlette 1.3.x) with HTTP_422_UNPROCESSABLE_CONTENT. Same 422 wire status; silences the 3 deprecation warnings under our own ownership (the two remaining warnings come from FastAPI internals — upstream's to fix). pyopenssl 26.0.0 → 26.3.0 floorNOT a security fix; required because pyOpenSSL <26.3.0 caps cryptography<47 in its install_requires, so without an explicit floor the resolver either downgrades cryptography below the GHSA-537c-gmf6-5ccf fix line or installs an inconsistent pair (pip's resolver warns but proceeds). Bambuddy has no direct from OpenSSL ... imports — pyOpenSSL is pulled transitively by asyncssh + pywebpush. Verification: pip-audit clean, pip check clean, ruff check backend/ clean, backend pytest -n 30 6167/6167 in 86.55s. No DB migration, no API surface change, no permission change, no frontend change.

Added

  • AMS Filament Backup is now first-class across the deficit check, the printer card, and a new BambuStudio-style backup modal (#1762, reported by jpcast2001 + Arn0uDz) — Four tightly coupled changes that close the gap reporter jpcast2001 hit on the dual-AMS X1C farm. Reporter scenario: PLA Basic in AMS-1 slot 1 with low remaining grams, the same PLA Basic in AMS-2 slot 1 with plenty — Bambuddy still blocked the print with an "insufficient filament" warning because per-slot accounting never noticed the backup peer. Reporter disabled disable_filament_warnings as a workaround; Arn0uDz hit the same shape on a different printer and noted "Print Anyway" didn't unstick them either. The single global AMS Filament Backup toggle that shipped in 0.2.5b1 (#1766) was the prerequisite for the firmware-level switch, but every Bambuddy surface still treated each slot as isolated. What's new on each surface:
    Backup-aware deficit aggregation (the load-bearing fix). backend/app/services/filament_deficit.py::compute_deficit_for_queue_item now reads PrinterState.ams_filament_backup via printer_manager.get_status and, when backup is ON, pools remaining_grams across every same-material assigned spool on the printer before deciding whether to block. Material identity uses the firmware's actual rule: same Bambu filament preset ID (Spool.slicer_filament, e.g. GFA00) AND same colour (with 1A1A1AFF normalised to match 1A1A1A — alpha stripped, hex uppercased). The preset identifies the filament profile (PETG HF, PLA Basic, etc.); the colour pins the variant. Three PETG HF spools in different colours all share the same preset but absolutely don't back each other up — the firmware would correctly swap PETG HF but the print would change colour mid-run. User-tagged spools without a preset get a unique-per-spool key so they never pair with anything else, matching the firmware: Bambu's backup logic relies on the preset, and grouping on cosmetic material+colour match alone would let two visually-identical but materially-different spools be treated as backups. Same (catalog-id, colour) rule on the Spoolman side via filament.id + color_hex; spools linked to different filament catalog entries never pool even if their material+colour strings match. Dual-extruder scoping is load-bearing here: H2D / H2C / X2D firmware cannot cross extruders even when bit 18 of print.cfg is set, so the pool is per-extruder-side via PrinterStatus.ams_extruder_map plus is_dual_nozzle_model(). Single-extruder printers collapse everything to one pool and ignore the map. The check still emits per-slot FilamentDeficit rows when the total required of a material on an extruder side exceeds the total available of that material, so the UI's "slot X is short" message still resolves to a specific slot the user can act on — it just doesn't fire spuriously when the firmware will actually save them.
    BambuStudio-style backup modal opens from the badge. The existing AMS Backup badge on the Filaments section header (#1766) now opens a dedicated modal instead of toggling state on click. The modal renders one SVG ring graphic per backup pair — each ring filled with the filament colour, the material name + rotation count (N× ↻) in the centre, and member slot labels distributed around the colour band on rounded contrast-aware pills (semi-opaque black on bright fills, semi-opaque white on dark) so the labels stay legible regardless of the spool colour. Closely modelled on Bambu Studio's "Auto Refill" widget. Lone slots are intentionally suppressed — the ring graphic is the answer to "which slots will save me when this one runs out"; everything else is visual noise. On dual-extruder printers (H2D / H2C / X2D), each ring carries a compact R / L badge in the top-left corner instead of section headers — and the badge ONLY appears when the extruder map carries TWO distinct values across the AMS units, so single-nozzle printers misflagged as dual or printers with routing data not yet reported collapse cleanly to no-badge rendering. Modal closes on Esc keypress (window-level listener registered while open, cleaned up on close), click-outside, or the close button. Theme-aware via CSS variables (var(--bg-secondary) / var(--text-primary) / etc.) matching AMSHistoryModal, so the modal follows whichever background variant the user has picked (neutral / warm / cool / oled / slate / forest). The badge itself stays in the Filaments section header where #1766 put it — its onClick was rewired from "directly toggle the backup state" to "open the modal", with the same setAmsFilamentBackup mutation hooked to the toggle inside the modal. The new computeBackupGroups(amsUnits, amsExtruderMap, isDualNozzle): BackupGroup[] helper in utils/amsHelpers.ts is the modal's data source — it returns one entry per non-empty slot, sorted with pairs first, then by material name, then by global tray id for deterministic rendering. Identity uses the same strict (preset, colour) rule as the backend. HT AMS (single-tray modules with ams_id ≥ 128) participate in groupings via getGlobalTrayId, so an HT slot can pair with a regular AMS slot when both hold the same preset and colour. Defensive dedup by ams.id (first occurrence wins) hardens the helper against duplicate entries that have been observed in the wild on VP-aggregated switch printers and MQTT partial-update edge cases — without the dedup, a single physical slot could render in two different rings.
    Active-print per-slot mapping pill while RUNNING / PAUSE. While the printer is mid-print, each slot tile referenced by PrinterStatus.ams_mapping (already on the wire, from the slicer's filament-map captured during dispatch) gets a small "P1 / P2 / P3 …" pill in the top-right corner — opposite the backup-group dot — naming which print-slot is mapped to this AMS slot. Catches the secondary report from jpcast2001's comment 2 verbatim: queue job set for "any X1C", scheduler bumped it to a printer with mismatched filament, no way to verify mid-print whether the right slots are loaded. With the pill the mismatch is visible the instant the print starts. The existing single-slot ring-2 ring-bambu-green highlight for effectiveTrayNow keeps its meaning ("the currently-active extrusion source RIGHT NOW") — the pill is per-slot static "this slot is filament N in the active print," not per-tick dynamic.
    Print Anyway diagnostic log (Arn0uDz follow-up). _block_on_filament_deficit in print_scheduler.py:1983 now logs at INFO when it honours item.skip_filament_check, so a future "Print Anyway didn't work" report (the third commenter on #1762 hit this shape) has an actionable line in the standard support bundle without needing debug logging enabled. The route-side log at print_queue.py:1278 is unchanged. Without logs from the original report we can't isolate the user's failure mode (the wire path on both ends still looks correct on inspection), so this is the minimal trace required to investigate the next occurrence — bundled in the same drop because Block 1 makes the original symptom disappear for users who had backup ON anyway. Tests. 8 new backend cases in test_filament_deficit.py::TestFilamentDeficitBackupAware pin every dimension: pool covers the assigned-slot shortfall → no deficit (the reporter scenario, with matching slicer_filament preset + matching colour); pool insufficient → deficit emitted with the correct slot id; peer slot holds a DIFFERENT preset → no pool, deficit fires; backup OFF → strict regression with the pre-#1762 per-slot accounting (using identical inputs to the "pool covers" case but flipping the toggle); dual-extruder printer with a peer on the OPPOSITE side → deficit fires because the firmware can't cross; STRICT-rule — two spools with material+colour match but NO preset must NEVER pair; COLOUR-strict — same preset + DIFFERENT colours must NOT pool (the reporter screenshot scenario, three PETG HF in different colours); COLOUR normalisation — 6-char and 8-char hex of the same RGB pool correctly (000000 matches 000000FF). 13 frontend cases in PrintersPageBackupGroups.test.ts pin computeBackupGroups: empty for missing input; ignores empty slots; pairs via preset; no-preset spools NEVER pair even on attribute-tuple match; different presets never cross; SAME preset + DIFFERENT colours don't pair; colour-hex 6-char and 8-char normalisation; lone slots returned alongside pairs in the same list; dual-extruder scopes per-side both ways; HT AMS pairs with regular AMS via getGlobalTrayId; preserves display name + tray colour for the modal swatch; DEFENSIVE dedup of duplicate ams.id entries (first wins). 10 modal render cases in AmsBackupModal.test.tsx: closed → null; ring renders for pairs and OMITS lone slots; Esc keypress closes the modal; Esc is a no-op after the modal closes (listener actually unmounts); toggle reflects ON state + fires onToggle(false) on click; toggle disabled when state unknown (A1 family); toggle disabled when permission missing; no-pairs empty state when no pair can form; R/L badges render when extruder map carries two distinct values; R/L badges absent when the map collapses to one extruder. Existing 8 test_filament_deficit.py + 60 PrintersPage.test.tsx cases stay green — the no-backup path is a strict no-op vs the pre-#1762 logic. i18n. 12 new keys × 11 locales for the modal + the active-print pill (printers.amsBackup.modalTitle / modalHelp / modalNoSlots / modalNoPairs / stateOn / stateOff / stateUnknown / extruderRightShort / extruderLeftShort, plus printers.activeJobSlot.title / ariaLabel) translated in de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW. Parity check 5253 leaves per locale, no English fallback; "AMS Filament Backup" is a Bambu product/firmware name and is allowlisted as a cognate where the European locales keep it verbatim. Scope. No DB migration, no new permission. The global Filament Backup badge stays where #1766 put it (Filaments section header on the printer card) — firmware reality is one bit on print.cfg, and moving the toggle per-AMS would misrepresent that. The badge click no longer toggles directly; it opens the modal, where the same setAmsFilamentBackup mutation is wired to the toggle. No schema change to FilamentDeficit — same shape, same wire payload, same 409 response under code: insufficient_filament. No change to the disable_filament_warnings setting (#720) — when on, the deficit check is still a no-op regardless of backup state. Behaviour shift worth flagging. Pre-PR, prints could be blocked with "insufficient filament" even when the firmware would actually have switched to a same-(preset, colour) peer mid-print. Post-PR, those prints dispatch. Users with backup misconfigured at the firmware level (e.g. FTS routing wrong) may see prints dispatch that previously got blocked at the deficit check; the printer would then fail mid-run rather than at queue-start time. The trade-off is correct — the warning shouldn't fire when backup will save you — but worth surfacing for anyone debugging post-upgrade.
  • Inline finish-photo embed in failure-event emails + user_print_* template disambiguation (#1792, reported by elit3ge) — Two related changes to the notification stack. (1) Template-driven inline finish-photo in email. Pushover / Telegram / Discord / ntfy users already get the finish-photo JPEG attached to terminal-print notifications (print_complete / print_failed / print_stopped event types), thanks to the capture path shipped in 0.2.5b1 (#1397) that extracts the last timelapse frame at print end and loads up to 2.5 MB into archive_data["image_data"]. Email was the one provider that dropped those bytes on the floor — text-only body, no visual context for the reporter's "Reason: unknown" failure mails. notification_service._send_email (backend/app/services/notification_service.py:413) now accepts finish_photo_url alongside image_data and the dispatcher (_send_to_provider at :745) threads the URL from the rendered template variables dict. Inline embed is opt-in via the existing {finish_photo_url} template variable — first draft of this fix unconditionally inlined the photo whenever bytes were present, which maziggy correctly flagged as bypassing the template system ("standard is to have variables for all available items in a template"). The contract now: if the user puts {finish_photo_url} in their email template body, the URL substring in the rendered body triggers the multipart/related shape — HTML part replaces the escaped URL in-place with <img src="cid:bambuddy-finish-photo"> (so the image appears WHERE the variable was, not stapled to the bottom), plain-text part keeps the URL as a clickable link, MIMEImage attached inline with Content-ID: <bambuddy-finish-photo> per RFC 2392. If the template doesn't reference the variable, single-part text-only — no surprise image. Default templates are unchanged; reporter (and any user who wants this) edits their print_complete / print_failed / print_stopped body once to add the variable. XSS hygiene: rendered body is html.escaped before the URL→<img> swap, newlines become <br>. Pushover/Telegram/Discord/ntfy senders untouched — their pre-existing "auto-attach whenever image_data is set" behaviour stays because their bodies aren't HTML-templatable for inline images anyway. (2) user_print_* template names get an " Email" suffix. Same reporter surfaced a separate confusion: the Message Templates list showed "Print Completed" and "User Print Completed" side-by-side with no cue they're different dispatch paths — the first is a provider-level broadcast to whatever notification channels the admin configured (ntfy/pushover/telegram/discord/email/webhook/homeassistant), the second is a per-user SMTP-only email to the user who submitted the job (requires advanced auth + user_notifications_enabled toggle + user has email + per-user pref opt-in). The EVENT_NAMES display map in backend/app/api/routes/notification_templates.py:51 already used the disambiguated "User Print Completed Email" label, but the seed wrote the short name to the DB, so the UI rendered the ambiguous one. Fresh installs now get the suffixed name straight from DEFAULT_TEMPLATES (backend/app/models/notification_template.py:198+). Existing installs get the rename via a new _migrate_rename_user_print_template_names (backend/app/core/database.py:3081+) that runs on startup and updates rows for the four user_print_* event types WHERE the name still matches the old default — admin-edited names are preserved. Standard SQL UPDATE works on both SQLite and Postgres without dialect branching. Tests: 6 new TestEmailProvider cases in backend/tests/unit/services/test_notification_service.py pinning the template-driven contract (no-image-no-URL → text-only, image-without-template-reference → STILL text-only, URL-in-body + bytes → multipart/related with cid, URL-arg-missing → text-only defence-in-depth, body-escape hygiene, URL→<img> in-place swap). 5 new migration cases in backend/tests/unit/test_user_print_template_rename_migration.py covering default-rename, user-edited preservation, provider-template don't-touch, second-run idempotency, empty-table fresh-install no-op. 11/11 + 140/140 adjacent notification tests green. Ruff clean. Verified end-to-end against a real SMTP provider with a real 48 KB finish-photo JPEG — Gmail rendered the inline image where the URL marker was in the body.
  • Dedicated "AI Failure Detection" notification event (#1794, reported by maziggy from a user report) — Obico failure detection now fires its own notification event (on_ai_failure_detection) instead of riding the multiplexed on_printer_error toggle. Reporter (P1S, Discord provider) had Obico enabled with obico_action=notify, detection was firing correctly per the logs, every other Discord notification was working — but spaghetti detections never reached Discord. Root cause. obico_actions._notify at obico_actions.py:75 was calling notification_service.on_printer_error(..., error_type="ai_failure_detection"). The notification service's provider filter at notification_service.py:722-725 requires the SUBSCRIBED-event boolean column to be True; the on_printer_error column defaults to False; the reporter's Discord provider was created without explicitly enabling Printer Error. The user couldn't have found the right toggle even if they'd known to look — the UI labels it "Printer Error" with no hint that flipping it also subscribes to AI detection. The same toggle multiplexed three distinct events (HMS hardware errors at main.py:1248 + Obico spaghetti + a error_type="ai_failure_detection" discriminator passed in the variables payload), so a user who wanted spaghetti alerts but not chamber-fan-stalled HMS pages had no way to express that. Fix. New on_ai_failure_detection Boolean column on notification_providers (defaults False — matches the conservative default of every other opt-in event); new notification_service.on_ai_failure_detection(printer_id, printer_name, task_name, confidence, action, db, image_data) method following the exact shape of on_printer_error (mirrors variable handling, template fan-out, provider filter, fail-open under quiet-hours / digest); new ai_failure_detection template entry seeded by seed_notification_templates with variables {printer}, {task_name}, {confidence}, {action}. The seeder only adds templates whose event_type is missing, so existing installations get the new template on next start without clobbering customised ones. obico_actions._notify swapped to the new method. Migration. Branched SQLite (DEFAULT 0) vs Postgres (DEFAULT false) per the existing stock-alert migration shape at database.py:2750 — Postgres rejects DEFAULT 0 for BOOLEAN columns. Existing providers receive the column with the conservative False default; they continue NOT receiving Obico notifications UNTIL they explicitly toggle the new "AI Failure Detection" event ON. This is the intended UX: previously the toggle was on Printer Error, which the reporter had OFF, so today they get nothing; after this change they still get nothing until they opt in via the dedicated toggle, but now they can find the toggle without trial-and-error. Frontend. New toggle row in NotificationProviderCard.tsx (between Printer Error and Low Filament) with a description line "Notify when Obico AI detects a possible print failure" so users discover the link to Obico without having to read source. New summary badge ("AI Failure Detection" in fuchsia) in the collapsed card view so admins can see at a glance which providers route AI alerts. New toggle in AddNotificationModal.tsx Printer Status section with matching state hook (onAiFailureDetection) wired through the create + update payload. ntfy per-event priority block also picks up the new event when enabled, matching how Printer Error and the stock-alert events behave there. Schema. NotificationProvider model + NotificationProviderBase/NotificationProviderUpdate schemas + _provider_to_dict route serialiser + create route + PATCH route (the latter uses model_dump(exclude_unset=True) so it picks up the new field automatically). Frontend NotificationProvider type + the update-payload variant. i18n. Two new keys — notifications.aiFailureDetection (label) and notifications.aiFailureDetectionDescription (help text) — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5242 leaves per locale, no English fallback. Tests. 4 new backend cases in test_notification_service.py::TestAIFailureDetectionNotifications (dispatch uses the new event field — NOT the legacy multiplexed one; provider with only on_printer_error=True is NOT notified — the regression guard for the reporter's symptom; variables include task_name + 2-decimal-formatted confidence + action; empty task_name falls back to "current job"). 3 new backend cases in test_obico_actions.py (execute_action(action='notify') calls on_ai_failure_detection and explicitly does NOT call on_printer_error; the pause action still pauses + notifies; notification-service exceptions are swallowed so a transient Discord blip can't kill the Obico detection loop). 5 new frontend cases — 4 in NotificationProviderCardAiFailureDetection.test.tsx (badge renders when ON; absent when OFF; toggle appears in expanded settings; toggling PATCHes the correct field and explicitly NOT on_printer_error) and 3 in AddNotificationModal.test.tsx (toggle renders in Printer Status section; save persists the new field without touching on_printer_error; ntfy priority block includes the event when enabled). Existing 87 test_notification_service.py + 52 Obico tests + 65 NotificationProviderCard* / AddNotificationModal* tests still green. Backend pytest -n 30 clean; ruff clean; npm run build clean; ESLint clean. Scope. No change to HMS hardware-error notifications — main.py::on_printer_error callers still fire the on_printer_error event with error_type shapes like "AMS Error" / "Heating Error", unchanged. The on_printer_error column stays on the table (default False, used for HMS only). Users who had it ON for HMS errors keep getting HMS notifications; what they LOSE is silent AI-failure dispatch on the same toggle, which most users with HMS-on never received anyway because error_type="ai_failure_detection" was the same value obico_actions._notify hardcoded. The full Obico action surface (notify / pause / pause_and_off) is unchanged on the dispatch side — execute_action still pauses + cuts plug power for pause_and_off; the only thing that moved is which notification-service method runs the fan-out.
  • Page-wide drag-and-drop upload on the File Manager (#1510, requested by maikolscripts) — File Manager gains the same drag-and-drop upload surface that the Archives page has had: drop any file anywhere on the page and the upload modal opens pre-populated with the dropped files, no need to click the Upload Files button first. The hardcoded "Upload 3MF" flow was the only path before this change. Unlike the Archives variant — which filters dropped files to .3mf only — the File Manager drop zone accepts whatever the upload modal itself accepts (3MF, STL, ZIP, images), so the page-wide surface is never more restrictive than the button it shortcuts. Permission-gated on library:upload so a viewer-tier user can't accidentally trigger the overlay. Shared hook. frontend/src/hooks/usePageFileDrop.ts is the new home for the drag-handler set — isDraggingOver state, dragHandlers to spread on the wrapper, optional extensions filter, optional onRejected callback for "you dropped something we won't accept" toasts, disabled flag for permission gating. Archives and File Manager both consume it; future drop-zones can opt in without re-implementing the cancel-safe logic. FileUploadModal.initialFiles prop. Modal accepts a File[] to pre-seed itself on first mount via a seededInitialRef guard so the same files don't re-add on subsequent renders. Existing manual-open paths (Upload Files button) pass nothing and behave unchanged. i18n. New key fileManager.releaseToUpload translated in all 11 locales (en: Release to upload, de: Loslassen zum Hochladen, es: Suelte para subir, fr: Relâcher pour téléverser, it: Rilascia per caricare, ja: 離してアップロード, ko: 놓아서 업로드, pt-BR: Solte para enviar, tr: Yüklemek için bırakın, zh-CN: 释放以上传, zh-TW: 釋放以上傳); existing fileManager.dropFilesHere reused. Parity 5240 leaves × 11 green, no English fallback. Tests. 13 new cases in src/__tests__/hooks/usePageFileDrop.test.tsx covering: overlay on dragenter, non-file payload ignored, child-element dragLeave keeps overlay (relatedTarget inside wrapper), outside-element dragLeave hides it, null relatedTarget hides it (cursor left window), document drop / dragend / Escape all reset (the three cancel paths the prior inline implementation missed — see the Fixed entry), drop with mixed file types filters by extension, onRejected fires when extension filter drops everything, disabled is a no-op, overlay clears on successful drop. Existing 85 cases across ArchivesPage / FileManagerPage / FileManagerExternalFolder vitest still green. ESLint clean; npm run build clean.
  • Sort Printers page by ETA (#1609, requested by forgecrafttechnologies-source) — The Printers page sort dropdown gains a fifth option, ETA, beside the existing Name / Status / Model / Location. Sorts the fleet by remaining print time so the printer that's finishing next sits at the top — the reporter's use case is staging the next job's filament ahead of time without scanning every card. Tier ordering. Tier 0 = currently printing with a known remaining_time > 0, sorted ascending by remaining minutes (soonest first); Tier 1 = currently printing without an ETA yet (post-start_print window before the slicer reports total time); Tier 2 = idle / finished; Tier 3 = offline. Tiebreaker within every tier is printer name, so two printers with the same ETA — or two idle printers — stay in a stable alphabetic order. The ascending / descending direction button still applies after tiers resolve, so descending puts offline printers at the top for operators triaging the fleet for connectivity issues. Data source. The cached remaining_time (minutes) on the per-printer status query (['printerStatus', id]) — the same field the per-card "ETA … min" label already reads from on PrintersPage.tsx:3633 and the fleet-wide "next finish" badge already aggregates on PrintersPage.tsx:996. No new backend query, no new round-trip; the sort consumes data that's already in the React Query cache and updated on every WebSocket push. No grouping. Unlike status / model / location sorts (which group rows under section headers), the ETA sort renders a flat list — each printer's ETA is unique so grouping would just produce a header per row. i18n. New key printers.sort.eta translated in all 11 locales (en: ETA, de: Restzeit, es: Tiempo restante, fr: Temps restant, it: Tempo rimanente, ja: 残り時間, ko: 남은 시간, pt-BR: Tempo restante, tr: Kalan süre, zh-CN: 剩余时间, zh-TW: 剩餘時間), no English fallback. Parity check 5239 leaves per locale, green. ESLint clean; npm run build clean.
  • Prominent sponsor banner at the top of Settings → General (the default landing tab) — Full-width gradient panel with a heart icon, a one-line independence framing, and a "View supporters" CTA linking to bambuddy.cool/sponsors.html?from=app-settings so Matomo can split-track this surface against the website's own positions. Motivation lives in bambuddy-install-base-2026-06-20.md: re-baselining install count via the ghcr.io pull counter (~10k pulls/day rising) puts active deployments around 8-12k, and at 8 sponsors (per [[sponsor-portal]]) that's 0.08% conversion — roughly an order of magnitude under industry-benchmark for OSS with visible CTA. Matomo data confirms it's a discovery gap rather than a value-prop gap: /sponsors.html reaches only 1.18% of website visitors over the May 21 - Jun 19 window even though /installation.html reaches 29%, and the sponsors page itself converts fine when reached (70 s dwell, 53% bounce). The banner targets the in-app surface where the existing 9,140 monthly installation-page visitors actually live after they finish installing. Three new sponsors.* i18n keys (sectionTitle, tagline, viewSupporters) translated into all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Same release also ships a post-install ribbon between Quick Install and System Requirements on the bambuddy-website repo's installation.html, plus a ?from=install-bottom tracking param on the existing bottom CTA so the two website positions are A/B-comparable in Matomo from day one.
  • In-app sponsor-toast triggered at earned milestones (Prints / Cost / Archives / Anniversary / Version-update) — Companion piece to the prominent sponsor banner that shipped earlier in this release (Settings → General full-width gradient panel). Banner gives passive every-visit visibility on a single page; the toast adds opt-out-able active visibility at moments where the user has just earned something with Bambuddy. Motivation: 0.08% conversion gap. Re-baselining the install base from the ghcr.io pull counter (~10,000 pulls/day rising, captured in bambuddy-install-base-2026-06-20.md) places active installs around 8,000-12,000 — at 8 current sponsors (per [[sponsor-portal]]) that's a 0.08% conversion rate, 6-25× under industry-benchmark for OSS with visible CTA. Matomo data over the May 21 - Jun 19 window shows only 1.18% of website visitors reach /sponsors.html despite 29% hitting /installation.html — the ask was discoverable on the marketing site but invisible inside the running app where users actually live. Trigger families (5). Prints: completed prints reach 100 / 500 / 1000 / 2500 / 5000. Cost: cumulative tracked filament-plus-energy cost crosses €100 / €500 / €1000 (currency-agnostic threshold — the frontend renders with the user's configured currency symbol). Archives: 50 / 250 / 1000 print archives saved. Anniversary: 1 year from the user's created_at (auth-enabled), or MIN(users.created_at) as the install-anchor (auth-disabled, see below). Version-update: soft fallback that fires once after a major-version bump, re-armable on each subsequent bump. Priority order. When multiple families are eligible at once, the service picks in this order: anniversary → prints → archives → cost → version-update — most emotional / earned first; version-update is the unobtrusive fallback. 14-day cooldown across all families so an active power-week with stacked milestones never triggers more than once. Backed by a single last_shown_at column on the per-user state row. Auth-disabled mode is first-class, not an afterthought. Roughly 60-70% of installs run with auth disabled (single-user home setups — exactly the local-first cohort that "Bambuddy stays free because people support it" lands hardest with). Rather than ship a half-feature for them, the state schema uses a user_id NULLABLE column: in auth-enabled mode there's one row per real user; in auth-disabled mode there's a single NULL-keyed install-default row. The service evaluates exactly one code path that branches at the SQL WHERE level (column IS NULL vs column = X), no doubled storage logic, no duplicated trigger code. Counter queries for prints / cost / archives use print_log.created_by_id IS NULL for the install-default count. Backend. New SponsorToastState model (backend/app/models/sponsor_toast_state.py) with columns user_id (nullable FK with ON DELETE CASCADE so a deleted user takes their toast state with them), last_shown_at, milestones_seen (Text storing a JSON-serialised list[str] of fired milestone keys for SQLite/Postgres uniformity), last_seen_version, plus standard created_at/updated_at timestamps. UNIQUE constraint on user_id so there can be at most one row per user (or exactly one NULL-keyed row). The table is created via Base.metadata.create_all() at init — no explicit migration in run_migrations() needed since this is a brand-new table, not an ALTER on an existing one. Service. backend/app/services/sponsor_prompt.py with two public entry points: evaluate(db, user_id_or_None) -> Trigger | None walks the five checks in priority order, returns the first eligible one or None; dismiss(db, user_id_or_None, milestone) anchors the 14-day cooldown and either appends the milestone to milestones_seen (one-shot families) or just bumps last_seen_version (version-update is re-armable). State row is created lazily on first access so no migration seed is required. Print-milestone selection picks the LARGEST unseen threshold the user has crossed — a user who reaches 600 prints with no prior toasts gets prints-500 (not prints-100), so the relevant milestone fires; if they've already seen prints-500, they'd fall through to prints-100 next time the cooldown lifts. Cost path sums print_log.cost + print_log.energy_cost so the threshold reflects total spend Bambuddy has tracked, not just material. Routes. GET /api/v1/sponsor-prompt/check returns {show: false} or {show: true, milestone, family, threshold, payload}; POST /api/v1/sponsor-prompt/dismiss takes {milestone: string} and returns 204. Both gated with Permission.SETTINGS_READ via RequirePermissionIfAuthEnabled — every authenticated user has this, and auth-disabled installs hit them with current_user = None and the service handles that as the install-default row. Frontend hook. New useSponsorPrompt(currencyCode) hook (frontend/src/hooks/useSponsorPrompt.ts) fires once per browser session after auth resolves: checks sessionStorage['sponsorPromptShown'] to avoid double-firing on a single session's mount/unmount cycles (Layout re-renders, navigation, etc.), then calls sponsorPromptApi.check(). If a trigger comes back, builds the localised message via the new sponsors.toast* keys and displays a persistent toast with a "View supporters" CTA linking to https://bambuddy.cool/sponsors.html?from=app-toast-{milestone} — every milestone gets its own tracking parameter so Matomo can split-test which trigger families drive the most conversion. Click on the CTA fires sponsorPromptApi.dismiss(milestone) to anchor the cooldown server-side and closes the toast. The hook is wired into Layout.tsx (which sits inside <ProtectedRoute> so auth has already resolved) and pulls settings.currency from the existing settings useQuery — no duplicate fetch. Toast extension. Existing ToastContext extended with optional action: { label, href, onClick } on showPersistentToast. The non-dispatch toast renderer gets a new branch: if action is present, render an inline <a> styled as a small bambu-green pill before the dismiss-X. Click on the action fires its onClick (used by the sponsor hook to call dismiss) and closes the toast. Existing showToast / showPersistentToast call sites are unaffected — action is optional, omitting it gives the previous icon + message + X behaviour exactly. i18n. 5 templated keys in the existing sponsors.* namespace (toastPrints {{count}} / toastCost {{total}} / toastArchives {{count}} / toastAnniversary / toastVersionUpdate {{version}}) — fewer raw strings than naive per-milestone (5 × 5 + 3 + 3 + 1 + 1 = 25) but emotionally equivalent because i18next interpolates the count at render time. Real translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); no English fallback. Parity check 5214 leaves per locale. Cost messages are written so the currency symbol can be prepended client-side ({{total}} already includes the symbol) — works for USD, EUR, GBP, JPY, etc., the existing getCurrencySymbol util returns the right glyph from settings.currency. What this does NOT do. Provide an in-app opt-out toggle — the 14-day cooldown plus the "earned milestone" requirement means a typical user sees the toast 5-15 times per year, which we picked deliberately as the line between visible and naggy. If user feedback after the 2026-06-27 Matomo conversion check (see the install-base memory) shows the cadence is too aggressive we'll add a Settings → Notifications toggle then; shipping it now would dilute the "is this actually a problem worth fixing?" signal. Use plural-form i18n suffixes (_one, _other) on the count keys — the message templates are written so they read naturally at every count value (100 / 500 / 1000 are all plural in every locale, anniversary is hardcoded to "one year"), but languages with three+ plural forms (Russian, Polish, Arabic) would need this later if we ship those locales. Affect the existing Settings → General sponsor banner shipped earlier in this release — that's a passive every-visit surface and stays exactly as-is; the toast is the active milestone-based companion. Affect un-authenticated routes (login page, setup page, spoolbuddy kiosk, camera embeds) — the hook lives inside Layout which only renders inside <ProtectedRoute>. Tests. 24 new cases. 20 in backend/tests/unit/test_sponsor_prompt_service.py covering: empty-state no-fire (× 2), state row lazy creation, 14-day cooldown (within / past × 2), prints fires at 100 + picks-highest-unseen + skips-already-seen + failed-prints-don't-count (× 4), archives at 50, cost crosses 100 (counting only completed cost-bearing prints, not raw print count), anniversary at 370d vs 300d (× 2), version-update first-read silently anchors vs subsequent fires on bump (× 2), priority anniversary-beats-prints + prints-beats-archives (× 2), dismiss-adds-to-seen-and-anchors-cooldown + re-evaluation-returns-None, version-update-dismiss-updates-version-not-seen-list (× 2), auth-disabled uses install-anchor + null-keyed-counters-isolated-from-per-user (× 2). 4 in backend/tests/integration/test_sponsor_prompt_api.py covering: /check returns {show: false} on empty install, /dismiss 422 on missing milestone, /dismiss 204 on success, check-then-dismiss-then-recheck-is-silent (cooldown anchors even when the original check returned show: false). Frontend: existing ToastContext.test.tsx, Layout.test.tsx, SettingsPage.test.tsx all green (74/74 — the action-prop extension is additive on an optional field, so existing toast tests with no action keep their previous expectations). Full backend pytest -n 30 6250/6250 in 64 s; ruff clean (4 import-order auto-fixes applied); ESLint clean; npm run build clean (1.74 s); i18n parity 5214 × 11 green.
  • File Manager: user-authored tags for cross-cutting file filtering (#1268, requested by zumik3-del, seconded by unLieb) — Third and final piece of #1268, shipped alongside the recursive-search + markdown-description-panel changes below. Folders are the hierarchy (every file lives in exactly one); tags are the orthogonal labels ("toy", "kid-safe", "petg-only", "failed twice", "gift") and a single file can carry as many as the user wants. Reporter wanted to find "every toy regardless of which folder it lives in" — folders alone can't do that without forcing files into one bucket. Catalog model. New library_tags table (id, name, name_key UNIQUE = LOWER(TRIM(name)), timestamps) holds the global tag catalog — one set per install, not per-user (matches the Locations PR #1505 from earlier in 0.2.5b1). name_key UNIQUE on a normalised key collapses "Toys" / "toys" / " TOYS " into a single row so users can't accidentally fragment the tag space by typing variations. New library_file_tags(file_id, tag_id) composite-PK association table with ON DELETE CASCADE on both sides — deleting a tag drops every chip from every file (files survive); deleting a file drops its tag links (catalog rows survive). Both tables auto-create via Base.metadata.create_all() at init — no explicit run_migrations() step needed since they're greenfield. API. New /library/tags router (backend/app/api/routes/library_tags.py) with GET (list + per-tag file_count projected via subquery; filtered by ownership for LIBRARY_READ_OWN users so chip counts match what they'd actually see), POST (create — strips whitespace, 409 on case-insensitive dup with both pre-check AND post-commit IntegrityError catch for race safety), PATCH /{id} (rename, same 409 rules, self-rename allowed via id-exclusion in the pre-check), DELETE /{id} (cascade), and POST /library/tags/bulk-assign for multi-file ops. Bulk-assign supports three actions: add (idempotent — re-applying doesn't 409, just no-ops for pre-existing pairs, count reports what actually changed), remove, and replace (strip everything currently on the listed files, then INSERT the new set — passing empty tag_ids with replace clears the file's tag set entirely). Per-file ownership enforced for LIBRARY_UPDATE_OWN users via a pre-filter on file_ids (silently drops files the caller can't update — same posture as library_trash bulk routes; the response counts reflect what actually happened so the UI can detect partial application). Unknown file_ids (race with a deleter, stale FE selection) are silently dropped instead of 404'ing the whole call. list_files extension. New tag_ids: list[int] query param on the existing /library/files route — repeated ?tag_ids=N&tag_ids=M style. AND semantics: JOIN the association, GROUP BY file.id HAVING COUNT(DISTINCT tag_id) = len(tag_ids), portable across SQLite and Postgres. Per the design discussion, the tag filter intentionally bypasses folder scoping (folder_id / project_id / include_root / recursive are all skipped while tag_ids is non-empty) — the whole point of tags is cross-cutting "every file matching these labels regardless of where it lives". Every file in every listing response now carries tags: list[{id, name}] via selectinload(LibraryFile.tags) so chip rendering on the FE is N+1-free. Frontend — LibraryTagsModal. Catalog CRUD modal opened from the File Manager toolbar's new Tags button, max-w-4xl wide so multi-language subtitles don't wrap. Table with name + file count + rename/delete actions; row-click pushes the tag into the active filter and closes the modal. Delete confirm-dialog warns specifically when file_count > 0 ("This tag is on N file(s). Deleting removes the chip from all of them; files themselves are untouched."). Same Esc / backdrop / mid-mutation guard shape as LocationsModal. Frontend — BulkTagsPickerModal. Opens from the File Manager's multi-select toolbar (new Tag button between Move and Delete). Add/Remove radio at the top, scrollable checkbox list of catalog tags, inline "create new tag" affordance disabled on case-insensitive dup against the existing list, Apply button disabled until ≥1 tag is selected. The replace action is exposed in the API but deliberately NOT in the UI — arbitrary multi-file replace is destructive and confusing; future bulk-edit screen can opt in later. Frontend — FileManagerPage integration. New selectedTagIds: number[] state, sorted into the useQuery key so the cache hits are stable regardless of toggle order. Tag catalog shared with the modals via ['library-tags'] query key (extracted to frontend/src/utils/libraryTagsQuery.ts to satisfy Vite's react-refresh rule that component files export only components). useEffect prunes selectedTagIds when a tag is deleted from the catalog so the filter never strands on a phantom id. Filter rail above the file list lists EVERY catalog tag as a togglable chip — inactive chips are outlined and muted, active chips are filled bambu-green with an X, click toggles. "Clear all" appears only when ≥1 tag is active. Hidden entirely when the catalog is empty so fresh installs don't see a stray bar. List view gets a dedicated Tags column at minmax(0, 200px) between Prints and Actions — placed after the existing data attributes since tags are a "file attribute". Empty state shows a - to keep the column shape consistent. Grid view chips render below the metadata block in each FileCard. Chip clicks in both views push to selectedTagIds; click propagation is stopped so a chip click doesn't toggle the file's selection state. Type safety. LibraryFileListItem.tags?: LibraryTagSummary[] is OPTIONAL even though the backend always emits an empty array, because legacy msw mocks in pre-existing tests (FileManagerPage / FileManagerExternalFolder) construct partial file shapes without the field — without the ? the renderer crashed on .length. Read sites use file.tags ?? [] and the !. non-null assertion only inside the inner && guard. Dependencies. Zero new deps. The whole tag UI reuses lucide-react's Tag icon, existing button/modal primitives, and tanstack/react-query already in the bundle. Bundle size unchanged from the previous 0.2.5b1 baseline (7,876 KB raw / 2,122 KB gzip). Tests. 15 backend integration cases in backend/tests/integration/test_library_tags_api.py — CRUD: create + list, strip-whitespace, case-insensitive dup 409 across "Toys"/"toys"/"TOYS"/" ToYs ", rename, rename-collision 409, self-rename allowed, delete cascades associations but keeps files, delete-unknown 404. Bulk: add idempotency (second call adds 0, file_count stays 1), remove drops only listed tags (peer tag stays), replace-with-empty clears, unknown file ids silently skipped, invalid action 422. Filter: AND across two tags returns only the intersection file, tag filter overrides folder_id (file from another folder still appears when the tag matches), file listing includes the tags array. 15/15 green plus 102/102 across test_library_api.py + test_library_trash_api.py (no regression). 8 frontend cases — 4 in LibraryTagsModal.test.tsx (renders + count, create flow PATCHes correctly, row click → onPickTag + close, in-use delete warning), 4 in BulkTagsPickerModal.test.tsx (lists tags, check + Add calls bulkAssign with action='add' and the right file/tag arrays, Remove radio + Apply uses action='remove', Apply disabled when no tag selected). Full vitest run: 2249/2249 across 170 test files. Full backend pytest -n 30: 6341/6341. i18n. 37 new keys under fileManager.tags.* namespace (modal title/subtitle, manage/manageTitle, add/edit, name/fileCount, empty/noMatches, createPlaceholder/createButton, nameRequired, searchPlaceholder, CRUD success/failure toasts, applyAdd/applyRemove + their success messages, actionAdd/actionRemove radio labels, tagAction button, bulkTitle, bulkTooltip, noPermission, filterLabel, clearAll, confirmDelete + the in-use variant, editAria/deleteAria). Translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5292 leaves per locale, no English fallback (the defaultValue: "..." shortcut from a first-draft modal was removed precisely so the parity check would fail loudly if any locale missed a key). Permissions. Catalog mutations require LIBRARY_UPDATE_ALL (the catalog is global — ownership-aware update isn't meaningful for a row no user owns). Bulk-assign uses the existing LIBRARY_UPDATE_ALL/LIBRARY_UPDATE_OWN ownership pair. GET uses LIBRARY_READ_* with the file-count projection narrowed for *_OWN callers. No new permission constants, no new RBAC migration. Out of scope (deferred to v2 if asked). Tag colors / icons (label-only chips per design decision #1), tags on print_archives rows (different mental model — archives are completed prints), auto-tags derived from 3MF metadata categories (kept user-authored per design decision #4), import/export of the tag set, tag-filter intersected with folder scoping (the design call was that cross-cutting filter overrides folder selection — adding an "AND folder" toggle would need separate UX work). Closes #1268 alongside the recursive-search and markdown-description-panel pieces below — all three deliverables in this issue ship in the same minor.
  • File Manager: recursive search and per-folder markdown description panel (#1268, requested by zumik3-del, second by unLieb) — Two of the three asks bundled in #1268; the third (tags) is gated on the community-interest check Martin posted there. (1) Recursive search inside the selected folder. Until now, picking "Toys" in the sidebar and typing robot only found files in Toys/ itself — anything under Toys/Cars/ or Toys/Cars/Race/ was invisible until the user manually drilled in. The page's client-side filter was running over a server-narrowed list (/library/files?folder_id=X is strict equality on folder_id), so search couldn't see what the listing didn't load. New recursive=true query param on /library/files walks the library_folders.parent_id tree via a recursive CTE rooted at the requested folder_id and returns every descendant folder's files in one round-trip. Recursive CTEs work on both SQLite (≥3.8.3, shipped 2014 — Bambuddy's floor is well above that) and Postgres without dialect branching. Default off so the existing folder-browsing call sites (Project / Archive detail pages, the FE's no-search case) keep their narrow single-folder semantics — only the FE's search bar opts in, and only when both a folder is selected AND searchQuery.trim() is non-empty. A small "Including subfolders" hint renders under the search input when the recursive request is active so the user understands why a file from two folders away showed up. (2) Per-folder markdown description panel. New endpoint GET /library/folders/{folder_id}/readme reads the first .md file in the folder and returns {filename, content, truncated}. Selection prefers README.md / readme.md / description.md (case-insensitive — picked via func.lower(filename) LIKE '%.md' filter + an in-Python stem-preference sort), falls back to the alphabetically-first *.md otherwise. 404 when no markdown file is present so the FE can hide the side panel — non-users pay no UI cost. Bytes are clipped at 512 KiB (_README_BYTES_CAP) with a truncated flag so the panel can warn the reader; UTF-8 decode uses errors="replace" so one bad byte never blanks the panel. New FolderReadmePanel.tsx component fetches the README on folder-select, renders it via react-markdown9 + remark-gfm4 (tables / strikethrough / task lists), collapsible (default expanded), max-height 24rem with internal scroll. react-markdown 9 doesn't render raw HTML by default — XSS safe without dompurify. Links open in a new tab with rel="noopener noreferrer". Tailwind has no typography plugin in this project so per-element components map h1/h2/h3/p/ul/ol/code/blockquote/table/etc. to explicit utility classes that match the rest of the app's look. Both ask 1 and 2 ship as one PR because they share scope (file-manager UX), the same reporter, and the same review surface; ask 3 (tags) is held back as gated on the public interest signal Martin requested in his comment ("If you'd find this feature useful, please give this issue a thumbs up"). Backend. list_files route at backend/app/api/routes/library.py:1729+ gains the recursive: bool = False param + the recursive-CTE branch. New get_folder_readme route at :1042+ with _README_BYTES_CAP constant + _README_PREFERRED_STEMS selection tuple. New FolderReadmeResponse schema in backend/app/schemas/library.py:66+. Frontend. api.getLibraryFiles at frontend/src/api/client.ts:5785+ gains the recursive = false parameter; api.getLibraryFolderReadme is the matching helper for the new endpoint. FileManagerPage.tsx derives searchExpandsSubfolders from selectedFolderId !== null && searchQuery.trim().length > 0 and threads it into both the useQuery key (so toggling search refetches with the new scope) and the API call. The new FolderReadmePanel mounts above the file list when selectedFolderId !== null. Dependencies. react-markdown ^9 + remark-gfm ^4 added to frontend/package.json (~30 KB gzipped — single use-site for now, but reusable for any future markdown surface — print-archive notes, custom-field docs, etc.). No new backend dependency. Tests. 6 backend integration cases in backend/tests/integration/test_library_api.py pin the contract: recursive=true walks a three-level tree and returns files from all levels but NOT a sibling unrelated branch; recursive=true without folder_id is a no-op (the existing include_root branch still handles scoping); README endpoint returns the first .md with the correct on-disk content; README endpoint prefers README.md over notes.md even when notes.md is inserted FIRST and readme.md is lowercase; 404 when the folder has no .md; 404 when the folder doesn't exist. 3 frontend cases in src/__tests__/components/FolderReadmePanel.test.tsx cover: 404 hides the panel (no leaked chrome), markdown content renders via findByRole('heading'), truncated flag surfaces a chip. Full backend pytest -n 30 6326/6326 green; frontend vitest 1094/1094 component cases green; ruff clean; npm run build clean. i18n. 2 new keys — fileManager.searchSubfoldersHint (the small under-search caption) + fileManager.readme.truncated (the chip label when the markdown was clipped). Translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity check 5255 leaves per locale, no English fallback. Scope. No new permission — both endpoints reuse the existing LIBRARY_READ_ALL / LIBRARY_READ_OWN ownership-aware permission pair (so a viewer-tier user with read_own only sees their own files in recursive listings + can only request the README of folders containing their own files). No DB migration. The recursive CTE is a single SQL query — no N+1, no per-folder round-trip, scales to deeply-nested model libraries.
  • By-tag spool lookup, readable with a Manage-Inventory API key (#1700 closing #1663, reported + contributed by bambuman) — Companion to the QR-code-API-key flow below: gives bambuman's BambuMan NFC inventory app — and any future scanner-driven Bambuddy integration — a way to dedupe a spool scan with a single, narrowly-scoped API key. New endpoint: GET /inventory/spools/by-tag?tray_uuid=…&tag_uid=…&include_archived=false. tray_uuid is the primary identifier (it's the same 32-char hex the AMS reports over MQTT, so the scan can match a spool that's already linked to the printer), tag_uid is the fallback. At least one must be supplied (400 otherwise); 404 when nothing matches. Both values are passed through normalize_tray_uuid / normalize_tag_uid from backend/app/utils/tag_normalization.py — lowercase / colon / dash separators all match the stored uppercase hex, mirroring the existing link_tag route's func.upper(column) == value comparison so SQLite and Postgres behave identically. Archived spools are excluded by default, opt in via include_archived=true. Why this isn't on the existing /inventory/spools list endpoint: that one is purely advisory — it returns every spool the caller is allowed to see, no auth narrowing possible. The contributor's NFC app would have had to pull the whole inventory to check whether a freshly-scanned tag already existed, which both required the broader Read Status scope (an API key with Manage Inventory alone — the documented kiosk/inventory-write scope — couldn't list spools) and grew O(n) with the user's spool count. By-tag lookup is O(1) and the narrower scope rule below means the Manage-Inventory key the app already needs to create a spool is also enough to check whether one exists before creating. Scope shape (per-endpoint, NOT a global mapping change): RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_UPDATE) — INVENTORY_READ is satisfied by can_read_status (read-status keys), INVENTORY_UPDATE by can_manage_inventory (manage-inventory keys), and _check_apikey_permissions(..., require_any=True) enforces that at least one mapped flag is set (the GHSA-r2qv-8222-hqg3 fail-closed rule). Listing all spools (/inventory/spools) and fetching by id (/inventory/spools/{id}) still require Read Status unchanged — only this one endpoint accepts either scope. The first iteration of the PR widened the global _APIKEY_SCOPE_BY_PERMISSION to a tuple, which would have promoted ~21 inventory-read endpoints to also accept manage-inventory keys; review caught that the global shape was wider than the ask and the contributor revised to the per-endpoint dependency. The drift-detection RBAC scope-introspection tests stay untouched because the global table didn't change. Route ordering: the new /spools/by-tag registers at inventory.py:1184 before the existing /spools/{spool_id} at :1227, so FastAPI's first-match wins and the literal by-tag path never collides with the int spool_id route (pinned by test_does_not_collide_with_spool_id_route). Tests: 13 integration cases in backend/tests/integration/test_spool_by_tag_lookup.py — match by tray_uuid, match by tag_uid, normalisation of messy input, tray_uuid-preferred-when-both-given, tray_uuid-miss falls through to tag_uid (not 404), no-id → 400, non-hex → 400, no-match → 404, archived-excluded-by-default + include-archived opt-in, route-collision regression, plus three API-key scope cases that pin the new dependency (manage-inventory key reads, read-status key reads, key without either inventory scope gets 403). 13/13 green plus the 48 existing route-auth-coverage + RBAC tests still green (the require_ substring pattern already catches require_any_permission_if_auth_enabled.<locals>.checker — no allowlist edit needed). Ruff clean. Companion docs (maziggy/bambuddy-wiki#42): docs/reference/api.md gains a new Spool Inventory section documenting the endpoint contract; docs/features/api-keys.md adds the by-tag row to the Common Endpoints table and a "Manage Inventory keys can look up spools by tag" note. No DB migration, no schema change, no frontend change.
  • QR code on API-key creation that encodes server URL + key together (#1677, contributed by bambuman) — The "API Key Created Successfully" panel gets a new QR code button next to Dismiss. Clicking it opens a modal showing a single QR encoding the Bambuddy base URL and the freshly-created API key together, so a mobile client (e.g. the contributor's BambuMan NFC inventory app, or any future Bambuddy-aware app) can scan once to configure both — no copy-paste of the long, shown-only-once secret. Payload contract (versioned): bambuddy://config?v=1&url=<encodeURIComponent(baseUrl)>&key=<encodeURIComponent(apiKey)>. v=1 first so future bumps to v=2 have a clean deprecation path; both values URL-encoded so reserved characters in either don't corrupt the parse. The builder lives in frontend/src/utils/apiKeyQr.ts exporting buildApiKeyQrPayload() + API_KEY_QR_VERSION so any future mobile-side parser has a stable shared constant to anchor against. baseUrl source: prefers the configured External URL setting (Settings → Network), falling back to window.location.origin if not set, so the encoded address is reachable from a phone behind a reverse proxy / Docker host. The fallback's failure mode (admin on http://localhost:8000 without External URL configured → phone can't reach the encoded URL) is unavoidable without exposing a network probe; the warning text in the modal cautions the user generally. Security posture: the QR is generated client-side from the in-memory createdAPIKey React state — the key is never persisted, never re-fetched (keys are stored hashed at /api/keys POST and returned in plaintext exactly once), and never round-trips to the server. No download button (intentional contrast with the existing QRCodeModal.tsx, which encodes a public archive URL and does offer download) so the secret can't be saved to disk via the browser's download manager. The "Dismiss" handler now clears both showApiKeyQR and createdAPIKey so closing the panel scrubs the plaintext from React state. Modal closes on Escape and backdrop click; an amber warning under the QR reminds the user not to screenshot or share. Component: new frontend/src/components/ApiKeyQRCodeModal.tsx using qrcode.react's QRCodeSVG at 256 px (renders Version 5 / 6 territory for the typical ~120-character payload, comfortably below the alphanumeric capacity). Dependency: qrcode.react ^4.2.0 added to frontend/package.json (+21 KB raw / ~9 KB gzip to the bundle). Existing frontend/src/components/QRCodeModal.tsx is untouched — different purpose (server-rendered PNG for archive deeplinks), different component, no collision. Tests: frontend/src/__tests__/utils/apiKeyQr.test.ts pins the contract — scheme + v= first, exact encoding of https://printer.local + bb_abc123 byte-for-byte, special-character round-trip (+, /, =, &, spaces), explicit assertion that the raw unencoded key never leaks into the payload, and a URLSearchParams round-trip that re-parses v / url / key back out and asserts equality with the inputs. 4/4 green. i18n: 4 new keys in the settings.* namespace (apiKeyQrButton, apiKeyQrTitle, apiKeyQrCaption, apiKeyQrWarning); full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity check green. ESLint clean; npm run build clean (7,603 kB raw, +21 kB vs dev). No backend change, no permission change, no DB migration.
  • Centralised sidebar layout + per-page hide toggles (#1673, contributed by EdwardChamberlain) — Sidebar item ordering and visibility move from inline Layout.tsx state to a dedicated module so the same persistence rules apply whether the user is reordering with drag-and-drop, toggling an item off, or accepting the admin-pushed default. New frontend/src/utils/sidebarLayout.ts owns the localStorage round-trip (sidebarOrder + sidebarHiddenSystemItems keys), the SIDEBAR_LAYOUT_CHANGED_EVENT cross-tab refresh broadcast, and the isExternalSidebarItemId helper that distinguishes the new ext-* external link prefix from built-in nav. Hide / show toggle: every built-in sidebar entry (Printers / Inventory / Archives / Queue / Projects / File Manager / Makerworld / Profiles / Maintenance / Statistics — Settings is intentionally non-hideable) now carries an eye icon in the Sidebar settings card; click it to drop that entry from the rendered sidebar. Hidden IDs persist per-user via localStorage so personal taste survives reloads without leaking to other users on a shared install. Re-show by clicking the eye again. The previous drag-to-reorder UX is retired in this PR — the hide list + admin default order cover the same "I never use the Stats page" / "give me Files first" needs without the affordance ambiguity of the rearrange handle. Admin default order: new default_sidebar_order setting (validated server-side at backend/app/schemas/settings.py:533+) holds a JSON object {order: string[], hiddenSystemItemIds: string[]} that admins set once from Settings → General → Sidebar (Set Default toggle). On first login per user, Layout.tsx's useEffect reads the admin default, filters it against the current defaultNavItems + valid external IDs (so a deleted external link or a removed built-in doesn't strand in someone's stored order), applies it locally, and records a per-user sidebarDefaultApplied_<user_id> localStorage flag so the default is one-shot — later user-driven changes aren't clobbered on every login. Settings card: ExternalLinksSettings.tsx is the single source of truth for the Sidebar card (card-sidebar-links) in Settings → General. The header now carries the Set Default toggle (visible only when the caller holds settings:write), a Reset button (clears both sidebarOrder + sidebarHiddenSystemItems to defaults), and the Add Link button (opens the external-link create modal). The body lists every sidebar item — built-in or external — with the eye toggle inline on each row. The header row uses flex-wrap on the outer container and the right-side control group so the Add Link button doesn't overflow the card's right edge when Column 3 sits at its narrow lg:max-w-sm (384px) width. Settings → General reordering (post-merge polish): the Updates card moved to the top of Column 3 (above the new Sidebar card); the Data Management card moved to the bottom of Column 2 (after Library Auto-Purge) so the General tab balances better with the new Sidebar card taking column 3's vertical real estate. Anchor IDs card-updates, card-data, card-sidebar-links are preserved so deep-links + the in-app registerSettingsSearch index still resolve. Layout merge edge case: the PR's refactor of Layout.tsx::isHidden accidentally dropped the dev-side notifications gate (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || settings?.user_notifications_enabled === false) and its advancedAuthStatus useQuery. The merged shape keeps three gates in priority order — hiddenSystemItemIds.includes(id) first (cheapest, explicit user intent), then the array-aware navPermissions check from #1755 (granular *:read_own / *:read_all tiers), then the notifications-specific gate — so a user without advanced auth doesn't suddenly see the Notifications entry. Backend: default_sidebar_order settings field accepts both shapes (plain array OR {order, hiddenSystemItemIds} object) for backward compat with installs that saved an array under an earlier draft of this work. Validator rejects any hiddenSystemItemIds that isn't a list[str] with 422. Tests: 17 new backend cases in test_sidebar_settings.py pinning the validator (empty / JSON-array / JSON-object / mixed-types / hostile shapes). Frontend: 5 new Layout.test.tsx cases pinning the hide-toggle behaviour (hidden ID drops the entry, hidden ID for Settings is ignored — settings is non-hideable, eye-click round-trips through localStorage, SIDEBAR_LAYOUT_CHANGED_EVENT triggers a re-read across tabs) and 255 added/changed lines in SettingsPage.test.tsx covering the admin-default toggle and the eye-icon visibility column. i18n: new keys in the externalLinks.* namespace (sidebarLayout / sidebarLayoutDescription / visibleInSidebar / hiddenFromSidebar / requiredInSidebar / setDefault / etc.), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Vitest test timeout raised in vitest.config.ts to absorb the userEvent.setup({delay: null}) cases in the heavier SettingsPage flows. Full vitest run green; ESLint clean; npm run build clean; ruff clean.
  • Structured storage locations catalog (#1505 closing #1004, contributed by Poltavtcev) — Inventory gets a first-class catalog of physical storage spots (shelves, drawers, dryboxes) instead of free-text in the spool's storage_location field. Spools now carry a location_id FK alongside the denormalized storage_location string (kept for Spoolman wire format + label rendering). The Inventory page picks up a Locations button that opens an in-page modal — the original PR landed a standalone /inventory/locations page; merged shape is a modal opened from Inventory so the catalog read sits next to the spool list. The modal handles create / edit / delete / pick-to-filter; row-click pushes the location_id into the Inventory filter state without a navigation. Deep-link ?location_id=<n> (and ?location_id=__none__ for the unset bucket) still works for sharing or bookmarking. Backend: new Location model + locations table with case-insensitive name_key (LOWER(TRIM(name))) UNIQUE — concurrent creates on the same name resolve to a single 409 via the IntegrityError → re-fetch shape in _create_location_or_get_existing. CRUD at /api/v1/inventory/locations, all five routes gated with RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ|UPDATE). Delete is blocked while spool_count > 0 so the user can't strand spools. Single-write-path is location_service::resolve_spool_location_fields() — both the internal-mode and Spoolman-mode spool routes feed through it so location_id and storage_location can never drift. Spoolman parity: location names sync into the local catalog on GET /spoolman/inventory/spools via maybe_sync_spoolman_locations; rename cascades to every Spoolman spool via client.rename_location, with a per-spool PATCH fallback when the upstream's bulk endpoint isn't there (Spoolman <0.16 doesn't expose PATCH /location/{name} and returns 404/405). get_distinct_locations normalises both the older list[str] and the newer list[dict] Spoolman payload shapes. Migration: inline in database.py::run_migrations — creates the locations table (DATETIME for SQLite / TIMESTAMP for Postgres), adds spool.location_id FK + index, then backfills the catalog from existing free-text values (GROUP BY LOWER(TRIM(storage_location)) so case variants like Drybox 1 and DRYBOX 1 collapse into one row). The legacy name_key backfill runs BEFORE the dedup INSERT so a pre-existing locations row with NULL name_key (manually inserted before this feature shipped) gets its column populated first and the subsequent spool-link UPDATE can join on it. Post-migration warn-log flags any spools that still carry free-text storage_location with no location_id — surfaces the rare mis-link case to ops instead of silently leaving them out of catalog filters. Rename safety: Spoolman PATCH runs BEFORE db.commit(), cascade failure rolls back the local rename and raises HTTP 502 — without this ordering a partial failure left the catalog and Spoolman's per-spool location field permanently diverged (the next sync recreates the old name as a duplicate catalog row). Legacy-row UPDATE matches func.lower(func.trim(Spool.storage_location)) == old_name.strip().lower() so the SQL TRIM symmetry holds for whitespace-padded values. Cross-tab refresh: spoolman_inventory.py now emits inventory_changed on the 8 spool-mutating routes (create, bulk-create, update, delete, archive, restore, reset-bulk, weight, tag) — internal mode already broadcast in 12 places, Spoolman mode silently degraded before. The useWebSocket handler invalidates inventoryLocationsQueryKey on every such message so location counts stay in sync across tabs. Performance: the Spoolman→catalog sync used to fire on every GET /spools request, hit Spoolman, and open a write transaction; now guarded by a 60s per-URL TTL cache (_spoolman_location_sync_last_run) so a polling UI doesn't burn a Spoolman round-trip + SQLite write per refetch. The route also passes its already-resolved client through to the sync so test fixtures that patch the route module's client also catch the sync's client lookup — without this the SSRF LAN-topology parametrize tests took ~45s on real TCP timeouts to RFC-1918 IPs (now 2.79s in isolation). Frontend: SpoolFormModal location dropdown sends location_id only (same shape in both inventory modes — no spoolmanMode ? ... : ... UI gate) and the onCreateLocation flow surfaces ApiError.message instead of a generic toast so 409 / 400 / 500 stay distinguishable. LocationsModal passes isLoading to ConfirmModal during delete so a mid-mutation cancel can't strand a toast on a dismissed dialog; Pencil / Trash icon buttons carry aria-label for SR announcement. i18n: new locations.* namespace (20 keys: title, subtitle, add, edit, delete, empty, name, spools, manage, createPlaceholder, nameRequired, created, updated, deleted, saveFailed, deleteFailed, deleteBlocked, confirmDelete, confirmDeleteMessage, editAria/deleteAria), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Tests: ~26 new across backend/tests/unit/test_location_service.py (rename strip/lower symmetry, sync-from-Spoolman log-on-unavailable, list[dict] payload normalisation), backend/tests/unit/test_spoolman_inventory_methods.py (get_distinct_locations shape guard × 4, rename_location bulk-then-fallback × 4 — 200 / 404 / 405 / 5xx), backend/tests/unit/test_location_migration.py (NULL + whitespace-only storage_location skip, legacy NULL name_key ordering, case-variant dedup, idempotency), backend/tests/integration/test_locations_api.py (CRUD round-trip, rename cascade, IntegrityError → 409, PATCH/DELETE 404, auth-gate 401 on all five routes when auth_enabled=true), and frontend/src/__tests__/components/LocationsModal.test.tsx (12 cases: open=false renders nothing + no fetch, row click → onPickLocation + onClose, 2-level Escape dialog stacking, rename collision 409 toast, disabled delete on spool_count>0, etc.). Frontend useWebSocket.test.ts exercises the inventory_changed → invalidate ['inventory-locations'] round-trip. Full backend pytest 6025/6025 (67s with -n 30); frontend vitest 2141/2141; ruff clean; npm run build clean; ESLint clean; i18n parity green.
  • Admin-configurable session lifetime (#1706, reported by AD3DStuff) — The 24-hour session cap that ships with Bambuddy was an intentional security hardening (audit finding M-2 reduced it from 7 days), but the "Remember Me" checkbox only controlled storage location (localStorage vs sessionStorage), not session duration. iPhone PWA users and homelab admins on trusted networks were getting kicked out every 24 hours with no way to extend it. New setting: session_max_hours under Settings → Users with three presets (24h / 7 days / 30 days) plus a custom field, hard-capped at 30 days (720h). Default remains 24h so existing deployments and the M-2 audit baseline are untouched until an admin opts in. The Settings card surfaces a yellow warning whenever the value exceeds 24h: "Longer sessions reduce automatic logout protection. Recommended only for trusted single-user deployments." Backend wiring: new resolve_session_max_minutes(db) helper in backend/app/core/auth.py reads the setting, clamps to [1h, 720h], and falls back to 24h on missing / blank / unparseable values. The helper is called at all four token-issuance sites — plain /auth/login, 2FA TOTP/email completion, 2FA backup-code completion, and OIDC callback — so a long-session policy works uniformly regardless of how the user authenticates. DB errors in the resolver are deliberately NOT caught: login is already inside a transaction and a broken DB must abort the login rather than silently extend or shrink the session lifetime. Defense-in-depth SESSION_MAX_HOURS_HARD_CEILING = 720 clamps any tampered DB row above the Pydantic ceiling. Already-issued tokens keep their original expiry — the new setting only affects future logins, so an admin lowering the value can't retroactively revoke active sessions and an admin raising it can't retroactively extend them. What this does NOT change: the "Remember Me" checkbox still controls only storage location (cleared on browser close vs persisted across restarts). The relabel from misleading-UX-perspective is left for a separate follow-up — that's a UX choice independent of the session-policy mechanism. API tokens (MAX_TOKEN_LIFETIME_DAYS), camera stream tokens (60min), WebSocket tokens (60min), and slicer download tokens (5min) keep their own TTLs and are unaffected. Tests: 15 new cases in `backend/tests/integration/test_session_p

Changelog truncated — see the full CHANGELOG.md for the complete list.

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.