Forráskód Böngészése

Merge pull request #1263 from maziggy/0.2.4

**Bambuddy v0.2.4**

## ⚠ Upgrade Notes — Read Before Updating

**Most users are upgrading from 0.2.3.2.** The 0.2.4 stable release lands on `main`, so the in-app **Apply Update** button in Settings → System → Updates works for everyone — the 0.2.3.x updater is hardcoded to `origin/main`, and the 0.2.4-beta updater resolves to the latest release tag via the GitHub releases API (respecting `include_beta_updates`). Either way, you get 0.2.4. Below are the explicit Docker + native paths for users who'd rather drive the upgrade from the command line.

**Make a backup before upgrading** via Settings → Backup → Create Backup. Native install with `update.sh` snapshots the database automatically and rolls back on failure. Docker and fully-manual paths don't.

### Docker

Make sure your `docker-compose.yml` `image:` line points at `:0.2.4` (or `:latest` for the rolling stable tag).

```bash
docker compose pull
docker compose up -d
```

While you're there, refresh `docker-compose.yml` from the repo: the entrypoint changes in 0.2.4 mean `user: "${PUID:-1000}:${PGID:-1000}"` is now removed (the new gosu entrypoint owns privilege drop), `PUID` / `PGID` env vars are added with the same defaults, and the `./virtual_printer:/app/data/virtual_printer` bind mount is commented out by default. If you depended on that bind mount to share VP certs with a host install, uncomment it again — first start auto-chowns it.

### Native install — recommended path

```bash
sudo BRANCH=v0.2.4 /opt/bambuddy/install/update.sh
```

The `BRANCH=` env var tells `update.sh` to fetch the `v0.2.4` tag instead of tracking `origin/main`. Omit it (`sudo /opt/bambuddy/install/update.sh`) to follow `main` going forward. The script handles backup, service stop/start, `pip install`, and the frontend build with the correct working directory.

### Native install — manual path

```bash
sudo systemctl stop bambuddy
cd /opt/bambuddy
sudo -u bambuddy git fetch origin --tags
sudo -u bambuddy git checkout v0.2.4
sudo /opt/bambuddy/venv/bin/pip install -r requirements.txt
sudo systemctl start bambuddy
```

### Behaviour changes to know about

- **Docker entrypoint replaces the `chmod 777 /app/data` workaround.** If you've been carrying that, drop it — gosu chowns `/app/data` + `/app/logs` to `PUID:PGID` on first start (idempotent via sentinel file, so restarts skip the recursive traversal). Bind-mounted host directories are chowned through the mount the first time the entrypoint sees wrong ownership.
- **PostgreSQL restore from SQLite Local Backup now works end-to-end.** The `cannot drop table printers` abort that bit Postgres adopters in 0.2.3.x is gone — unblocks the SQLite → Postgres migration path.
- **Path-prefixed reverse proxies remain unsupported.** The `base: ''` Vite config that shipped briefly in 0.2.4b2 was reverted because it broke camera popups and deep-route initial loads. If you've been running Bambuddy at `/bambuddy/` on Traefik / nginx / Cloudflare Tunnel subpath, the documented workaround (NPM addon + Cloudflare Tunnel at a real domain + HA Webpage panel via `TRUSTED_FRAME_ORIGINS`) keeps working.
- **SpoolBuddy kiosks: in-app update + System buttons now work.** Settings → Update Daemon and the QuickMenu System buttons (Restart Daemon / Restart Browser / Reboot / Shutdown) no longer return "API keys cannot be used for administrative operations" — all five now route through `INVENTORY_UPDATE`. Stop reaching for SSH.

---

**Highlights**

0.2.4 is the cumulative result of three beta cycles (b1 → b3) and post-b3 work — three big-ticket features sit at the centre: **Server-side slicing** via OrcaSlicer / Bambu Studio sidecar (closes the gap where Bambuddy users had to keep a slicer install just to re-slice their archive), **Slicer Bundle (.bbscfg) import** so preset selection no longer has to round-trip Bambu Cloud, and a **unified Spoolman inventory UI** with AMS slot assignments, Storage Location, NFC write, and a filament-catalog picker that brings Spoolman feature parity with the local-mode inventory. Around them: **MakerWorld URL-paste import**, **MFA at-rest encryption auto-bootstrap** (default-on, no setup), **GitHub backup extended to Gitea + Forgejo**, **Tailscale integration for virtual printers**, **per-event ntfy priority**, **long-lived camera stream tokens for HA / Frigate / kiosks**, **Library Trash Bin + auto-purge**, and **spool label printing** including a new 40×30 mm template, hex colour code, and a bolder brand line.

Plus the largest contributor wave to date — see attribution per item below.

---

**New Features**

- **Virtual Printer non-proxy modes now mirror the live target printer to the slicer** ([#1193](https://github.com/maziggy/bambuddy/issues/1193) follow-up) — Cached-as-base mirror so AMS/k-profile/camera/status passes through the VP to BambuStudio when running queue or review mode (previously only proxy mode).
 
- **Server-side slicing via OrcaSlicer / Bambu Studio sidecar** ([PR #1144](https://github.com/maziggy/bambuddy/pull/1144) by @maziggy) — Bambuddy now slices STL/STEP/3MF server-side via an `orca-slicer-api` (or BambuStudio-API) sidecar container. Pick a printer + filament + process preset triplet in the SliceModal, dispatch, and the resulting 3MF is dropped into the library / queue. Embedded-settings fallback when the CLI can't resolve a preset.

- **Slicer Bundle (.bbscfg) import** — Upload a BambuStudio "Printer Preset Bundle" once per printer, then pick from it for every subsequent slice. Closes the long tail of preset-resolution corner cases (cloud presets behind login, "from User" sentinels, the `# `-prefix clone trick, dangling `inherits` on renamed parents). Works alongside the cloud/local/standard preset tiers — pick "Slicer bundle" in the SliceModal to skip PresetRef resolution entirely.

- **Multi-color slicing in the Slice modal with per-plate filament discovery** ([PR #1205](https://github.com/maziggy/bambuddy/pull/1205) by @maugsburger) — Slice modal auto-discovers per-plate filament requirements via a preview-slice call on unsliced project files, so the AMS-slot picker knows which slots the plate actually consumes before dispatch.

- **MakerWorld URL-paste import** ([PR #1099](https://github.com/maziggy/bambuddy/pull/1099) by @maziggy) — Paste a MakerWorld model URL into the import dialog and Bambuddy resolves the plate instances, lets you pick one, and drops it into the library. Authenticated via your existing Bambu Cloud session.

- **Unified Spoolman inventory UI + AMS slot assignments + Storage Location + NFC write + filament-catalog picker** ([PR #1063](https://github.com/maziggy/bambuddy/pull/1063), [PR #1114](https://github.com/maziggy/bambuddy/pull/1114), [PR #1241](https://github.com/maziggy/bambuddy/pull/1241) by @netscout2001 / @maziggy) — Spoolman-backed installs now get feature parity with local inventory: the inventory page renders the same way regardless of backend, "Assign to AMS" works both directions, the SpoolBuddy kiosk can write NFC tags directly to Spoolman spools, and the filament catalog picker browses Spoolman's filament library inline.

- **Tailscale integration for virtual printers** ([PR #1070](https://github.com/maziggy/bambuddy/pull/1070) by @legend813, follow-up by @maziggy) — Virtual printer card surfaces the host's Tailscale IP + MagicDNS hostname with a copy button, so you can paste a tailnet IP into the slicer for private WireGuard reach without port forwarding. Per-VP opt-out toggle.

- **MFA at-rest encryption — default-on via auto-bootstrap** ([PR #1231](https://github.com/maziggy/bambuddy/pull/1231) by @netscout2001) — OIDC `client_secret` and TOTP secret rows are now Fernet-encrypted by default. The key is auto-bootstrapped from `MFA_ENCRYPTION_KEY` env var → `DATA_DIR/.mfa_encryption_key` → auto-generated. New Settings → Authentication → Security tab surfaces the key source, encrypted/legacy row counts, and a `decryption_broken` recovery flag. Key file is included in Local Backup ZIPs so the restore is self-contained.

- **GitHub Backup extended to Gitea + Forgejo** ([PR #1160](https://github.com/maziggy/bambuddy/pull/1160), [PR #1255](https://github.com/maziggy/bambuddy/pull/1255) by @BurntOutHylian) — Settings → Backup → Git Provider now supports Gitea (1.18+) and Forgejo (all versions) alongside GitHub.com and GitHub Enterprise. Gitea uses the Contents API for atomic multi-file commits; Forgejo v15+ token-scope quirk on `/repos/` is handled via a `/user` pre-check. Tested against Gitea 1.24.7 / 1.25.4 / 1.26.1 and Forgejo v11 / v15 LTS.

- **Stock forecasting + Logistics view** ([PR #1184](https://github.com/maziggy/bambuddy/pull/1184) by @Keybored02) — New Inventory → Logistics tab forecasts when each material/colour combo will run out based on rolling burn rate and lets you flag spools for re-order.

- **Spool label printing — DK label printers + AMS layouts** ([#809](https://github.com/maziggy/bambuddy/issues/809)) — Print labels for any spool directly from inventory: roomy single-label (62×29, 40×30), tight AMS-strip (3-up), and Avery 5160 sheets. New 40×30 mm template, hex colour code on every label (`#RRGGBB`), and a bolder Helvetica-Bold brand line at arm's-length-readable size.

- **Embedded GCode viewer** ([PR #963](https://github.com/maziggy/bambuddy/pull/963) by @Soopahfly) — "3D Preview" button on every archive and library file opens a PrettyGCode viewer iframe — no external slicer needed for visual confirmation of which plate is which.

- **Copy spool — duplicate any spool in two clicks** ([PR #1246](https://github.com/maziggy/bambuddy/pull/1246) by @MiguelAngelLV) — Copy button next to Edit on every inventory row prefills SpoolFormModal with the source spool's settings (except `weight_used`, reset to 0). Saves serial-data-entry time for users with many near-identical spools.

- **Build-plate icon on archive cards + uniform printer/model line** ([#1253](https://github.com/maziggy/bambuddy/issues/1253), reported by @tonygauderman) — Archive cards now show an OrcaSlicer-style bed icon (Cool / Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI) so users can tell which build plate the print was sliced for at a glance. Backfill script available for older archives.

- **Library Trash Bin + Admin Bulk Purge + Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008)) — Deleted library files now land in a trash bin instead of being permanently removed. Admin → Library → Trash for bulk purge, plus a configurable auto-purge schedule.

- **Archive Auto-Purge** ([#1008](https://github.com/maziggy/bambuddy/issues/1008) follow-up) — Same pattern for print archives — auto-delete archives older than N days, with bulk-purge admin UI.

- **Project URL + cover photo** ([#1155](https://github.com/maziggy/bambuddy/issues/1155)) — Projects can now carry a source URL and a cover photo (auto-derived from the source 3MF or user-uploaded), rendered on the project list and detail views.

- **"Not Printed" / "Printed" collections on the Archives page** ([#1153](https://github.com/maziggy/bambuddy/issues/1153)) — Filter archives by whether they've been printed at least once, useful for "what's still untried" hunting.

- **Virtual-printer archive name source toggle** ([#1152](https://github.com/maziggy/bambuddy/issues/1152)) — Choose whether VP-spawned archives are named from the source 3MF filename or the slicer-supplied print job name.

- **Enhanced filament colour handling: multi-colour gradients, transparency, visual effects** ([#1154](https://github.com/maziggy/bambuddy/issues/1154)) — Spool form supports multi-colour gradient stops, transparency, and effect overlays (Sparkle, Silk, Matte) — rendered correctly on the spool cards, AMS slot view, and inventory.

- **Per-spool category + low-stock threshold override** ([#729](https://github.com/maziggy/bambuddy/issues/729)) — Each spool can override the global low-stock threshold and carry a custom category, surfaced in the Logistics view.

- **Per-event ntfy priority** ([#990](https://github.com/maziggy/bambuddy/issues/990)) — Set ntfy push priority per notification event type (Print complete → default, Filament out → urgent, etc.).

- **Long-lived camera-stream tokens for HA / Frigate / kiosks** ([#1108](https://github.com/maziggy/bambuddy/issues/1108)) — Generate scoped, non-expiring tokens for embedding Bambuddy camera streams in Home Assistant Webpage cards, Frigate dashboards, or kiosk displays without baking in admin credentials.

- **Filament Track Switch (FTS) support** — Detect and surface FTS-equipped printers' track-switch events on the printer card.

- **AMS slot Load / Unload from the printer card** ([#891](https://github.com/maziggy/bambuddy/issues/891), reported by @NNeerr00, +1 from @cadtoolbox) — Click any AMS slot on the printer card to load or unload the filament — no more reaching for the BambuStudio app.

- **API keys can read Bambu Cloud presets on the owner's behalf** ([#1182](https://github.com/maziggy/bambuddy/issues/1182), reported by @turulix) — API keys can now route Bambu Cloud preset reads through the owner's stored cloud session, so Home Assistant / external automations can slice without separate cloud auth.

- **Home Assistant addon detection** — Bambuddy auto-detects when it's running as the HA OS addon and surfaces the right webhook URL / iframe origin on the Settings page.

- **OIDC: Azure Entra ID support — configurable email claim & verification + Remember Me persistent login** ([PR #1126](https://github.com/maziggy/bambuddy/pull/1126) by @netscout2001) — Custom email claim path (Azure's `preferred_username`/`emails[0]`), per-provider "Require email verified" toggle, and a "Remember me" checkbox on the login page for opt-in 30-day sessions.

- **OIDC auto-created users now get readable usernames and land in a configurable group** ([PR #1176](https://github.com/maziggy/bambuddy/pull/1176) by @netscout2001) — `preferred_username` / `name` claim is consumed first, falling back to email-prefix; default group for auto-provisioned OIDC users is now a Settings dropdown instead of hardcoded "Viewers".

- **Slicer presets now span Cloud, imported, and slicer-bundled tiers, end-to-end** — Unified preset resolution across the three sources so SliceModal, the dispatch path, and the preview-slice cache all agree on which preset a pick resolves to.

- **Plate-clear tracking and visibility on printer cards** ([PR #939](https://github.com/maziggy/bambuddy/pull/939) by @EdwardChamberlain) — Plate-clear gate persists across container restarts and Auto-Off power cycles, with a clearer pill on the printer card showing "Awaiting plate clear" vs "Ready to print".

- **Printer page header update** ([PR #1203](https://github.com/maziggy/bambuddy/pull/1203) by @EdwardChamberlain) — Cleaner header layout on the Printers page.


---

**Improved**

- **Docker data-volume ownership normalised at startup via gosu entrypoint** ([#1211](https://github.com/maziggy/bambuddy/issues/1211)) — Replaces the `chmod 777 /app/data` hack with a proper entrypoint that chowns `/app/data` + `/app/logs` to `PUID:PGID` and drops to that uid via gosu. Subsequent restarts skip the recursive traversal via a sentinel file — no startup penalty on multi-GB archive directories.

- **Spool edit form: persistent Extra Colours, distinguishable Dual Color vs Gradient, more visible Sparkle/checkerboard visuals** ([#1154](https://github.com/maziggy/bambuddy/issues/1154) follow-up, reported by @maugsburger) — UX pass on the colour-effects added in b1.

- **AMS slot "Assign to inventory spool" picker now lists every spool, including RFID-tagged Bambu Lab ones** ([#1133](https://github.com/maziggy/bambuddy/issues/1133)) — Previously hid RFID-bound spools, forcing users to clear the RFID tag first.

- **Inventory: "Delete Tag" button renamed to "Clear RFID Tag"** ([#729](https://github.com/maziggy/bambuddy/issues/729) follow-up) — Less alarming wording; clearer what the button actually does.

- **Nozzle icon on the dual-nozzle status card** ([#1115](https://github.com/maziggy/bambuddy/issues/1115)) — Visually distinguish left/right extruder activity at a glance.

- **Settings page: permission-gated instead of admin-only** — Every settings sub-page now respects per-group permissions (e.g. `SETTINGS_NOTIFICATIONS` for Notifications, `SETTINGS_PROFILES` for Profiles, etc.) so non-admin users with specific scopes can manage their own sections.

- **i18n: full key parity across all 8 locales** — CI gate now enforces parity across en/de/fr/it/ja/pt-BR/zh-CN/zh-TW.

- **Per-request trace ID column on every log line** — Plumbed through HTTP access log, application logs, and response headers so a support bundle can trace one request end-to-end.

- **Live slicer progress in the persistent slice toast** — Real progress percentage instead of a spinner, with URL-decoded filenames in the toast title.

- **Background-dispatch toast no longer reads as "frozen at 100%" for fast uploads** — Fast uploads (small files / fast networks) no longer leave the toast stuck at 100% for several seconds.

- **SpoolBuddy kiosk no longer shows main-app toasts** — Kiosk-side notification suppression so operator-side toasts don't leak onto the kiosk display.

- **SpoolBuddy kiosk: "Plate ready" pills under the printer status badges** — At-a-glance plate state alongside printer state.

- **MakerWorld URL-paste resolver shows which printer each plate was sliced for** — So the picker shows e.g. "Plate 1 (X1C)" / "Plate 2 (P1S)" instead of unlabelled.

---

**Fixed**

### Slicing / Dispatch

- 3MF profile-driven slicing silently produced wrong-printer output (every slice fell back to the source's embedded printer regardless of the picked profile).
- Sliced-archive card listed every project-wide AMS slot instead of just the filaments the print actually used.
- Sliced output of a "single-color" plate had filaments the user never picked.
- Slice modal had no warning when the picked printer profile didn't match the source 3MF's bound printer.
- Settings warning when OrcaSlicer is selected as the preferred slicer (vs Bambu Studio).
- MakerWorld P2S 3MFs failed to slice with "Param values in 3mf/config error: -1 not in range" ([#1201](https://github.com/maziggy/bambuddy/issues/1201), reported by @inorichi).
- Slicer "Send to printer" silently rejected the cached push_status with "storage needs to be inserted" on P1S/A1-class targets ([#1228](https://github.com/maziggy/bambuddy/issues/1228), reported by @rtadams89 and @smandon).
- Slicing a library file via API key fails with "no Bambu Cloud session is stored" even when the key has cloud access ([#1182](https://github.com/maziggy/bambuddy/issues/1182) follow-up, reported by @turulix).
- Slice button no longer enabled before the preview slice resolves.
- Reprint-from-archive failed with `0500_4003` SD R/W errors after a stuck dispatch ([#1136](https://github.com/maziggy/bambuddy/issues/1136)).
- P1P print dispatch failed with `0500_4003 "can't parse print file"` when the printer was slow to acknowledge ([#1150](https://github.com/maziggy/bambuddy/issues/1150), reported by @d3ni3).
- H2D Pro multi-plate dispatch double-/triple-fire ([#1157](https://github.com/maziggy/bambuddy/issues/1157)).
- Background-dispatch reported "Print started successfully" when the printer never actually transitioned ([#1134](https://github.com/maziggy/bambuddy/issues/1134), follow-up to [#1042](https://github.com/maziggy/bambuddy/issues/1042)).
- Queue auto-dispatched the next print onto a fouled bed after an aborted or cancelled print ([#1171](https://github.com/maziggy/bambuddy/issues/1171), reported by @tom5677).
- Queue item stuck at "printing" when print failed before reaching RUNNING ([#1111](https://github.com/maziggy/bambuddy/issues/1111)).
- Queue: batch (quantity>1) double-dispatched onto the same printer.
- Queue: active-item progress bar flashed 100% before dropping to 0%.
- Virtual Printer queue mode auto-dispatched onto the wrong colour when multiple compatible printers were available ([#1188](https://github.com/maziggy/bambuddy/issues/1188), reported by @EdwardChamberlain).

### AMS / Filament / Inventory

- X2D / H2D dual-nozzle without AMS: filament mapping reported "Required filament type not found in printer" even when the spools were physically loaded ([#1257](https://github.com/maziggy/bambuddy/issues/1257)).
- New AMS RFID rolls auto-named to the wrong colour when the hex is shared across material variants (e.g. PLA Matte rolls named "Jade White") ([#1227](https://github.com/maziggy/bambuddy/issues/1227)).
- Bambu RFID auto-match created duplicate inventory rows for Quick-Add and non-Bambu-branded spools ([#918](https://github.com/maziggy/bambuddy/issues/918)).
- Filament usage double-counted when AMS auto-falls-back to a same-material spool ([#957](https://github.com/maziggy/bambuddy/issues/957)).
- Spool form's "Slicer Preset" dropdown silently dropped Local Profiles when Bambu Cloud was connected, and collapsed per-printer/per-nozzle variants into a single entry ([#1248](https://github.com/maziggy/bambuddy/issues/1248), reported by @andretietz).
- Spool auto-assign hit `IntegrityError` on Postgres when AMS pushes arrived in quick succession.
- AMS slot configuration intermittently fails to reach the printer after several configs in a row ([#1164](https://github.com/maziggy/bambuddy/issues/1164), reported by @RosdasHH).
- Spool assignment to a reset AMS slot left the slot unconfigured both in Bambuddy and on the printer.
- AMS slot truncation hid the `@printer 0.4 nozzle` suffix; inline hover expansion added ([#1237](https://github.com/maziggy/bambuddy/issues/1237), reported by @basziee).

### Virtual Printer

- VP queue mode dispatched onto the wrong colour with multiple compatible printers ([#1188](https://github.com/maziggy/bambuddy/issues/1188)).
- VP cached-as-base mirror now overlays SD/storage indicators (`home_flag`, `sdcard`, `storage`) so P1S/A1-class targets don't fail BambuStudio's pre-flight ([#1228](https://github.com/maziggy/bambuddy/issues/1228)).
- VP Tailscale cert-renewal restart silently failed mid-way (follow-up to [#1070](https://github.com/maziggy/bambuddy/issues/1070)).
- Virtual printer card's Tailscale FQDN copy button failed on HTTP.

### Backup / Git Providers

- Gitea backups silently failed after the first run; Forgejo v15 token-scope quirk broke "Test Connection"; many failure paths surfaced cryptic one-word errors ([#1224](https://github.com/maziggy/bambuddy/issues/1224) reported by @rtadams89, [#1239](https://github.com/maziggy/bambuddy/issues/1239) + [PR #1255](https://github.com/maziggy/bambuddy/pull/1255) by @BurntOutHylian).
- Backups to Gitea / Forgejo failed with "Failed to create tree" on empty repos and `list indices must be integers or slices, not str` on populated repos ([#1224](https://github.com/maziggy/bambuddy/issues/1224), [#1225](https://github.com/maziggy/bambuddy/issues/1225)).
- Backup restore silently lost most data (Postgres restore from a SQLite backup).
- Postgres restore from a SQLite Local Backup aborted with `cannot drop table printers`.

### OIDC / Auth

- OIDC callback code/state max_length raised from 512 to 2048 ([#1024](https://github.com/maziggy/bambuddy/issues/1024), [PR #1024](https://github.com/maziggy/bambuddy/pull/1024) by @netscout2001).
- OIDC `auto_link_existing_accounts` now works with custom email claims (Azure Entra ID) ([#1088](https://github.com/maziggy/bambuddy/issues/1088), [PR #1142](https://github.com/maziggy/bambuddy/pull/1142) by @netscout2001).
- OIDC issuer trailing-slash mismatch ([#995](https://github.com/maziggy/bambuddy/issues/995), [#985](https://github.com/maziggy/bambuddy/issues/985)).
- OIDC settings form: "Require email verified" toggle no longer jumps layout when auto-link is enabled.
- Setup: re-enabling auth could 422 on a password the form no longer needs.

### Camera

- Camera preview popup opened to a blank page; deep-route refresh and direct URL load broken ([#1221](https://github.com/maziggy/bambuddy/issues/1221), reported by @enjoylifenow / @Haeckan / @elit3ge / @jc21).
- External-camera frames returned as black on go2rtc and other MJPEG sources ([#1177](https://github.com/maziggy/bambuddy/issues/1177), reported by @nkm8).
- Camera page ignored `?fps=N` URL parameter ([#1131](https://github.com/maziggy/bambuddy/issues/1131) diagnostic).
- Camera stream second viewer fails / kicks the first off ([#1089](https://github.com/maziggy/bambuddy/issues/1089)).
- Camera TLS proxy logged unhandled exceptions when ffmpeg dropped its half of the connection mid-stream under uvloop.

### File Management / Library

- 3D Preview returned `{"detail":"Not Found"}` in Docker installs ([#1218](https://github.com/maziggy/bambuddy/issues/1218)).
- Archive 3MFs (and library file bytes) silently deleted from disk on every print completion ([#1212](https://github.com/maziggy/bambuddy/issues/1212), reported by @abbasegbeyemi).
- Archive created with wrong plate metadata when consecutive plates of the same model are printed back-to-back ([#1204](https://github.com/maziggy/bambuddy/issues/1204), reported by @BurntOutHylian).
- Archive Reprint colliding with originals (carryover fix from 0.2.3 cycle).
- Moving a file to an external folder updated the DB row but never wrote the bytes to the mount ([#1112](https://github.com/maziggy/bambuddy/issues/1112) follow-up).
- Uploads to writable external folders silently landed in internal storage ([#1112](https://github.com/maziggy/bambuddy/issues/1112)).
- GCode Viewer had no in-app way to navigate back (only the browser's back button worked).
- Archives card's "Reprint" / "Schedule" / "Slice" button labels truncated to "Re..." / "Sc..." on narrow browser windows ([#1249](https://github.com/maziggy/bambuddy/issues/1249)).
- Printer file download 500'd on non-ASCII filenames; same crash latent in three sibling endpoints ([#1245](https://github.com/maziggy/bambuddy/issues/1245), reported by @1000Delta).
- Reprint-from-Archive left `created_by_id` as `NULL` ([#730](https://github.com/maziggy/bambuddy/issues/730) follow-up).

### Print Queue / Notifications

- Print-complete notification reported the slicer's pre-print estimate instead of the actual elapsed time ([#1198](https://github.com/maziggy/bambuddy/issues/1198), reported by @BurntOutHylian).
- User-cancelled prints surfaced as "1 problem" on the printer card AND were archived as "Layer shift" failures.
- Pending review card and the resulting archive name disagreed; `.gcode.3mf` filename suffix wasn't fully stripped ([#1152](https://github.com/maziggy/bambuddy/issues/1152) follow-up, reported by @smandon).
- Auto-Print G-code Injection: start snippet landed before printer startup, and `{placeholder}` substitution was silently broken ([#422](https://github.com/maziggy/bambuddy/issues/422) follow-up).
- Plate-clear button stayed visible after the API cleared `awaiting_plate_clear` outside the printer-card click path ([#1128](https://github.com/maziggy/bambuddy/issues/1128)).

### Printer Card / UI

- Printer card's "Show on Printer Card" smart-plug button toggled power without confirmation ([#1260](https://github.com/maziggy/bambuddy/issues/1260), reported by @thkl).
- Printer card always shows the first plate's thumbnail when printing a multi-plate 3MF ([#1166](https://github.com/maziggy/bambuddy/issues/1166), reported by @smandon).
- Printer Info modal: serial-number and IP-address copy buttons silently did nothing on plain-HTTP LAN deployments ([#1174](https://github.com/maziggy/bambuddy/issues/1174), reported by @BurntOutHylian).
- Label picker modal clipped the 4th template option and Cancel button on short viewports ([#1230](https://github.com/maziggy/bambuddy/issues/1230), reported by @elit3ge).
- Project cover photo thumbnail too small to recognise the print ([#1155](https://github.com/maziggy/bambuddy/issues/1155) follow-up, reported by @smandon).
- Project picker UX in archives ([#1151](https://github.com/maziggy/bambuddy/issues/1151)).

### iframes / Reverse Proxies

- iframe embedding from trusted origins (e.g. Home Assistant Webpage panel) no longer blocked ([#1191](https://github.com/maziggy/bambuddy/issues/1191), reported by @azurusnova).
- Frontend served behind a path-prefixed reverse proxy loaded a blank page in 0.2.4b2 — reverted (see Upgrade Notes) ([#1195](https://github.com/maziggy/bambuddy/issues/1195) → reverted in 0.2.4b3).
- Spoolman iframe silently blank on HTTPS Bambuddy with HTTP Spoolman ([#1096](https://github.com/maziggy/bambuddy/issues/1096)).

### MakerWorld

- MakerWorld sidebar entry visible to every user regardless of group permissions ([#1175](https://github.com/maziggy/bambuddy/issues/1175)).
- MakerWorld P2S 3MF parse error on slice ([#1201](https://github.com/maziggy/bambuddy/issues/1201)).

### SpoolBuddy (Kiosk)

- SpoolBuddy install.sh re-run failed with `Permission denied` on root-owned files in update mode.
- SpoolBuddy SSH update aborted with `TypeError: startswith first arg must be bytes or a tuple of bytes, not str` after the host-key store succeeded.
- SpoolBuddy SSH update crashed on Postgres with `value too long for type character varying(500)` when storing the device's RSA host key.
- SpoolBuddy SSH update fails with "permission denied for user spoolbuddy" after Bambuddy keypair rotation.
- SpoolBuddy kiosk Settings → Update button returned "API keys cannot be used for administrative operations".
- SpoolBuddy with Spoolman: NFC tag scan looked up local DB first; Assign-to-AMS no-op on freshly-linked spools; AMS slot picker hid the assigned spool; LinkSpoolModal showed "Unknown color"; tag-write didn't enforce uniqueness; kiosk display held stale state.
- SpoolBuddy AMS page: re-assigning a just-unassigned spool sometimes showed an empty picker ([#1133](https://github.com/maziggy/bambuddy/issues/1133) follow-up).
- SpoolBuddy kiosk screen-blank timeout setting was ignored after the first save.
- SpoolBuddy kiosk screen never blanked while a load cell was producing noisy readings.

### Docker / Install / Logging

- Docker permission errors on `/app/data/virtual_printer` and similar paths — fixed via gosu entrypoint (see Highlights).
- In-app upgrade was hardcoded to `origin/main` and silently no-op'd whenever the latest release wasn't on main.
- In-app upgrade clobbered SSH `origin` on developer checkouts.
- Native-install in-app upgrade silently skipped `pip install` and the new dependencies never landed.
- Settings table filled with duplicate rows on legacy SQLite installs.
- Install script failed for first-time users.
- "Open in Slicer" fails on Windows / Linux for any filename containing spaces or special characters ([#1059](https://github.com/maziggy/bambuddy/issues/1059)).
- `bambuddy.log` filling with `Exception terminating connection ... CancelledError` + `database is locked` cascades on long uploads ([#1112](https://github.com/maziggy/bambuddy/issues/1112) follow-up).
- Windows install: `bambuddy.log` filling with `WinError 10054`.
- `logs/bambuddy.log` was silently dropping records from named child loggers.
- Uvicorn HTTP access log was missing from `bambuddy.log`.
- Swagger UI link in Settings → API Keys rendered a blank page.

### Misc

- H2C dual-nozzle detection missed post-2026 serial batches ([#1105](https://github.com/maziggy/bambuddy/issues/1105)).
- Groups: edits to custom-group permissions appeared lost on reopen ([#1083](https://github.com/maziggy/bambuddy/issues/1083)).
- Settings: failed-save toast looped forever when the user lacked `settings:update`.
- Settings → API Keys: deleted key stayed on screen until manual reload.
- i18n placeholder mismatches in Japanese rendered literal `{{count}}` / `{{name}}` strings in the UI.
- `formatTimeOnly` tests failed under non-`:`-separator locales ([#1213](https://github.com/maziggy/bambuddy/issues/1213), reported by @maugsburger).

---

**Security**

- **python-multipart bumped to >=0.0.27** to clear CVE-2026-42561 (b3).
- **pip upgraded to >=26.1 inside the Docker image** to clear CVE-2026-6357 (medium; GitHub code-scanning alert #778). No `requirements.txt` change — the floor is enforced at the image-build layer where the vulnerable copy lived. (libexpat1 alert #795 also flagged by code-scanning is a DoS-only XML attribute-collision CVE with no patched Debian trixie package yet — left open as a tracking signal.)

---

**Contributors**

Big thanks to everyone who shipped code this cycle:

@netscout2001, @BurntOutHylian, @EdwardChamberlain, @Soopahfly, @legend813, @Keybored02, @maugsburger, @MiguelAngelLV

(See [CHANGELOG.md](https://github.com/maziggy/bambuddy/blob/main/CHANGELOG.md) for the full per-fix detail.)
MartinNYHC 2 hete
szülő
commit
a19f74252e
100 módosított fájl, 14798 hozzáadás és 1270 törlés
  1. 20 0
      .env.example
  2. 2 2
      .github/workflows/security.yml
  3. 6 0
      .gitignore
  4. 51 0
      .gitleaks.toml
  5. 5 2
      .pre-commit-config.yaml
  6. 3 1
      CHANGELOG.md
  7. 53 5
      Dockerfile
  8. 1 2
      Dockerfile.test
  9. 25 5
      README.md
  10. 10 9
      UPDATING.md
  11. 326 0
      backend/app/api/routes/_spoolman_helpers.py
  12. 24 1
      backend/app/api/routes/api_keys.py
  13. 81 0
      backend/app/api/routes/archive_purge.py
  14. 264 7
      backend/app/api/routes/archives.py
  15. 310 0
      backend/app/api/routes/auth.py
  16. 87 76
      backend/app/api/routes/camera.py
  17. 164 18
      backend/app/api/routes/cloud.py
  18. 31 8
      backend/app/api/routes/github_backup.py
  19. 703 297
      backend/app/api/routes/inventory.py
  20. 4 3
      backend/app/api/routes/kprofiles.py
  21. 211 0
      backend/app/api/routes/labels.py
  22. 1136 26
      backend/app/api/routes/library.py
  23. 281 0
      backend/app/api/routes/library_trash.py
  24. 433 0
      backend/app/api/routes/makerworld.py
  25. 255 40
      backend/app/api/routes/mfa.py
  26. 56 3
      backend/app/api/routes/pending_uploads.py
  27. 1 1
      backend/app/api/routes/print_queue.py
  28. 463 81
      backend/app/api/routes/printers.py
  29. 174 5
      backend/app/api/routes/projects.py
  30. 272 82
      backend/app/api/routes/settings.py
  31. 48 0
      backend/app/api/routes/slice_jobs.py
  32. 552 0
      backend/app/api/routes/slicer_presets.py
  33. 536 110
      backend/app/api/routes/spoolbuddy.py
  34. 413 109
      backend/app/api/routes/spoolman.py
  35. 1541 0
      backend/app/api/routes/spoolman_inventory.py
  36. 224 25
      backend/app/api/routes/updates.py
  37. 18 1
      backend/app/api/routes/users.py
  38. 43 0
      backend/app/api/routes/virtual_printers.py
  39. 73 0
      backend/app/core/asyncio_handlers.py
  40. 195 14
      backend/app/core/auth.py
  41. 46 1
      backend/app/core/config.py
  42. 852 52
      backend/app/core/database.py
  43. 179 29
      backend/app/core/encryption.py
  44. 109 0
      backend/app/core/logging_filters.py
  45. 26 0
      backend/app/core/paths.py
  46. 27 0
      backend/app/core/permissions.py
  47. 118 0
      backend/app/core/trace.py
  48. 607 63
      backend/app/main.py
  49. 2 0
      backend/app/models/__init__.py
  50. 12 1
      backend/app/models/api_key.py
  51. 1 0
      backend/app/models/archive.py
  52. 4 1
      backend/app/models/color_catalog.py
  53. 28 0
      backend/app/models/filament_sku_settings.py
  54. 2 0
      backend/app/models/github_backup.py
  55. 25 1
      backend/app/models/library.py
  56. 77 0
      backend/app/models/long_lived_token.py
  57. 4 0
      backend/app/models/notification.py
  58. 13 0
      backend/app/models/notification_template.py
  59. 30 1
      backend/app/models/oidc_provider.py
  60. 6 0
      backend/app/models/pending_upload.py
  61. 5 0
      backend/app/models/printer.py
  62. 8 0
      backend/app/models/project.py
  63. 22 0
      backend/app/models/shopping_list.py
  64. 21 1
      backend/app/models/spool.py
  65. 1 0
      backend/app/models/spoolbuddy_device.py
  66. 34 0
      backend/app/models/spoolman_k_profile.py
  67. 35 0
      backend/app/models/spoolman_slot_assignment.py
  68. 8 0
      backend/app/models/virtual_printer.py
  69. 4 0
      backend/app/schemas/api_key.py
  70. 1 0
      backend/app/schemas/archive.py
  71. 25 0
      backend/app/schemas/archive_purge.py
  72. 86 9
      backend/app/schemas/auth.py
  73. 53 26
      backend/app/schemas/github_backup.py
  74. 59 0
      backend/app/schemas/library_trash.py
  75. 111 0
      backend/app/schemas/makerworld.py
  76. 8 0
      backend/app/schemas/notification.py
  77. 25 0
      backend/app/schemas/printer.py
  78. 33 1
      backend/app/schemas/project.py
  79. 40 2
      backend/app/schemas/settings.py
  80. 188 0
      backend/app/schemas/slicer.py
  81. 67 0
      backend/app/schemas/slicer_presets.py
  82. 108 1
      backend/app/schemas/spool.py
  83. 49 32
      backend/app/schemas/spoolbuddy.py
  84. 30 0
      backend/app/schemas/spoolman.py
  85. 95 2
      backend/app/services/archive.py
  86. 233 0
      backend/app/services/archive_purge.py
  87. 122 20
      backend/app/services/background_dispatch.py
  88. 23 5
      backend/app/services/bambu_ftp.py
  89. 315 58
      backend/app/services/bambu_mqtt.py
  90. 12 2
      backend/app/services/camera.py
  91. 280 0
      backend/app/services/camera_fanout.py
  92. 63 29
      backend/app/services/external_camera.py
  93. 113 0
      backend/app/services/filament_requirements.py
  94. 4 0
      backend/app/services/git_providers/__init__.py
  95. 78 0
      backend/app/services/git_providers/base.py
  96. 22 0
      backend/app/services/git_providers/factory.py
  97. 101 0
      backend/app/services/git_providers/forgejo.py
  98. 365 0
      backend/app/services/git_providers/gitea.py
  99. 431 0
      backend/app/services/git_providers/github.py
  100. 257 0
      backend/app/services/git_providers/gitlab.py

+ 20 - 0
.env.example

@@ -16,3 +16,23 @@ LOG_TO_FILE=true
 # and these values override any database settings (read-only in UI)
 # HA_URL=http://supervisor/core
 # HA_TOKEN=your-long-lived-access-token
+
+# Trusted iframe origins (#1191) — comma-separated list of scheme://host[:port]
+# origins permitted to embed Bambuddy via <iframe>. Defaults to empty (strict:
+# only same-origin embedding allowed). Set this to your Home Assistant origin
+# when using the HA Webpage dashboard panel, since HA on port 8123 and Bambuddy
+# on port 8000 are different origins to the browser. Wildcards, paths, and
+# non-http(s) schemes are rejected at startup with a warning.
+# TRUSTED_FRAME_ORIGINS=http://homeassistant.local:8123
+
+# MFA at-rest encryption key (#1219) — Fernet, base64-encoded 32 bytes.
+# Auto-generated and stored in DATA_DIR/.mfa_encryption_key on first startup
+# if unset. Set explicitly to manage the key out-of-band (e.g. via a secret
+# manager).
+# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
+#
+# NOTE: Local backups (.zip) include the auto-generated key file, so a backup
+# is self-contained. If you set this variable explicitly, ensure your backups
+# also store the value separately (otherwise an encrypted backup cannot be
+# restored after key loss).
+# MFA_ENCRYPTION_KEY=

+ 2 - 2
.github/workflows/security.yml

@@ -146,7 +146,7 @@ jobs:
           retention-days: 30
 
       - name: Create or close pip security issue
-        if: always()
+        if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
         uses: actions/github-script@v7
         with:
           script: |
@@ -321,7 +321,7 @@ jobs:
           retention-days: 30
 
       - name: Create or close npm security issue
-        if: always()
+        if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
         uses: actions/github-script@v7
         with:
           script: |

+ 6 - 0
.gitignore

@@ -65,6 +65,9 @@ data/
 # JWT secret file (should be in data dir, but protect project root too)
 .jwt_secret
 
+# MFA encryption key file (#1219) — same protection as .jwt_secret
+.mfa_encryption_key
+
 # SpoolBuddy SSH keys (generated at runtime for remote updates)
 spoolbuddy/ssh/
 
@@ -77,3 +80,6 @@ support-packages/
 backups/
 bin/
 advertisements/
+
+# gitleaks reports
+gitleaks-report.json

+ 51 - 0
.gitleaks.toml

@@ -0,0 +1,51 @@
+title = "Bambuddy gitleaks config"
+
+# Extend the built-in ruleset instead of replacing it.
+[extend]
+useDefault = true
+
+# ── Custom rules ─────────────────────────────────────────────────────────
+
+# Flag credentials embedded in URL userinfo, e.g.
+#   http://USERNAME:PASSWORD@host/
+# gitleaks' default ruleset does not catch these because plain alphanumeric
+# passwords have no recognisable signature — only the URL structure does.
+[[rules]]
+id = "basic-auth-url"
+description = "Credentials in HTTP(S) URL userinfo"
+regex = '''https?://[^:/\s@]+:[^@/\s]{4,}@'''
+tags = ["credentials", "url"]
+
+[rules.allowlist]
+# Skip well-known dummy/example creds that legitimately appear in docs
+# and test fixtures, and template-literal interpolations in source code
+# (e.g. `http://${user}:${password}@...` — not an actual credential).
+regexes = [
+    '''https?://user:pass(word)?@''',
+    '''https?://admin:admin@''',
+    '''https?://test:test@''',
+    '''https?://example:example@''',
+    '''https?://foo:bar@''',
+    '''https?://[^:]+:password@''',
+    '''https?://[^:]+:secret@''',
+    # JS template literal  http://${user}:${password}@
+    '''https?://\$\{[^}]+\}:\$\{[^}]+\}@''',
+    # Python f-string      http://{username}:{password}@
+    '''https?://\{[^}]+\}:\{[^}]+\}@''',
+]
+
+# ── Global allowlist ─────────────────────────────────────────────────────
+
+[allowlist]
+description = "Global paths and patterns that never contain real secrets"
+paths = [
+    '''(.*?)(png|jpg|jpeg|gif|svg|ico|webp|pdf)$''',
+    '''frontend/dist/.*''',
+    '''frontend/node_modules/.*''',
+    '''backend/tests/fixtures/.*''',
+    '''static/assets/.*''',   # bundled frontend build output (minified JS/CSS)
+    # Historical log file (deleted in working tree, still in git history).
+    # Credentials inside have been rotated; allowlisted to keep future scans
+    # from re-surfacing them as noise.
+    '''bambutrack\.log\.1$''',
+]

+ 5 - 2
.pre-commit-config.yaml

@@ -19,9 +19,12 @@ repos:
     rev: v5.0.0
     hooks:
       - id: trailing-whitespace
-        exclude: ^static/
+        # Exclude static/ (build output) and gcode_viewer/ (vendored third-party
+        # assets — see gcode_viewer/VENDORED.md) so whitespace normalisation
+        # doesn't drift the files away from upstream.
+        exclude: ^(static/|gcode_viewer/)
       - id: end-of-file-fixer
-        exclude: ^static/
+        exclude: ^(static/|gcode_viewer/)
       - id: check-yaml
       - id: check-json
         exclude: ^(static/|frontend/tsconfig\.)

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 3 - 1
CHANGELOG.md


+ 53 - 5
Dockerfile

@@ -23,20 +23,38 @@ ENV DEBIAN_FRONTEND=noninteractive
 RUN apt-get update && apt-get install -y --no-install-recommends \
     curl \
     ffmpeg \
+    gnupg \
+    gosu \
     iproute2 \
     libcap2-bin \
     openssh-client \
     && rm -rf /var/lib/apt/lists/*
 
+# Install the Tailscale CLI only (no tailscaled — the daemon runs on the host).
+# Bambuddy calls `tailscale status` / `tailscale cert` via the host's socket,
+# which the user mounts in via docker-compose when they want to enable the
+# Tailscale integration for virtual printers. Without the socket mount, the
+# binary is harmless — the code logs a hint and falls back to self-signed.
+RUN curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.noarmor.gpg \
+        -o /usr/share/keyrings/tailscale-archive-keyring.gpg \
+    && curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.tailscale-keyring.list \
+        -o /etc/apt/sources.list.d/tailscale.list \
+    && apt-get update && apt-get install -y --no-install-recommends tailscale \
+    && rm -rf /var/lib/apt/lists/*
+
 # Allow binding to privileged ports (e.g. 990/FTPS) as non-root user.
 # File capabilities are more reliable than Docker cap_add with user: directive,
 # which depends on ambient capability support in the container runtime.
 RUN setcap cap_net_bind_service=+ep "$(readlink -f /usr/local/bin/python3)"
 
-# Install Python dependencies with cache mount
+# Install Python dependencies with cache mount.
+# pip is upgraded to >=26.1 first to close CVE-2026-6357 — the python:3.13-slim
+# base image ships pip 26.0.1, which runs its self-update check after installing
+# wheels (so a hostile wheel could hijack stdlib imports during install).
 COPY requirements.txt ./
 RUN --mount=type=cache,target=/root/.cache/pip \
-    pip install --root-user-action=ignore -r requirements.txt
+    pip install --root-user-action=ignore --upgrade 'pip>=26.1' \
+ && pip install --root-user-action=ignore -r requirements.txt
 
 # Copy backend
 COPY backend/ ./backend/
@@ -53,9 +71,38 @@ COPY .git/HEAD ./.git/HEAD
 # Copy built frontend from builder stage
 COPY --from=frontend-builder /app/static ./static
 
-# Create data directory for persistent storage
-# chmod 777 allows running as non-root user (e.g., with docker compose user: directive)
-RUN mkdir -p /app/data /app/logs && chmod 777 /app/data /app/logs
+# Copy embedded GCode viewer static assets (PrettyGCode + Bambuddy adapter).
+# Served by the explicit @app.get("/gcode-viewer/{...}") routes in main.py,
+# which resolve files under (static_dir.parent / "gcode_viewer") = /app/gcode_viewer/.
+# Without this COPY the routes return a bare 404 at request time and the 3D
+# Preview iframe shows {"detail":"Not Found"} (see #1218). The directory is
+# vendored third-party JS — the Vite build does NOT stage it into static/,
+# the dev server serves it via a configureServer middleware that's dev-only.
+COPY gcode_viewer/ ./gcode_viewer/
+
+# Create data directories. Ownership is normalised at startup by the
+# entrypoint (chowns to PUID:PGID and drops privileges via gosu before
+# exec'ing the app), so we don't need a chmod 777 hack here — that was
+# the workaround for the previous compose `user: "1000:1000"` model and
+# only worked when the volume's perms happened to survive (named volume
+# first-create case; bind-mount-source case bit users in #1211 / #668).
+#
+# The sentinel file is needed so a freshly-created Docker named volume
+# isn't "empty" from Docker's POV. On empty volumes Docker resyncs the
+# directory metadata (incl. ownership) from the image on every mount,
+# which would mean our entrypoint chown gets reverted on every restart
+# and re-fired on every start (slow on multi-GB archive dirs). With a
+# sentinel inside the volume on first mount, Docker considers the
+# volume populated and stops resyncing, so the chown is genuinely
+# one-shot.
+RUN mkdir -p /app/data /app/logs && \
+    : >/app/data/.bambuddy && \
+    : >/app/logs/.bambuddy
+
+# Entrypoint script: handles PUID/PGID + ownership normalisation +
+# privilege drop. See deploy/docker-entrypoint.sh for the full rationale.
+COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
 
 # Environment variables
 ENV PYTHONUNBUFFERED=1
@@ -90,4 +137,5 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
 # Run the application
 # Use standard asyncio loop (uvloop has permission issues in some Docker environments)
 # Port is configurable via PORT environment variable (default: 8000)
+ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
 CMD ["sh", "-c", "uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000} --loop asyncio"]

+ 1 - 2
Dockerfile.test

@@ -26,8 +26,7 @@ ENV DATA_DIR=/app/data
 ENV TESTING=1
 
 # Default command runs pytest (excluding docker integration tests)
-# Use -n auto for parallel execution (auto-detects available CPUs)
-CMD ["pytest", "backend/tests/", "-v", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"]
+CMD ["pytest", "backend/tests/", "-v", "--tb=short", "-p", "no:cacheprovider", "-n", "30"]
 
 # -------------------------------------------
 # Frontend test stage

+ 25 - 5
README.md

@@ -70,7 +70,7 @@ You don't need to be a developer for the docs or moderator roles. If you enjoy w
 **Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer:
 
 - 🔒 **End-to-end TLS encryption** — FTP, file transfer, and camera are transparently proxied with the printer's real TLS certificate
-- 🛡️ **VPN recommended** — Use Tailscale/WireGuard for full data encryption ([details](https://wiki.bambuddy.cool/features/virtual-printer/))
+- 🛡️ **Optional Tailscale integration** — per-VP toggle + Docker socket mount surface the host's Tailscale IP on the VP card, so you know which `100.x.x.x` to paste into the slicer when you want a virtual printer reachable over your tailnet ([setup](https://wiki.bambuddy.cool/features/virtual-printer/)). Bambuddy's self-signed CA import is still required for the slicer side — the Bambu Studio / OrcaSlicer printer-MQTT trust path uses a bundled BBL CA, not the system trust store, so even a publicly-trusted cert wouldn't help. Tailscale's role is the private tunnel (reachability from anywhere, no port forwarding), not cert-import elimination.
 - 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server
 - 🔑 **Uses printer's access code** — No additional credentials needed
 - ⚡ **Full-speed printing** — Transparent TCP proxy, only MQTT is decrypted for IP rewriting
@@ -117,7 +117,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - Duplicate detection & full-text search
 - Photo attachments & failure analysis
 - Timelapse editor (trim, speed, music) with automatic AVI-to-MP4 conversion for P1-series printers, manual upload & remove
-- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support, nozzle-aware matching for dual-nozzle H2D/H2D Pro)
+- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support, nozzle-aware matching for dual-nozzle H2D/H2D Pro, **Filament Track Switch (FTS) support** — when the FTS accessory is installed the per-nozzle filter is suppressed since the FTS routes any AMS slot to either extruder)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
 - Tag management (rename/delete across all archives)
@@ -125,7 +125,8 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
-- Live camera streaming (MJPEG) & snapshots with multi-viewer support
+- Live camera streaming (MJPEG) & snapshots with multi-viewer support — most Bambu printers only allow one upstream connection, so Bambuddy fans out a single shared stream to all browser tabs / cards / overlays
+- **Long-lived camera tokens** for Home Assistant / Frigate / kiosks — mint a token from Settings → API Keys, paste it once, capped at 365 days, revocable at any time (no infinite tokens — leaked permanent tokens are unsafe by design)
 - **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`), configurable FPS (`?fps=30`), status-only mode (`?camera=false`)
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
@@ -138,6 +139,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - AMS slot RFID re-read
+- **AMS slot Load / Unload from the printer card** — Hover any AMS slot or external spool, click the menu button, and load that tray or unload the currently-loaded one without going to the touchscreen; supports dual-extruder H2D (Ext-L / Ext-R drive their own nozzle)
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
 - AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
 - **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets, optional spool rotation; automatic PSU detection and HMS power error reporting
@@ -192,12 +194,24 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - Plate selection for multi-plate 3MF files
 - Duplicate detection via file hash
 - Mobile-friendly with always-visible action buttons
+- **Server-side Slice button** (optional) — slice STL/3MF without a desktop slicer when the [`slicer-api/` Compose stack](slicer-api/README.md) is running; the result lands as a new `.gcode.3mf` in the same folder, with progress shown via a toast tracker that follows the job to completion. Supports importing **Bambu Studio Printer Preset Bundles** (`.bbscfg`) so a curated printer + process + filament triplet can be picked in the Slice dialog without re-uploading JSON profiles ([details](https://wiki.bambuddy.cool/features/slicer-api/#slicer-bundles-bbscfg))
+
+### 🌍 MakerWorld Integration
+- Paste any `makerworld.com/models/…` URL → preview, plate picker, and import without leaving Bambuddy
+- Per-plate **Save** or **Save & Slice in Bambu Studio / OrcaSlicer** (your preferred slicer from Settings)
+- **Import all plates** button for multi-plate models
+- Auto-creates a "MakerWorld" folder in File Manager; override with any existing folder via the picker
+- Per-plate image gallery with keyboard-navigable lightbox
+- Recent imports sidebar — last 10 MakerWorld imports with one-click jump to File Manager or slicer
+- Remove-from-library for imported plates with confirm modal (no LAN cookie paste, no browser extension)
+- Reuses your existing Bambu Cloud login — no separate OAuth flow or browser extension to install
 
 ### 📁 Projects
 - Group related prints (e.g., "Voron Build")
 - Track plates (print jobs) and parts separately
 - Auto-detect parts count from 3MF files
 - Color-coded project badges
+- **Project URL + cover photo** — paste a MakerWorld/Printables/Thingiverse link and upload a hero image so each card is immediately recognisable; the URL renders as a one-click link beside the project name
 - Bulk assign archives via multi-select toolbar
 - Import/Export projects as ZIP (includes files) or JSON
 - Print or queue files from linked library folders directly in the project view (resulting archive auto-linked to the project)
@@ -207,7 +221,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 
 ### 🔔 Notifications
 - WhatsApp, Telegram, Discord
-- Email, Pushover, ntfy
+- Email, Pushover, ntfy (with per-event priority — Min / Low / Default / High / Urgent)
 - Home Assistant persistent notifications
 - Custom webhooks
 - Quiet hours & daily digest
@@ -229,6 +243,8 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - **Per-spool cost tracking** — Set cost/kg on each spool; costs are automatically calculated at print completion and aggregated to archives. Print modal shows real-time cost preview. Configurable default cost and currency in Settings.
 - **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight.
 - Spool catalog, color catalog, PA profile matching, and low-stock alerts
+- **Multi-colour gradients, transparency, and visual effects** — Paste a comma-separated hex list (e.g. from 3dfilamentprofiles.com) to render a spool as a gradient or conic colour wheel; transparency shows through a checkerboard so the alpha you set is the alpha you see; pick a visual effect (sparkle, wood, marble, glow, matte) for the swatch overlay. Same fields are editable on the colour catalog so combos can be reused across spools.
+- **Printable spool labels** — Generate PDF labels for any selection of spools in four pre-built sizes: AMS holder (30×15 mm), box label (62×29 mm), Avery L7160 sheet (A4, 21 per page), and Avery 5160 sheet (US Letter, 30 per page). Each label shows the colour swatch, brand, material, name, the **spool ID** (for at-a-glance identification across many similar spools), and a QR code that deep-links straight back to the spool's row in Bambuddy when scanned with a phone. Pick from the inventory page — search, filter by material, multi-select spools, then print or save to PDF.
 
 ### 🔧 Integrations
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display
@@ -241,14 +257,18 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - **Scheduled local backups** - Automatic backup snapshots on hourly/daily/weekly schedule with retention management and NAS-mountable output
 - External sidebar links
 - Webhooks & API keys
+  - Per-user ownership — each key acts on behalf of its creator
+  - Optional **cloud-access scope** — opt in to let an API key read its owner's Bambu Cloud presets / filament catalogue / device list (off by default)
 - Interactive API browser with live testing
 
 ### 🖨️ Virtual Printer & Remote Printing
-- **🌐 Proxy Mode (NEW!)** — Print remotely from anywhere via secure TLS relay
+- **🌐 Proxy Mode** — Print remotely from anywhere via secure TLS relay
+- **🪞 Live target-printer mirror in non-proxy modes (NEW!)** — Immediate / Review / Queue VPs now mirror their target printer's live state to the slicer: AMS slot contents, FTS / dual-extruder routing, k-profiles, AMS load / dry / calibration commands, and the camera stream all flow through the VP. Use the slicer as a full remote for the printer behind the VP without giving up Bambuddy's queue / archive / dispatch features.
 - Emulates a Bambu Lab printer on your network
 - Send prints directly from Bambu Studio/Orca Slicer
 - Configurable printer model (X1C, P1S, A1, H2D, etc.)
 - Archive mode, Review mode, Queue mode, or Proxy mode
+- Queue mode: optional **force-color-match** so the scheduler refuses to dispatch onto a printer with the wrong filament loaded
 - SSDP discovery (same LAN) or manual IP entry (VPN/remote)
 - Network interface override for multi-NIC/Docker/VPN setups
 - Secure TLS/MQTT/FTP communication

+ 10 - 9
UPDATING.md

@@ -1,9 +1,8 @@
 # Updating Bambuddy
 
-> **One-time note for 0.2.2.x → 0.2.3:** the in-app **Update** button does not
-> reliably perform this specific migration. Do this one upgrade from the
-> command line using the steps below. Once you're on 0.2.3, the in-app Update
-> button works normally again for all future releases.
+> **0.2.3 note:** the in-app **Update** button is unreliable when upgrading from
+> older releases. Use the commands below instead — they cover every supported
+> install path and are safe to run repeatedly.
 
 Pick the section that matches how Bambuddy was installed.
 
@@ -75,7 +74,10 @@ These installs have no `.git` directory, so neither `update.sh` nor a plain
 
 ```bash
 # 1. Back up your stateful data
-Create and download a backup via Bambuddy Settings -> Backup -> Local Backup
+sudo systemctl stop bambuddy
+sudo tar czf ~/bambuddy-backup.tgz -C /opt/bambuddy \
+  data bambuddy.db bambuddy.db-shm bambuddy.db-wal \
+  virtual_printer archive projects icons .env 2>/dev/null || true
 
 # 2. Remove the old install and reinstall via install.sh
 sudo rm -rf /opt/bambuddy
@@ -83,10 +85,9 @@ curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/insta
   -o /tmp/install.sh && sudo bash /tmp/install.sh --path /opt/bambuddy
 
 # 3. Restore your data
-Restore your backup via Bambuddy -> Settings -> Backup -> Local Backup
-
-# 4. Restart Bambuddy
-sudo systemctl restart bambuddy
+sudo systemctl stop bambuddy
+sudo tar xzf ~/bambuddy-backup.tgz -C /opt/bambuddy
+sudo systemctl start bambuddy
 ```
 
 ---

+ 326 - 0
backend/app/api/routes/_spoolman_helpers.py

@@ -0,0 +1,326 @@
+"""Pure helper functions for Spoolman spool mapping.
+
+No heavy dependencies — importable in unit tests without the full backend stack.
+"""
+
+from __future__ import annotations
+
+import ipaddress
+import json
+import logging
+import math
+import re
+from typing import Any
+from urllib.parse import urlparse
+
+from typing_extensions import TypedDict
+
+logger = logging.getLogger(__name__)
+
+
+class MappedSpoolFields(TypedDict):
+    """Full shape of the dict returned by _map_spoolman_spool (InventorySpool-compatible)."""
+
+    id: int
+    material: str | None
+    subtype: str | None
+    brand: str | None
+    color_name: str | None
+    rgba: str | None
+    label_weight: int | None
+    core_weight: int | None
+    core_weight_catalog_id: None
+    weight_used: float | None
+    weight_locked: bool
+    last_scale_weight: None
+    last_weighed_at: None
+    slicer_filament: None
+    slicer_filament_name: str | None
+    nozzle_temp_min: int | None
+    nozzle_temp_max: None
+    note: str | None
+    added_full: None
+    last_used: str | None
+    encode_time: str | None
+    tag_uid: str | None
+    tray_uuid: str | None
+    data_origin: str | None
+    tag_type: str | None
+    archived_at: str | None
+    created_at: str | None  # None when Spoolman spool has no registered timestamp
+    updated_at: str | None
+    cost_per_kg: float | None
+    storage_location: str | None
+    k_profiles: list[Any]
+
+
+class NormalizedVendorRef(TypedDict):
+    """Vendor reference embedded in a NormalizedFilament."""
+
+    id: int
+    name: str
+
+
+class NormalizedFilament(TypedDict):
+    """Normalised Spoolman filament dict returned by the /filaments catalog endpoint."""
+
+    id: int
+    name: str
+    material: str | None
+    color_hex: str | None
+    color_name: str | None
+    weight: int | None
+    spool_weight: float | None
+    vendor: NormalizedVendorRef | None
+
+
+_CLOUD_METADATA_IPS = frozenset(
+    {
+        # AWS / GCP / Azure / Oracle / DigitalOcean IMDS
+        ipaddress.ip_address("169.254.169.254"),
+        # Alibaba Cloud metadata
+        ipaddress.ip_address("100.100.100.200"),
+        # AWS IMDS IPv6
+        ipaddress.ip_address("fd00:ec2::254"),
+    }
+)
+
+
+def assert_safe_spoolman_url(url: str) -> None:
+    """Raise ValueError if *url* should be blocked as an SSRF risk.
+
+    Bambuddy is typically deployed on a home LAN alongside Spoolman, so
+    loopback (127.0.0.1) and RFC-1918 private ranges (192.168.x.x, 10.x.x.x,
+    172.16-31.x) must be permitted — they are THE normal Spoolman topology.
+    This guard therefore targets the genuinely dangerous cases only.
+
+    Checks performed:
+    - Scheme must be http or https (no file://, gopher://, dict://, etc.).
+    - Numeric-encoded IP addresses in decimal (e.g. ``2130706433``) or hex
+      (e.g. ``0x7f000001``) are rejected. Python's ``ipaddress`` module raises
+      ``ValueError`` for these forms so they would otherwise bypass the
+      explicit-IP block below, but libc (and browsers) resolve them as valid
+      IPv4 addresses.
+    - Cloud provider metadata endpoints (169.254.169.254, 100.100.100.200,
+      fd00:ec2::254) are blocked — the classic SSRF credential-exfil target.
+    - Multicast (224.0.0.0/4, ff00::/8) and unspecified (0.0.0.0, ::) addresses
+      are blocked — pointless as a destination and suggests misuse.
+    - IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are unwrapped so they cannot
+      bypass the checks above.
+
+    Hostname-based addresses ("localhost", "spoolman.lan", "internal.corp")
+    are out of scope — DNS resolution is deliberately not performed here.
+    """
+    parsed = urlparse(url)
+    if parsed.scheme.lower() not in ("http", "https"):
+        raise ValueError("Spoolman URL must use http or https")
+
+    hostname = (parsed.hostname or "").lower()
+
+    # Reject decimal- and hex-encoded IPs (e.g. http://2130706433/ or
+    # http://0x7f000001/). These slip past ipaddress.ip_address() but libc
+    # (and browsers) parse them as IPv4 — an obvious bypass if not caught.
+    if re.match(r"^(0x[0-9a-f]+|[0-9]+)$", hostname, re.I):
+        raise ValueError("Spoolman URL must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
+
+    try:
+        addr = ipaddress.ip_address(hostname)
+    except ValueError:
+        # Not a bare IP address — includes intentional cases such as "localhost" and
+        # RFC-1918 hostnames ("spoolman.lan", "192.168.1.10" would be caught above as
+        # a dotted-decimal IP; symbolic names resolve via DNS which is out of scope).
+        # Running Spoolman on the same host or home LAN is the standard Bambuddy
+        # topology, so loopback and private ranges are deliberately NOT blocked here.
+        return
+
+    # Unwrap IPv4-mapped IPv6 (::ffff:169.254.169.254 etc.) so attackers can't
+    # encode a blocked IPv4 into an IPv6 literal to bypass the check.
+    effective: ipaddress.IPv4Address | ipaddress.IPv6Address = addr
+    if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
+        effective = addr.ipv4_mapped
+
+    if effective in _CLOUD_METADATA_IPS:
+        raise ValueError("Spoolman URL must not point to a cloud metadata endpoint")
+
+    if effective.is_multicast or effective.is_unspecified:
+        raise ValueError("Spoolman URL must not point to a multicast or unspecified address")
+
+
+_COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")
+_TAG_HEX_RE = re.compile(r"^[0-9A-F]+$")
+
+
+def _safe_int(value: object, fallback: int) -> int:
+    """Convert value to int, returning fallback for None/NaN/Inf/non-numeric."""
+    try:
+        f = float(value)  # type: ignore[arg-type]
+        if math.isfinite(f):
+            return int(f)
+    except (TypeError, ValueError):
+        pass
+    return fallback
+
+
+def _safe_float(value: object, fallback: float) -> float:
+    """Convert value to float, returning fallback for None/NaN/Inf/non-numeric."""
+    try:
+        f = float(value)  # type: ignore[arg-type]
+        if math.isfinite(f):
+            return f
+    except (TypeError, ValueError):
+        pass
+    return fallback
+
+
+def _safe_optional_float(value: object) -> float | None:
+    """Convert value to finite float, or None if missing/NaN/Infinite/non-numeric.
+
+    Used for optional monetary fields (price) to prevent Infinity/NaN from
+    reaching JSON serialisation, which raises ValueError with allow_nan=False.
+    """
+    if value is None:
+        return None
+    try:
+        f = float(value)  # type: ignore[arg-type]
+        if math.isfinite(f):
+            return f
+    except (TypeError, ValueError):
+        pass
+    return None
+
+
+def _extract_extra_str(extra: dict, key: str) -> str:
+    """Extract a JSON-encoded string from a Spoolman extra dict.
+
+    Spoolman stores extra values as JSON-stringified text — a stored string
+    "GFL05" appears as `'"GFL05"'` (six chars including the quotes). This
+    unwraps that, returning the bare string. Returns "" for missing keys,
+    non-strings, or invalid JSON.
+    """
+    raw = extra.get(key)
+    if not isinstance(raw, str):
+        return ""
+    try:
+        decoded = json.loads(raw)
+    except (json.JSONDecodeError, ValueError):
+        # Tolerate bare-string values written without JSON encoding.
+        return raw
+    return decoded if isinstance(decoded, str) else ""
+
+
+def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
+    """Convert a raw Spoolman spool dict to the InventorySpool-compatible format.
+
+    Fields not supported by Spoolman (k_profiles, slicer_filament, …) are
+    returned as None / empty so the frontend can still render them without
+    errors.  The ``data_origin`` field is set to ``"spoolman"`` so UI code can
+    distinguish these spools from local ones.
+    """
+    raw_id = spool.get("id")
+    if raw_id is None:
+        raise ValueError("Spoolman spool is missing required 'id' field")
+    try:
+        spool_id: int = int(raw_id)
+    except (TypeError, ValueError):
+        raise ValueError(f"Spoolman spool 'id' is not a valid integer: {raw_id!r}")
+    if spool_id <= 0:
+        raise ValueError(f"Spoolman spool 'id' must be a positive integer, got {spool_id}")
+
+    filament: dict = spool.get("filament") or {}
+    if not filament:
+        logger.warning(
+            "Spoolman spool %s has no filament data — all filament fields will use defaults",
+            spool_id,
+        )
+    vendor: dict = filament.get("vendor") or {}
+    extra: dict = spool.get("extra") or {}
+
+    # RFID tag stored as JSON-encoded string in Spoolman extra.tag.
+    # 32-char hex → Bambu Lab tray UUID; 8–30-char hex → NFC tag UID.
+    # Accepting the full realistic UID range (4-byte = 8 chars, 7-byte = 14 chars,
+    # 10-byte = 20 chars) avoids silently dropping valid SpoolBuddy-written tags.
+    raw_tag: str = (extra.get("tag") or "").strip('"').upper()
+    _raw_is_hex = bool(_TAG_HEX_RE.match(raw_tag))
+    tag_uid = raw_tag if _raw_is_hex and 8 <= len(raw_tag) <= 30 else None
+    tray_uuid = raw_tag if _raw_is_hex and len(raw_tag) == 32 else None
+
+    # Subtype = filament name with material prefix stripped
+    material: str = (filament.get("material") or "").strip()
+    filament_name: str = (filament.get("name") or "").strip()
+    if material and filament_name.upper().startswith(material.upper()):
+        subtype: str | None = filament_name[len(material) :].strip() or None
+    else:
+        subtype = filament_name or None
+
+    # Colour: validate as 6-char hex; fall back to neutral grey for invalid values
+    raw_color = (filament.get("color_hex") or "").upper().removeprefix("#")
+    color_hex: str = raw_color if _COLOR_HEX_RE.match(raw_color) else "808080"
+    rgba: str = color_hex + "FF"
+
+    label_weight: int = _safe_int(filament.get("weight"), 1000)
+    used_weight: float = _safe_float(spool.get("used_weight"), 0.0)
+
+    # Archived state – Spoolman uses a boolean ``archived`` field
+    archived: bool = spool.get("archived", False)
+    archived_at: str | None = None
+    if archived:
+        archived_at = spool.get("last_used") or spool.get("registered") or None
+
+    created_at: str | None = spool.get("registered") or None
+
+    # Spoolman doesn't standardise a `color_name` field — most installs only
+    # populate `color_hex` (the swatch) and the filament's `name` (which often
+    # carries the colour, e.g. "PLA Basic Red"). Without a fallback the
+    # frontend lists a sea of "Unknown color" entries that all look identical
+    # except for the swatch. Fall back to the filament name minus material
+    # prefix (the same string the `subtype` field already carries — typically
+    # "Basic Red" / "PLA+ Black" / etc.) so the user can tell spools apart at
+    # a glance even on Spoolman installs that don't fill color_name.
+    color_name: str | None = filament.get("color_name") or subtype or None
+
+    nozzle_temp_raw = filament.get("settings_extruder_temp")
+    nozzle_temp_min: int | None = _safe_int(nozzle_temp_raw, 0) or None
+
+    return {
+        "id": spool_id,
+        "material": material,
+        "subtype": subtype,
+        "color_name": color_name,
+        "rgba": rgba,
+        "brand": vendor.get("name") or None,
+        "label_weight": label_weight,
+        "core_weight": _safe_int(
+            spool.get("spool_weight") if spool.get("spool_weight") is not None else filament.get("spool_weight"), 250
+        ),
+        "core_weight_catalog_id": None,
+        "weight_used": used_weight,
+        "weight_locked": False,
+        "last_scale_weight": None,
+        "last_weighed_at": None,
+        # BambuStudio slicer preset — Spoolman has no native field, so the
+        # update endpoint persists these under bambu_slicer_filament[_name]
+        # in the spool's extra dict. Values are JSON-encoded strings; an
+        # empty string ("") means cleared. Falls back to Spoolman's
+        # filament_name for slicer_filament_name when nothing is stored.
+        "slicer_filament": (_extract_extra_str(extra, "bambu_slicer_filament") or None),
+        "slicer_filament_name": (_extract_extra_str(extra, "bambu_slicer_filament_name") or (filament_name or None)),
+        "nozzle_temp_min": nozzle_temp_min,
+        "nozzle_temp_max": None,
+        "note": spool.get("comment") or None,
+        "added_full": None,
+        "last_used": spool.get("last_used"),
+        # encode_time semantics differ: local records NFC write time; Spoolman first_used
+        # records first print use — different events; using first_used as best available proxy.
+        "encode_time": spool.get("first_used"),
+        "tag_uid": tag_uid,
+        "tray_uuid": tray_uuid,
+        "data_origin": "spoolman",
+        "tag_type": "spoolman",
+        "archived_at": archived_at,
+        "created_at": created_at,
+        # Spoolman has no updated_at field; use registered timestamp as best available proxy
+        "updated_at": created_at,
+        "cost_per_kg": _safe_optional_float(spool.get("price")),
+        "storage_location": spool.get("location") or None,
+        "k_profiles": [],
+    }

+ 24 - 1
backend/app/api/routes/api_keys.py

@@ -35,13 +35,23 @@ async def list_api_keys(
 async def create_api_key(
     data: APIKeyCreate,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
 ):
     """Create a new API key.
 
     IMPORTANT: The full API key is only returned in this response.
     Store it securely - it cannot be retrieved again.
     """
+    # Reject can_access_cloud on auth-disabled deployments — there's no per-user
+    # cloud_token to read against, so the flag would just silently do nothing.
+    # Surfacing the rejection at create time prevents the user from thinking
+    # they've configured cloud access when they actually haven't.
+    if data.can_access_cloud and current_user is None:
+        raise HTTPException(
+            status_code=400,
+            detail="can_access_cloud requires authentication to be enabled (per-user cloud tokens)",
+        )
+
     # Generate the key
     full_key, key_hash, key_prefix = generate_api_key()
 
@@ -49,9 +59,11 @@ async def create_api_key(
         name=data.name,
         key_hash=key_hash,
         key_prefix=key_prefix,
+        user_id=current_user.id if current_user else None,
         can_queue=data.can_queue,
         can_control_printer=data.can_control_printer,
         can_read_status=data.can_read_status,
+        can_access_cloud=data.can_access_cloud,
         printer_ids=data.printer_ids,
         expires_at=data.expires_at,
     )
@@ -65,9 +77,11 @@ async def create_api_key(
         name=api_key.name,
         key_prefix=api_key.key_prefix,
         key=full_key,  # Only returned on creation
+        user_id=api_key.user_id,
         can_queue=api_key.can_queue,
         can_control_printer=api_key.can_control_printer,
         can_read_status=api_key.can_read_status,
+        can_access_cloud=api_key.can_access_cloud,
         printer_ids=api_key.printer_ids,
         enabled=api_key.enabled,
         last_used=api_key.last_used,
@@ -115,6 +129,15 @@ async def update_api_key(
         api_key.can_control_printer = data.can_control_printer
     if data.can_read_status is not None:
         api_key.can_read_status = data.can_read_status
+    if data.can_access_cloud is not None:
+        # Same constraint as create — flipping cloud access on a legacy key
+        # without an owner would be silently broken; reject at the route layer.
+        if data.can_access_cloud and api_key.user_id is None:
+            raise HTTPException(
+                status_code=400,
+                detail="can_access_cloud requires the API key to have an owner; recreate the key after upgrading",
+            )
+        api_key.can_access_cloud = data.can_access_cloud
     if data.printer_ids is not None:
         api_key.printer_ids = data.printer_ids
     if data.enabled is not None:

+ 81 - 0
backend/app/api/routes/archive_purge.py

@@ -0,0 +1,81 @@
+"""Archive auto-purge endpoints (#1008 follow-up).
+
+Admin-only (``ARCHIVES_PURGE``). Provides:
+
+* ``GET /archives/purge/preview`` — live count for the admin slider
+* ``POST /archives/purge`` — one-shot manual bulk delete
+* ``GET/PUT /archives/purge/settings`` — auto-purge toggle + threshold
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import require_permission_if_auth_enabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.schemas.archive_purge import (
+    ArchivePurgePreviewResponse,
+    ArchivePurgeRequest,
+    ArchivePurgeResponse,
+    ArchivePurgeSettings,
+)
+from backend.app.services.archive_purge import (
+    MAX_AUTO_PURGE_DAYS,
+    MIN_AUTO_PURGE_DAYS,
+    archive_purge_service,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/archives", tags=["archives-purge"])
+
+
+@router.get("/purge/preview", response_model=ArchivePurgePreviewResponse)
+async def preview_archive_purge(
+    older_than_days: int = Query(ge=1, le=3650),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
+):
+    """Count + size of archives eligible for purge. Read-only."""
+    result = await archive_purge_service.preview_purge(db, older_than_days=older_than_days)
+    return ArchivePurgePreviewResponse(**result)
+
+
+@router.post("/purge", response_model=ArchivePurgeResponse)
+async def execute_archive_purge(
+    body: ArchivePurgeRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
+):
+    """Hard-delete archives older than the threshold. Irreversible."""
+    deleted = await archive_purge_service.purge_older_than(db, older_than_days=body.older_than_days)
+    return ArchivePurgeResponse(deleted=deleted)
+
+
+@router.get("/purge/settings", response_model=ArchivePurgeSettings)
+async def get_archive_purge_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
+):
+    cfg = await archive_purge_service.get_settings(db)
+    return ArchivePurgeSettings(enabled=cfg["enabled"], days=cfg["days"])
+
+
+@router.put("/purge/settings", response_model=ArchivePurgeSettings)
+async def update_archive_purge_settings(
+    body: ArchivePurgeSettings,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
+):
+    if body.days < MIN_AUTO_PURGE_DAYS or body.days > MAX_AUTO_PURGE_DAYS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"days must be between {MIN_AUTO_PURGE_DAYS} and {MAX_AUTO_PURGE_DAYS}",
+        )
+    saved = await archive_purge_service.set_settings(db, enabled=body.enabled, days=body.days)
+    return ArchivePurgeSettings(enabled=saved["enabled"], days=saved["days"])

+ 264 - 7
backend/app/api/routes/archives.py

@@ -25,8 +25,14 @@ from backend.app.models.filament import Filament
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
+from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
-from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
+from backend.app.utils.http import build_content_disposition
+from backend.app.utils.threemf_tools import (
+    extract_nozzle_mapping_from_3mf,
+    extract_project_filaments_from_3mf,
+    extract_source_printer_model_from_3mf,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -122,6 +128,7 @@ def archive_to_response(
         "total_layers": archive.total_layers,
         "nozzle_diameter": archive.nozzle_diameter,
         "bed_temperature": archive.bed_temperature,
+        "bed_type": archive.bed_type,
         "nozzle_temperature": archive.nozzle_temperature,
         "sliced_for_model": archive.sliced_for_model,
         "status": archive.status,
@@ -628,7 +635,7 @@ async def export_archives(
     return StreamingResponse(
         io.BytesIO(file_bytes),
         media_type=content_type,
-        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+        headers={"Content-Disposition": build_content_disposition(filename)},
     )
 
 
@@ -667,7 +674,7 @@ async def export_stats(
     return StreamingResponse(
         io.BytesIO(file_bytes),
         media_type=content_type,
-        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+        headers={"Content-Disposition": build_content_disposition(filename)},
     )
 
 
@@ -1214,6 +1221,8 @@ async def rescan_archive(
         archive.nozzle_diameter = metadata["nozzle_diameter"]
     if metadata.get("bed_temperature"):
         archive.bed_temperature = metadata["bed_temperature"]
+    if metadata.get("bed_type"):
+        archive.bed_type = metadata["bed_type"]
     if metadata.get("nozzle_temperature"):
         archive.nozzle_temperature = metadata["nozzle_temperature"]
     if metadata.get("makerworld_url"):
@@ -2338,10 +2347,11 @@ async def get_qrcode(
     pil_img.save(buffer, format="PNG")
     buffer.seek(0)
 
+    qr_filename = f"qr_{archive.print_name or archive_id}.png"
     return Response(
         content=buffer.getvalue(),
         media_type="image/png",
-        headers={"Content-Disposition": f'inline; filename="qr_{archive.print_name or archive_id}.png"'},
+        headers={"Content-Disposition": build_content_disposition(qr_filename, disposition="inline")},
     )
 
 
@@ -2568,10 +2578,17 @@ async def get_archive_capabilities(
 @router.get("/{archive_id}/gcode")
 async def get_gcode(
     archive_id: int,
+    plate: int | None = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
-    """Extract and return G-code from the 3MF file."""
+    """Extract and return G-code from the 3MF file.
+
+    When *plate* is provided, returns the G-code for that specific plate
+    (e.g. ``?plate=2`` returns ``Metadata/plate_2.gcode``). If omitted, falls
+    back to the first plate found in the archive (preserving the original
+    behaviour for callers that predate the multi-plate viewer).
+    """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     if not archive:
@@ -2581,6 +2598,9 @@ async def get_gcode(
     if not file_path.is_file():
         raise HTTPException(404, "File not found")
 
+    if plate is not None and plate < 1:
+        raise HTTPException(400, "Plate index must be >= 1")
+
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
             # Bambu 3MF files store G-code in Metadata/plate_X.gcode
@@ -2591,8 +2611,28 @@ async def get_gcode(
                     "No G-code found. This file hasn't been sliced yet - G-code is only available after slicing in Bambu Studio.",
                 )
 
-            # Get the first plate's G-code (usually plate_1.gcode)
-            gcode_content = zf.read(gcode_files[0]).decode("utf-8")
+            if plate is not None:
+                # Resolve plate → filename via the same parsing the plates
+                # endpoint uses (int() on the suffix), so zero-padded names
+                # like plate_01.gcode are found when the plates endpoint
+                # reported index 1.
+                selected = None
+                for gf in gcode_files:
+                    if not gf.startswith("Metadata/plate_"):
+                        continue
+                    suffix = gf[len("Metadata/plate_") : -len(".gcode")]
+                    try:
+                        if int(suffix) == plate:
+                            selected = gf
+                            break
+                    except ValueError:
+                        continue
+                if selected is None:
+                    raise HTTPException(404, f"Plate {plate} not found in this archive")
+            else:
+                selected = gcode_files[0]
+
+            gcode_content = zf.read(selected).decode("utf-8")
             return Response(content=gcode_content, media_type="text/plain")
     except zipfile.BadZipFile:
         raise HTTPException(400, "Invalid 3MF file")
@@ -2788,6 +2828,10 @@ async def get_archive_plates(
         raise HTTPException(404, "Archive file not found")
 
     plates = []
+    # Initialize so the `has_gcode = bool(gcode_files)` after the try/except
+    # never raises NameError when the archive isn't a valid zip (e.g. plain
+    # .gcode file from a sliced-archive flow that didn't request 3MF output).
+    gcode_files: list[str] = []
 
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
@@ -3028,11 +3072,25 @@ async def get_archive_plates(
     except Exception as e:
         logger.warning("Failed to parse plates from archive %s: %s", archive_id, e)
 
+    # Has gcode iff the plate list was built from .gcode filenames (as opposed
+    # to the JSON/PNG fallback for source-only 3MF projects). Callers that need
+    # to preview gcode — the viewer, skip-objects — can gate on this instead of
+    # 404-ing on every plate request.
+    has_gcode = bool(gcode_files)
+    # SliceModal pre-check signal — see library.py for rationale.
+    source_printer_model: str | None = None
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            source_printer_model = extract_source_printer_model_from_3mf(zf)
+    except (zipfile.BadZipFile, OSError):
+        pass
     return {
         "archive_id": archive_id,
         "filename": archive.filename,
         "plates": plates,
         "is_multi_plate": len(plates) > 1,
+        "has_gcode": has_gcode,
+        "source_printer_model": source_printer_model,
     }
 
 
@@ -3068,10 +3126,72 @@ async def get_plate_thumbnail(
     raise HTTPException(404, f"Thumbnail for plate {plate_index} not found")
 
 
+async def _try_preview_slice_filaments(
+    db: AsyncSession,
+    *,
+    kind: str,
+    source_id: int,
+    plate_id: int,
+    file_path: Path,
+    request_id: str | None = None,
+    bundle_id: str | None = None,
+    printer_name: str | None = None,
+    process_name: str | None = None,
+    filament_names: list[str] | None = None,
+) -> list[dict] | None:
+    """Run a preview slice via the user's configured sidecar so the filament
+    list endpoint can return real per-plate filaments for unsliced project
+    files. Returns ``None`` on any failure — the caller falls back to the
+    painted-face heuristic. ``request_id`` flows through to the sidecar
+    for live progress on the SliceModal's inline spinner + toast.
+
+    Bundle context (id + preset names) is forwarded to the preview helper
+    so the preview can mirror the real-print profile triplet when supplied
+    — see ``slice_preview.get_preview_filaments`` for the full contract.
+    """
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.services.slice_preview import get_preview_filaments
+
+    preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
+    if preferred == "orcaslicer":
+        configured = await get_setting(db, "orcaslicer_api_url")
+        api_url = (configured or settings.slicer_api_url).strip()
+    elif preferred == "bambu_studio":
+        configured = await get_setting(db, "bambu_studio_api_url")
+        api_url = (configured or settings.bambu_studio_api_url).strip()
+    else:
+        return None
+    if not api_url:
+        return None
+
+    try:
+        file_bytes = file_path.read_bytes()
+    except OSError:
+        return None
+    return await get_preview_filaments(
+        kind=kind,
+        source_id=source_id,
+        plate_id=plate_id,
+        file_bytes=file_bytes,
+        file_name=file_path.name,
+        api_url=api_url,
+        request_id=request_id,
+        bundle_id=bundle_id,
+        printer_name=printer_name,
+        process_name=process_name,
+        filament_names=filament_names,
+    )
+
+
 @router.get("/{archive_id}/filament-requirements")
 async def get_filament_requirements(
     archive_id: int,
     plate_id: int | None = None,
+    request_id: str | None = None,
+    bundle_id: str | None = None,
+    printer_name: str | None = None,
+    process_name: str | None = None,
+    filament_names: str | None = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
@@ -3083,6 +3203,12 @@ async def get_filament_requirements(
     Args:
         archive_id: The archive ID
         plate_id: Optional plate index to filter filaments for (for multi-plate files)
+        bundle_id / printer_name / process_name / filament_names: Optional
+            bundle context. When all four are supplied, the preview slice
+            (run for unsliced project files) uses ``slice_with_bundle``
+            against the named preset triplet instead of the embedded-
+            settings fallback. ``filament_names`` is comma- or semicolon-
+            separated.
     """
     import defusedxml.ElementTree as ET
 
@@ -3142,6 +3268,7 @@ async def get_filament_requirements(
                                             "used_grams": round(used_grams, 1),
                                             "used_meters": float(used_m) if used_m else 0,
                                             "tray_info_idx": tray_info_idx,
+                                            "used_in_plate": True,
                                         }
                                     )
                             break
@@ -3172,9 +3299,43 @@ async def get_filament_requirements(
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                     "tray_info_idx": tray_info_idx,
+                                    "used_in_plate": True,
                                 }
                             )
 
+            # Unsliced project files: see library.py for full rationale.
+            # Return the FULL project_settings.config slot list with a
+            # used_in_plate flag derived from the preview slice; the
+            # CLI needs every slot pre-filled to avoid silent default
+            # substitution.
+            if not filaments:
+                project_filaments = extract_project_filaments_from_3mf(zf)
+                used_slot_ids: set[int] = set()
+                if project_filaments and plate_id is not None:
+                    parsed_filament_names: list[str] | None = None
+                    if filament_names:
+                        parsed_filament_names = [
+                            n.strip() for n in filament_names.replace(";", ",").split(",") if n.strip()
+                        ] or None
+                    preview = await _try_preview_slice_filaments(
+                        db,
+                        kind="archive",
+                        source_id=archive_id,
+                        plate_id=plate_id,
+                        file_path=file_path,
+                        request_id=request_id,
+                        bundle_id=bundle_id,
+                        printer_name=printer_name,
+                        process_name=process_name,
+                        filament_names=parsed_filament_names,
+                    )
+                    if preview is not None:
+                        used_slot_ids = {f["slot_id"] for f in preview}
+                fallback_all_used = not used_slot_ids
+                for f in project_filaments:
+                    f["used_in_plate"] = fallback_all_used or f["slot_id"] in used_slot_ids
+                filaments = project_filaments
+
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
 
@@ -3195,6 +3356,102 @@ async def get_filament_requirements(
     }
 
 
+@router.post("/{archive_id}/slice", status_code=202)
+async def slice_archive(
+    archive_id: int,
+    request: SliceRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+):
+    """Enqueue a slice job for an archive's source. Returns 202 + job_id;
+    the slice runs in the background, the caller polls `GET /slice-jobs/{id}`.
+
+    Source preference: ``source_3mf_path`` (the un-sliced project file the
+    user originally sent to slice) → ``file_path`` (the sliced 3MF/gcode that
+    actually printed).
+    """
+    from backend.app.api.routes.library import slice_and_persist_as_archive
+    from backend.app.core.database import async_session
+    from backend.app.services.slice_dispatch import (
+        http_exception_to_job_error,
+        slice_dispatch,
+    )
+
+    archive = await db.get(PrintArchive, archive_id)
+    if archive is None:
+        raise HTTPException(status_code=404, detail="Archive not found")
+
+    src_relative = archive.source_3mf_path or archive.file_path
+    if not src_relative:
+        raise HTTPException(
+            status_code=400,
+            detail="Archive has no source file to slice",
+        )
+
+    src_path = Path(settings.base_dir) / src_relative
+    if not src_path.exists():
+        raise HTTPException(status_code=404, detail="Archive source file missing on disk")
+
+    raw_filename = archive.filename or src_path.name
+    src_lower = raw_filename.lower()
+    if not (
+        src_lower.endswith(".stl")
+        or src_lower.endswith(".3mf")
+        or src_lower.endswith(".step")
+        or src_lower.endswith(".stp")
+    ):
+        raise HTTPException(
+            status_code=400,
+            detail="Archive's source file must be STL, 3MF, or STEP to slice",
+        )
+
+    # Match the library route: derive the sliced output's filename from
+    # `print_name` when set, so the new archive row's display name lines
+    # up with the source's display.
+    src_ext = Path(raw_filename).suffix.lower() or ".3mf"
+    src_filename = (
+        f"{archive.print_name.strip()}{src_ext}" if archive.print_name and archive.print_name.strip() else raw_filename
+    )
+
+    model_bytes = src_path.read_bytes()
+    archive_id_local = archive.id
+    user_id = current_user.id if current_user else None
+
+    async def _run(job_id: int):
+        async with async_session() as task_db:
+            # Re-fetch the source archive on the background-task session.
+            src_archive = await task_db.get(PrintArchive, archive_id_local)
+            if src_archive is None:
+                raise http_exception_to_job_error(
+                    HTTPException(status_code=404, detail="Archive disappeared during slice")
+                )
+            try:
+                response = await slice_and_persist_as_archive(
+                    task_db,
+                    model_bytes=model_bytes,
+                    model_filename=src_filename,
+                    request=request,
+                    source_archive=src_archive,
+                    current_user_id=user_id,
+                    job_id=job_id,
+                )
+            except HTTPException as exc:
+                raise http_exception_to_job_error(exc) from exc
+        return response.model_dump()
+
+    job = await slice_dispatch.enqueue(
+        kind="archive",
+        source_id=archive.id,
+        source_name=archive.print_name or archive.filename or f"archive {archive.id}",
+        run=_run,
+    )
+    return {
+        "job_id": job.id,
+        "status": job.status,
+        "status_url": f"/api/v1/slice-jobs/{job.id}",
+    }
+
+
 @router.post("/{archive_id}/reprint")
 async def reprint_archive(
     archive_id: int,

+ 310 - 0
backend/app/api/routes/auth.py

@@ -9,6 +9,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException,
 from fastapi.security import HTTPAuthorizationCredentials
 from jwt.exceptions import PyJWTError
 from sqlalchemy import delete, select
+from sqlalchemy.exc import SQLAlchemyError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -39,6 +40,8 @@ from backend.app.models.group import Group
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.schemas.auth import (
+    EncryptionRowCounts,
+    EncryptionStatusResponse,
     ForgotPasswordConfirmRequest,
     ForgotPasswordRequest,
     ForgotPasswordResponse,
@@ -53,6 +56,7 @@ from backend.app.schemas.auth import (
     TestSMTPRequest,
     TestSMTPResponse,
     UserResponse,
+    _validate_password_complexity,
 )
 from backend.app.services.email_service import (
     create_password_reset_link_email_from_template,
@@ -228,6 +232,17 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
                         detail="Admin username and password are required when enabling authentication (no admin users exist)",
                     )
 
+                # Enforce password complexity only when actually creating a new admin.
+                # Schema-level validation was removed so that re-enabling auth with an
+                # existing admin (or LDAP) doesn't reject whatever placeholder the form sends.
+                try:
+                    _validate_password_complexity(request.admin_password)
+                except ValueError as exc:
+                    raise HTTPException(
+                        status_code=status.HTTP_400_BAD_REQUEST,
+                        detail=str(exc),
+                    )
+
                 # Check if username already exists (shouldn't happen if no admin users exist, but check anyway)
                 existing_user = await get_user_by_username(db, request.admin_username)
                 if existing_user:
@@ -1265,3 +1280,298 @@ async def get_ldap_status(db: AsyncSession = Depends(get_db)):
         "ldap_enabled": settings.get("ldap_enabled", "false").lower() == "true",
         "ldap_configured": bool(settings.get("ldap_server_url")),
     }
+
+
+# =============================================================================
+# Long-lived camera-stream tokens (#1108)
+# =============================================================================
+# Camera-only V1. Issue scope: a token a user can paste into Home Assistant /
+# Frigate / a kiosk and have it keep working for days/weeks rather than
+# refreshing the 60-minute ephemeral token. Permission gate: CAMERA_VIEW
+# (same blast radius as the existing 60-min token-mint endpoint).
+
+
+def _long_lived_token_to_response(record, *, plaintext: str | None = None) -> dict:
+    """Serialise a LongLivedToken row for the SPA. Plaintext is included
+    only at create time (and then never again), per the issue's "shown once"
+    contract.
+    """
+    return {
+        "id": record.id,
+        "user_id": record.user_id,
+        "name": record.name,
+        "scope": record.scope,
+        "lookup_prefix": record.lookup_prefix,
+        "created_at": record.created_at.isoformat() if record.created_at else None,
+        "expires_at": record.expires_at.isoformat() if record.expires_at else None,
+        "last_used_at": record.last_used_at.isoformat() if record.last_used_at else None,
+        # Plaintext is the ONLY field the user ever sees in full — copied once
+        # to a clipboard / kiosk config and then forgotten.
+        "token": plaintext,
+    }
+
+
+@router.post("/tokens", response_model=dict, status_code=status.HTTP_201_CREATED)
+async def create_long_lived_camera_token(
+    payload: dict,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    db: AsyncSession = Depends(get_db),
+):
+    """Mint a long-lived camera-stream token (#1108).
+
+    Body: ``{"name": str, "expires_in_days": int, "scope": "camera_stream"}``.
+
+    The plaintext token is returned **exactly once** in the response. The DB
+    only ever stores a pbkdf2 hash, so a leaked DB dump cannot replay the
+    token. Hard cap of 365 days; the issue's ``expire_in: 0`` (never) is
+    explicitly rejected.
+    """
+    from backend.app.services.long_lived_tokens import (
+        ALLOWED_SCOPES,
+        MAX_TOKEN_LIFETIME_DAYS,
+        create_token,
+    )
+
+    # Auth-disabled path: tokens are user-owned, but if auth is off there is
+    # no user to own them. Refuse rather than silently picking a random user.
+    if current_user is None:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Long-lived tokens require authentication to be enabled",
+        )
+
+    name = payload.get("name")
+    if not isinstance(name, str) or not name.strip():
+        raise HTTPException(status_code=400, detail="name is required")
+    expires_in_days = payload.get("expires_in_days")
+    if not isinstance(expires_in_days, int) or expires_in_days <= 0:
+        raise HTTPException(
+            status_code=400,
+            detail=(
+                f"expires_in_days must be a positive integer (max {MAX_TOKEN_LIFETIME_DAYS}; #1108: no infinite tokens)"
+            ),
+        )
+    scope = payload.get("scope", "camera_stream")
+    if scope not in ALLOWED_SCOPES:
+        raise HTTPException(status_code=400, detail=f"unsupported scope: {scope!r}")
+
+    try:
+        created = await create_token(
+            db,
+            user_id=current_user.id,
+            name=name,
+            expires_in_days=expires_in_days,
+            scope=scope,
+        )
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    _logger.info(
+        "Long-lived camera token created: user=%s name=%r scope=%s expires=%s",
+        current_user.username,
+        name,
+        scope,
+        created.record.expires_at.isoformat(),
+    )
+    return _long_lived_token_to_response(created.record, plaintext=created.plaintext)
+
+
+@router.get("/tokens", response_model=list[dict])
+async def list_long_lived_tokens(
+    user_id: int | None = None,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    db: AsyncSession = Depends(get_db),
+):
+    """List long-lived tokens.
+
+    Default: caller's own tokens.
+    Admins can pass ``?user_id=N`` to see another user's tokens, or omit it
+    to see everything (handy for leak triage).
+    """
+    from backend.app.services.long_lived_tokens import list_user_tokens
+
+    # Auth-disabled installs don't have a notion of "my tokens" — refuse so
+    # we don't leak a global list to whoever can hit the API.
+    if current_user is None:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Long-lived tokens require authentication to be enabled",
+        )
+
+    # Reload with groups so is_admin reflects group membership reliably.
+    user_with_groups = (
+        await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
+    ).scalar_one()
+
+    if user_id is None or user_id == current_user.id:
+        records = await list_user_tokens(db, current_user.id)
+    elif user_with_groups.is_admin:
+        records = await list_user_tokens(db, user_id)
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Only admins can list other users' tokens",
+        )
+    return [_long_lived_token_to_response(r) for r in records]
+
+
+@router.get("/tokens/all", response_model=list[dict])
+async def list_all_long_lived_tokens(
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    db: AsyncSession = Depends(get_db),
+):
+    """Admin-only: every active long-lived token in the system, newest first.
+    Used by the leak-triage view in admin settings.
+    """
+    from backend.app.services.long_lived_tokens import list_all_tokens
+
+    if current_user is None:
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Auth required")
+    user_with_groups = (
+        await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
+    ).scalar_one()
+    if not user_with_groups.is_admin:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Admin only",
+        )
+    records = await list_all_tokens(db)
+    return [_long_lived_token_to_response(r) for r in records]
+
+
+@router.delete("/tokens/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def revoke_long_lived_token(
+    token_id: int,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    db: AsyncSession = Depends(get_db),
+):
+    """Revoke a long-lived token. Owners can revoke their own; admins any."""
+    from backend.app.models.long_lived_token import LongLivedToken
+    from backend.app.services.long_lived_tokens import revoke_token
+
+    if current_user is None:
+        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Auth required")
+
+    record = (await db.execute(select(LongLivedToken).where(LongLivedToken.id == token_id))).scalar_one_or_none()
+    if record is None:
+        raise HTTPException(status_code=404, detail="Token not found")
+
+    if record.user_id != current_user.id:
+        # Reload for is_admin so admins can revoke any user's token (leak response).
+        user_with_groups = (
+            await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
+        ).scalar_one()
+        if not user_with_groups.is_admin:
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail="You can only revoke your own tokens",
+            )
+
+    revoked = await revoke_token(db, token_id)
+    if not revoked:
+        # Already revoked is treated as 404 for idempotency from the UI side.
+        raise HTTPException(status_code=404, detail="Token not found or already revoked")
+    _logger.info(
+        "Long-lived camera token revoked: id=%d by user=%s",
+        token_id,
+        current_user.username,
+    )
+    return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
+@router.get("/encryption-status", response_model=EncryptionStatusResponse)
+async def get_encryption_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+) -> EncryptionStatusResponse:
+    """Report at-rest encryption status for OIDC + TOTP secrets.
+
+    Surfaces:
+      (a) whether a key is configured and where it came from
+      (b) how many rows are still legacy plaintext
+      (c) whether decryption is broken (no key OR key cannot decrypt existing rows)
+      (d) the count of rows skipped during the last re-encryption migration
+
+    S2: gated on SETTINGS_UPDATE so Viewers (who only have SETTINGS_READ)
+    cannot read encryption-status — admin/operator only.
+    """
+    from sqlalchemy import case, func, not_, select
+
+    from backend.app.core.database import get_migration_error_count
+    from backend.app.core.encryption import get_key_source, is_encryption_active, mfa_decrypt
+    from backend.app.models.oidc_provider import OIDCProvider
+    from backend.app.models.user_totp import UserTOTP
+
+    key_configured = is_encryption_active()
+    key_source = get_key_source() or "none"
+
+    try:
+        oidc_row = await db.execute(
+            select(
+                func.sum(case((not_(OIDCProvider._client_secret_enc.like("fernet:%")), 1), else_=0)),
+                func.sum(case((OIDCProvider._client_secret_enc.like("fernet:%"), 1), else_=0)),
+            )
+        )
+        legacy_oidc, encrypted_oidc = oidc_row.one()
+        totp_row = await db.execute(
+            select(
+                func.sum(case((not_(UserTOTP._secret_enc.like("fernet:%")), 1), else_=0)),
+                func.sum(case((UserTOTP._secret_enc.like("fernet:%"), 1), else_=0)),
+            )
+        )
+        legacy_totp, encrypted_totp = totp_row.one()
+    except SQLAlchemyError:
+        _logger.exception("Failed to query encryption row counts")
+        raise HTTPException(status_code=500, detail="Failed to retrieve encryption status")
+
+    legacy_plaintext_rows = EncryptionRowCounts(
+        oidc_providers=int(legacy_oidc or 0),
+        user_totp=int(legacy_totp or 0),
+    )
+    encrypted_rows = EncryptionRowCounts(
+        oidc_providers=int(encrypted_oidc or 0),
+        user_totp=int(encrypted_totp or 0),
+    )
+
+    # B4: detect "wrong key" state — sample-decrypt one encrypted row to
+    # distinguish "no key" from "key configured but cannot decrypt these rows".
+    # The legacy computed-field check (key_configured=False AND encrypted>0)
+    # missed the case where an operator pasted a different valid Fernet key
+    # (rotation, cross-deployment restore, env override) — status would show
+    # green while every encrypted row was unrecoverable.
+    decryption_broken = False
+    total_encrypted = encrypted_rows.oidc_providers + encrypted_rows.user_totp
+    if not key_configured and total_encrypted > 0:
+        decryption_broken = True
+    elif key_configured and total_encrypted > 0:
+        sample_value: str | None = None
+        try:
+            if encrypted_rows.oidc_providers > 0:
+                r = await db.execute(
+                    select(OIDCProvider._client_secret_enc)
+                    .where(OIDCProvider._client_secret_enc.like("fernet:%"))
+                    .limit(1)
+                )
+                sample_value = r.scalar_one_or_none()
+            if sample_value is None and encrypted_rows.user_totp > 0:
+                r = await db.execute(select(UserTOTP._secret_enc).where(UserTOTP._secret_enc.like("fernet:%")).limit(1))
+                sample_value = r.scalar_one_or_none()
+        except SQLAlchemyError:
+            _logger.exception("Failed to query sample encrypted row for decryption probe")
+            # Over-alert is safer than silent corruption — surface as broken.
+            decryption_broken = True
+            sample_value = None
+
+        if sample_value:
+            try:
+                mfa_decrypt(sample_value)
+            except RuntimeError:
+                decryption_broken = True
+
+    return EncryptionStatusResponse(
+        key_configured=key_configured,
+        key_source=key_source,
+        legacy_plaintext_rows=legacy_plaintext_rows,
+        encrypted_rows=encrypted_rows,
+        decryption_broken=decryption_broken,
+        migration_error_count=get_migration_error_count(),
+    )

+ 87 - 76
backend/app/api/routes/camera.py

@@ -31,6 +31,12 @@ from backend.app.services.camera import (
     read_next_chamber_frame,
     test_camera_connection,
 )
+from backend.app.services.camera_fanout import (
+    MjpegBroadcaster,
+    get_or_create_broadcaster,
+    iter_subscriber,
+    shutdown_broadcaster,
+)
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["camera"])
@@ -552,8 +558,6 @@ async def camera_stream(
         printer_id: Printer ID
         fps: Target frames per second (default: 10, max: 30)
     """
-    import uuid
-
     printer = await get_printer_or_404(printer_id, db)
 
     # Check for external camera first
@@ -602,12 +606,6 @@ async def camera_stream(
     else:
         fps = min(max(fps, 1), 30)
 
-    # Generate unique stream ID for tracking
-    stream_id = f"{printer_id}-{uuid.uuid4().hex[:8]}"
-
-    # Create disconnect event that will be set when client disconnects
-    disconnect_event = asyncio.Event()
-
     # Choose the appropriate stream generator based on model
     if is_chamber_image_model(printer.model):
         stream_generator = generate_chamber_mjpeg_stream
@@ -616,80 +614,80 @@ async def camera_stream(
         stream_generator = generate_rtsp_mjpeg_stream
         logger.info("Using RTSP protocol for %s", printer.model)
 
-    # Track stream start time
+    # Track stream start time. Set only if absent so the value reflects when
+    # the SHARED upstream first started streaming, not when each new viewer
+    # attached — otherwise /camera/status would report stream_uptime jumping
+    # backward whenever a second viewer joins. The upstream generator's
+    # finally clears this entry when the upstream actually ends.
     import time
 
-    _stream_start_times[printer_id] = time.time()
-
-    async def _kill_stream_process(sid: str):
-        """Terminate+kill the ffmpeg process for a stream ID."""
-        proc = _active_streams.get(sid)
-        if proc and proc.returncode is None:
-            try:
-                proc.terminate()
-                try:
-                    await asyncio.wait_for(proc.wait(), timeout=2.0)
-                except TimeoutError:
-                    proc.kill()
-                    await proc.wait()
-            except (ProcessLookupError, OSError):
-                pass
-
-    async def _monitor_disconnect():
-        """Background task: poll for client disconnect independently of frame loop."""
-        try:
-            while not disconnect_event.is_set():
-                await asyncio.sleep(2)
-                if await request.is_disconnected():
-                    logger.info("Disconnect monitor: client gone (stream %s)", stream_id)
-                    disconnect_event.set()
-                    # Kill ffmpeg process (RTSP streams)
-                    await _kill_stream_process(stream_id)
-                    # Close chamber stream connection if applicable
-                    chamber = _active_chamber_streams.get(stream_id)
-                    if chamber:
-                        try:
-                            chamber[1].close()
-                        except OSError:
-                            pass
-                    break
-        except asyncio.CancelledError:
-            pass
+    _stream_start_times.setdefault(printer_id, time.time())
+
+    # Fan-out broadcaster (#1089): one upstream connection per printer, shared
+    # across all viewers. Most Bambu printers only allow a single concurrent
+    # camera connection, so opening the same printer in two tabs would
+    # otherwise kick the first viewer off. The broadcaster owns the single
+    # upstream and the per-viewer disconnect handling.
+    #
+    # Note: the upstream's fps is fixed by the first viewer who creates the
+    # broadcaster. Concurrent viewers share that rate; new viewers after
+    # teardown create a fresh broadcaster at their requested fps.
+    fanout_key = f"printer-{printer_id}"
+    upstream_stream_id = f"{printer_id}-fanout"
+
+    def _factory(disconnect_event: asyncio.Event):
+        # Re-bind locals into the closure so the async generator below sees
+        # them — disconnect_event is owned by the broadcaster and signalled
+        # when the last subscriber leaves (after the grace window).
+        return stream_generator(
+            ip_address=printer.ip_address,
+            access_code=printer.access_code,
+            model=printer.model,
+            fps=fps,
+            stream_id=upstream_stream_id,
+            disconnect_event=disconnect_event,
+            printer_id=printer_id,
+        )
 
-    monitor_task = asyncio.create_task(_monitor_disconnect())
+    # Subscribe with a one-shot retry to close a tiny race: the grace-window
+    # teardown can flip the broadcaster to `stopped=True` between the registry
+    # lookup and our subscribe call. The retry forces the registry to mint a
+    # fresh broadcaster (since the now-stopped one is replaced), and the second
+    # subscribe is guaranteed to land on it before any teardown can fire.
+    broadcaster: MjpegBroadcaster = await get_or_create_broadcaster(fanout_key, _factory)
+    try:
+        queue = await broadcaster.subscribe()
+    except RuntimeError:
+        broadcaster = await get_or_create_broadcaster(fanout_key, _factory)
+        queue = await broadcaster.subscribe()
+    logger.info(
+        "Camera viewer attached to %s (subscribers=%d)",
+        fanout_key,
+        broadcaster.subscriber_count,
+    )
 
-    async def stream_with_disconnect_check():
-        """Wrapper generator that monitors for client disconnect."""
+    async def _is_disconnected() -> bool:
         try:
-            async for chunk in stream_generator(
-                ip_address=printer.ip_address,
-                access_code=printer.access_code,
-                model=printer.model,
-                fps=fps,
-                stream_id=stream_id,
-                disconnect_event=disconnect_event,
-                printer_id=printer_id,
-            ):
-                # Check if client is still connected
-                if disconnect_event.is_set() or await request.is_disconnected():
-                    logger.info("Client disconnected detected for stream %s", stream_id)
-                    disconnect_event.set()
-                    break
-                yield chunk
-        except asyncio.CancelledError:
-            logger.info("Stream %s cancelled", stream_id)
-            disconnect_event.set()
-        except GeneratorExit:
-            logger.info("Stream %s generator closed", stream_id)
-            disconnect_event.set()
-        finally:
-            disconnect_event.set()
-            monitor_task.cancel()
-            # Give a moment for the inner generator to clean up
-            await asyncio.sleep(0.1)
+            return await request.is_disconnected()
+        except Exception:
+            # Older starlette/uvicorn can raise during teardown — treat that
+            # as "client gone" so the subscriber cleanly unsubscribes.
+            return True
+
+    def _log_detach(remaining: int) -> None:
+        logger.info("Camera viewer detached from %s (subscribers=%d)", fanout_key, remaining)
+
+    async def _generate():
+        async for chunk in iter_subscriber(
+            broadcaster,
+            queue,
+            is_disconnected=_is_disconnected,
+            on_unsubscribe=_log_detach,
+        ):
+            yield chunk
 
     return StreamingResponse(
-        stream_with_disconnect_check(),
+        _generate(),
         media_type="multipart/x-mixed-replace; boundary=frame",
         headers={
             "Cache-Control": "no-cache, no-store, must-revalidate",
@@ -711,6 +709,12 @@ async def stop_camera_stream(
     """
     stopped = 0
 
+    # Tear down the fan-out broadcaster first (#1089). This cleanly notifies
+    # all subscribed viewers and asks the upstream generator to stop
+    # reconnecting before we fall back to forcefully killing the process below.
+    if await shutdown_broadcaster(f"printer-{printer_id}"):
+        logger.info("Shut down camera fan-out broadcaster for printer %s", printer_id)
+
     # Stop ffmpeg/RTSP streams
     to_remove = []
     for stream_id, process in list(_active_streams.items()):
@@ -788,7 +792,12 @@ async def camera_snapshot(
     if printer.external_camera_enabled and printer.external_camera_url:
         from backend.app.services.external_camera import capture_frame
 
-        frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type, timeout=15)
+        frame_data = await capture_frame(
+            printer.external_camera_url,
+            printer.external_camera_type,
+            timeout=15,
+            snapshot_url=printer.external_camera_snapshot_url,
+        )
         if not frame_data:
             raise HTTPException(
                 status_code=503,
@@ -1036,6 +1045,7 @@ async def check_plate_empty(
         external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None,
         use_external=use_external,
         roi=roi,
+        external_camera_snapshot_url=printer.external_camera_snapshot_url if printer.external_camera_enabled else None,
     )
 
     # Get reference count for the response
@@ -1120,6 +1130,7 @@ async def calibrate_plate_detection(
         external_camera_url=printer.external_camera_url if printer.external_camera_enabled else None,
         external_camera_type=printer.external_camera_type if printer.external_camera_enabled else None,
         use_external=use_external,
+        external_camera_snapshot_url=printer.external_camera_snapshot_url if printer.external_camera_enabled else None,
     )
 
     if light_warning and success:

+ 164 - 18
backend/app/api/routes/cloud.py

@@ -9,13 +9,21 @@ import logging
 from pathlib import Path
 from typing import Literal
 
-from fastapi import APIRouter, Body, Depends, HTTPException
+from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
+from fastapi.security import HTTPAuthorizationCredentials
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    _user_from_api_key,
+    _validate_api_key,
+    require_permission_if_auth_enabled,
+    security,
+)
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.models.api_key import APIKey
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.schemas.cloud import (
@@ -42,7 +50,116 @@ from backend.app.utils.filament_ids import filament_id_to_setting_id
 
 logger = logging.getLogger(__name__)
 
-router = APIRouter(prefix="/cloud", tags=["cloud"])
+
+async def _cloud_api_key_gate(
+    request: Request,
+    credentials: HTTPAuthorizationCredentials | None = Depends(security),
+    x_api_key: str | None = Header(default=None, alias="X-API-Key"),
+    db: AsyncSession = Depends(get_db),
+) -> None:
+    """Router-level dependency: enforce API-key cloud-access fences (#1182).
+
+    Runs before every /cloud/* handler. JWT-authed and anonymous callers are
+    no-ops — their access is gated by the per-route ``Permission.CLOUD_AUTH``
+    / ``Permission.FILAMENTS_READ`` / etc. dependency. API-keyed callers
+    must have an owner and ``can_access_cloud=True``; legacy ownerless keys
+    and keys without the cloud scope are rejected here.
+
+    On a successful API-keyed request the owner User is stashed on
+    ``request.state.api_key_owner`` so route handlers can resolve it via
+    ``cloud_caller`` (the auth gate returns None for API keys to avoid a
+    wider behaviour change in non-cloud routes — see auth.py).
+
+    The dep duplicates the API-key validation done by the regular auth gate
+    (which runs as a route-level dep, *after* router-level deps). The cost
+    is one extra ``SELECT FROM api_keys`` per /cloud/* request — bounded and
+    cheap (key_prefix is indexed).
+    """
+    api_key_value: str | None = None
+    if x_api_key:
+        api_key_value = x_api_key
+    elif credentials and credentials.credentials.startswith("bb_"):
+        api_key_value = credentials.credentials
+
+    if api_key_value is None:
+        return  # JWT or anonymous — no-op
+
+    api_key = await _validate_api_key(db, api_key_value)
+    if api_key is None:
+        # Invalid key — let the route-level auth gate produce the 401 so the
+        # error matches what every other route returns for a bad key.
+        return
+    _assert_api_key_can_access_cloud(api_key)
+    # All fences passed. Stash the owner so cloud routes can resolve their
+    # caller User without going through the auth gate (which intentionally
+    # returns None for API keys to keep #1182 surface-bounded to /cloud/*).
+    request.state.api_key_owner = await _user_from_api_key(db, api_key)
+
+
+def cloud_caller(*permissions: Permission):
+    """Route-level dep factory for /cloud/* handlers.
+
+    Returns a Depends that resolves to:
+      - the JWT-authenticated User (when a JWT is present and the route's
+        permission set is satisfied), OR
+      - the API-key owner User stashed by the router-level gate
+        (``request.state.api_key_owner``), OR
+      - None when auth is disabled.
+
+    Replaces the direct ``RequirePermissionIfAuthEnabled(...)`` dep on cloud
+    routes so API-keyed callers get the *owner* in ``current_user`` rather
+    than None — without that the route falls back to the global Settings
+    cloud_token, which is empty in auth-enabled deployments.
+    """
+    base_dep = require_permission_if_auth_enabled(*permissions)
+
+    async def resolved(
+        request: Request,
+        base_user: User | None = Depends(base_dep),
+    ) -> User | None:
+        if base_user is not None:
+            return base_user
+        return getattr(request.state, "api_key_owner", None)
+
+    return Depends(resolved)
+
+
+async def resolve_api_key_cloud_owner(
+    credentials: HTTPAuthorizationCredentials | None = Depends(security),
+    x_api_key: str | None = Header(default=None, alias="X-API-Key"),
+    db: AsyncSession = Depends(get_db),
+) -> User | None:
+    """Route-level dep for non-/cloud/* endpoints that need to read the
+    caller's stored Bambu Cloud token (e.g. the slice path resolving cloud
+    presets — #1182 follow-up).
+
+    Returns the API key's owner User when the caller is an API-keyed
+    request *and* the key has ``can_access_cloud=True``; returns None for
+    JWT, anonymous, or API keys without the cloud scope. The caller is
+    expected to fall back to the JWT-authed ``current_user`` first and use
+    this dep's result only when ``current_user`` is None.
+
+    Unlike ``_cloud_api_key_gate`` (which 403s legacy/non-cloud keys at the
+    router level), this dep is permissive: it returns None instead of
+    raising, so a slice request via an API key without cloud scope still
+    runs against local presets. The downstream cloud-token check in
+    ``preset_resolver._resolve_cloud`` produces the right 400 if the
+    request actually selects a cloud preset.
+    """
+    api_key_value: str | None = None
+    if x_api_key:
+        api_key_value = x_api_key
+    elif credentials and credentials.credentials.startswith("bb_"):
+        api_key_value = credentials.credentials
+    if api_key_value is None:
+        return None
+    api_key = await _validate_api_key(db, api_key_value)
+    if api_key is None or api_key.user_id is None or not api_key.can_access_cloud:
+        return None
+    return await _user_from_api_key(db, api_key)
+
+
+router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
 
 
 # Keys for storing cloud credentials in settings
@@ -132,6 +249,35 @@ async def clear_token(db: AsyncSession, user: User | None = None) -> None:
     await db.commit()
 
 
+def _assert_api_key_can_access_cloud(api_key: APIKey) -> None:
+    """Reject API keys that aren't authorised to read cloud data.
+
+    Three independent fences for API keys (#1182):
+      1. user_id IS NOT NULL — legacy keys created before per-user ownership
+         have no owner whose cloud_token we could read; force recreate.
+      2. can_access_cloud=True — opt-in scope so existing automation doesn't
+         start reading cloud data without the operator explicitly enabling it.
+      3. owner has stored cloud_token — enforced separately at the route
+         level via ``build_authenticated_cloud`` returning None.
+    """
+    if api_key.user_id is None:
+        raise HTTPException(
+            status_code=401,
+            detail=(
+                "This API key was created before per-user cloud access was supported. "
+                "Recreate it from Settings → API Keys to use /cloud/* endpoints."
+            ),
+        )
+    if not api_key.can_access_cloud:
+        raise HTTPException(
+            status_code=403,
+            detail=(
+                "This API key is not authorised to access Bambu Cloud data. "
+                "Enable 'Allow cloud access' on the key in Settings → API Keys."
+            ),
+        )
+
+
 async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> BambuCloudService | None:
     """Build a per-request cloud service seeded with the caller's stored token + region.
 
@@ -149,7 +295,7 @@ async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> Bamb
 @router.get("/status", response_model=CloudAuthStatus)
 async def get_auth_status(
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """Get current cloud authentication status.
 
@@ -179,7 +325,7 @@ async def get_auth_status(
 async def login(
     request: CloudLoginRequest,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
     Initiate login to Bambu Cloud.
@@ -219,7 +365,7 @@ async def login(
 async def verify_code(
     request: CloudVerifyRequest,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
     Complete login with verification code (email or TOTP).
@@ -264,7 +410,7 @@ async def verify_code(
 async def set_token(
     request: CloudTokenRequest,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
     Set access token directly.
@@ -290,7 +436,7 @@ async def set_token(
 @router.post("/logout")
 async def logout(
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """Log out of Bambu Cloud."""
     await clear_token(db, current_user)
@@ -301,7 +447,7 @@ async def logout(
 async def get_slicer_settings(
     version: str = "02.04.00.70",
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
     Get all slicer settings (filament, printer, process presets).
@@ -372,7 +518,7 @@ async def get_slicer_settings(
 async def get_setting_detail(
     setting_id: str,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
     Get detailed information for a specific setting/preset.
@@ -399,7 +545,7 @@ async def get_setting_detail(
 async def get_filament_presets(
     version: str = "02.04.00.70",
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+    current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
 ):
     """
     Get just filament presets (convenience endpoint).
@@ -594,7 +740,7 @@ _filament_id_to_setting_id = filament_id_to_setting_id
 async def get_filament_info(
     setting_ids: list[str] = Body(...),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+    current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
 ):
     """
     Get filament preset info (name and K value) for multiple setting IDs.
@@ -673,7 +819,7 @@ async def get_filament_info(
 @router.get("/devices", response_model=list[CloudDevice])
 async def get_devices(
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    current_user: User | None = cloud_caller(Permission.PRINTERS_READ),
 ):
     """
     Get list of bound printer devices.
@@ -710,7 +856,7 @@ async def get_devices(
 @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
 async def get_firmware_updates(
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    current_user: User | None = cloud_caller(Permission.FIRMWARE_READ),
 ):
     """
     Check for firmware updates for all bound devices.
@@ -786,7 +932,7 @@ async def get_firmware_updates(
 async def create_setting(
     request: SlicerSettingCreate,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
     Create a new slicer preset/setting.
@@ -823,7 +969,7 @@ async def update_setting(
     setting_id: str,
     request: SlicerSettingUpdate,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
     Update an existing slicer preset/setting.
@@ -854,7 +1000,7 @@ async def update_setting(
 async def delete_setting(
     setting_id: str,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
     """
     Delete a slicer preset/setting.
@@ -937,7 +1083,7 @@ _filament_id_name_cache_time: float = 0
 @router.get("/filament-id-map")
 async def get_filament_id_map(
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+    current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
 ):
     """
     Get filament_id → name mapping for user cloud presets.

+ 31 - 8
backend/app/api/routes/github_backup.py

@@ -19,6 +19,7 @@ from backend.app.schemas.github_backup import (
     GitHubBackupStatus,
     GitHubBackupTriggerResponse,
     GitHubTestConnectionResponse,
+    ProviderType,
 )
 from backend.app.services.github_backup import github_backup_service
 
@@ -34,6 +35,8 @@ def _config_to_response(config: GitHubBackupConfig) -> dict:
         "repository_url": config.repository_url,
         "has_token": bool(config.access_token),
         "branch": config.branch,
+        "provider": config.provider,
+        "allow_insecure_http": config.allow_insecure_http,
         "schedule_enabled": config.schedule_enabled,
         "schedule_type": config.schedule_type,
         "backup_kprofiles": config.backup_kprofiles,
@@ -86,6 +89,7 @@ async def save_config(
         config.repository_url = config_data.repository_url
         config.access_token = config_data.access_token
         config.branch = config_data.branch
+        config.provider = config_data.provider.value
         config.schedule_enabled = config_data.schedule_enabled
         config.schedule_type = config_data.schedule_type.value
         config.backup_kprofiles = config_data.backup_kprofiles
@@ -93,11 +97,12 @@ async def save_config(
         config.backup_settings = config_data.backup_settings
         config.backup_spools = config_data.backup_spools
         config.backup_archives = config_data.backup_archives
+        config.allow_insecure_http = config_data.allow_insecure_http
         config.enabled = config_data.enabled
 
         # Calculate next scheduled run if enabled
         if config.schedule_enabled:
-            config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
+            config.next_scheduled_run = github_backup_service.calculate_next_run(config.schedule_type)
         else:
             config.next_scheduled_run = None
 
@@ -108,6 +113,7 @@ async def save_config(
             repository_url=config_data.repository_url,
             access_token=config_data.access_token,
             branch=config_data.branch,
+            provider=config_data.provider.value,
             schedule_enabled=config_data.schedule_enabled,
             schedule_type=config_data.schedule_type.value,
             backup_kprofiles=config_data.backup_kprofiles,
@@ -115,11 +121,12 @@ async def save_config(
             backup_settings=config_data.backup_settings,
             backup_spools=config_data.backup_spools,
             backup_archives=config_data.backup_archives,
+            allow_insecure_http=config_data.allow_insecure_http,
             enabled=config_data.enabled,
         )
 
         if config.schedule_enabled:
-            config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
+            config.next_scheduled_run = github_backup_service.calculate_next_run(config.schedule_type)
 
         db.add(config)
         logger.info("Created GitHub backup config: %s", config.repository_url)
@@ -145,8 +152,19 @@ async def update_config(
 
     update_dict = update_data.model_dump(exclude_unset=True)
 
+    # Validate HTTP URL restriction when the URL policy is being changed. This avoids blocking unrelated autosaves
+    # for legacy configs that already contain an HTTP URL.
+    if "repository_url" in update_dict or "allow_insecure_http" in update_dict:
+        url_to_check = update_dict.get("repository_url", config.repository_url)
+        effective_allow_http = update_dict.get("allow_insecure_http", config.allow_insecure_http)
+        if url_to_check and url_to_check.startswith("http://") and not effective_allow_http:
+            raise HTTPException(
+                status_code=422,
+                detail="This URL uses HTTP instead of HTTPS. Enable 'Allow insecure HTTP' if your instance does not use TLS.",
+            )
+
     for key, value in update_dict.items():
-        if key == "schedule_type" and value is not None:
+        if key in ("schedule_type", "provider") and value is not None:
             setattr(config, key, value.value)
         else:
             setattr(config, key, value)
@@ -154,7 +172,7 @@ async def update_config(
     # Recalculate next scheduled run if schedule settings changed
     if "schedule_enabled" in update_dict or "schedule_type" in update_dict:
         if config.schedule_enabled:
-            config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
+            config.next_scheduled_run = github_backup_service.calculate_next_run(config.schedule_type)
         else:
             config.next_scheduled_run = None
 
@@ -188,12 +206,13 @@ async def delete_config(
 
 @router.post("/test", response_model=GitHubTestConnectionResponse)
 async def test_connection(
-    repo_url: str = Query(..., description="GitHub repository URL"),
+    repo_url: str = Query(..., description="Repository URL"),
     token: str = Query(..., description="Personal Access Token"),
+    provider: ProviderType = Query(default=ProviderType.GITHUB, description="Git provider key"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
-    """Test GitHub connection with provided credentials."""
-    result = await github_backup_service.test_connection(repo_url, token)
+    """Test Git provider connection with provided credentials."""
+    result = await github_backup_service.test_connection(repo_url, token, provider=provider)
     return GitHubTestConnectionResponse(**result)
 
 
@@ -212,7 +231,11 @@ async def test_stored_connection(
     if not config.access_token:
         raise HTTPException(status_code=400, detail="No access token configured")
 
-    test_result = await github_backup_service.test_connection(config.repository_url, config.access_token)
+    test_result = await github_backup_service.test_connection(
+        config.repository_url,
+        config.access_token,
+        provider=config.provider,
+    )
     return GitHubTestConnectionResponse(**test_result)
 
 

+ 703 - 297
backend/app/api/routes/inventory.py

@@ -4,12 +4,16 @@ import logging
 import httpx
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi.responses import StreamingResponse
-from pydantic import BaseModel
-from sqlalchemy import func, select
+from pydantic import BaseModel, Field, field_validator
+from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
+from backend.app.core.auth import (
+    RequireAnyPermissionIfAuthEnabled,
+    RequirePermissionIfAuthEnabled,
+    require_auth_if_enabled,
+)
 from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -30,33 +34,369 @@ from backend.app.schemas.spool import (
     SpoolKProfileResponse,
     SpoolResponse,
     SpoolUpdate,
+    normalize_effect_type,
+    normalize_extra_colors,
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
-from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
+from backend.app.utils.filament_ids import (
+    GENERIC_FILAMENT_IDS,
+    MATERIAL_TEMPS,
+    filament_id_to_setting_id,
+    normalize_slicer_filament,
+)
 from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
 
 logger = logging.getLogger(__name__)
 
-router = APIRouter(prefix="/inventory", tags=["inventory"])
+_GENERIC_ID_VALUES = set(GENERIC_FILAMENT_IDS.values())
 
-# Material temperature defaults (nozzle min/max)
-MATERIAL_TEMPS: dict[str, tuple[int, int]] = {
-    "PLA": (190, 230),
-    "PETG": (220, 260),
-    "ABS": (240, 270),
-    "ASA": (240, 270),
-    "TPU": (200, 240),
-    "PA": (260, 290),
-    "PC": (250, 280),
-    "PVA": (190, 210),
-    "PLA-CF": (210, 240),
-    "PETG-CF": (240, 270),
-    "PA-CF": (270, 300),
-}
+router = APIRouter(prefix="/inventory", tags=["inventory"])
 
 # FilamentColors.xyz API
 FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
 
+# Generic Bambu filament IDs by material — fallback when no specific
+# preset is resolvable. Keep aligned with the inline table in
+# apply_spool_to_slot_via_mqtt below; both paths must produce the same
+# value for a given material.
+_GENERIC_FILAMENT_IDS: dict[str, str] = {
+    "PLA": "GFL99",
+    "PETG": "GFG99",
+    "ABS": "GFB99",
+    "ASA": "GFB98",
+    "PC": "GFC99",
+    "PA": "GFN99",
+    "NYLON": "GFN99",
+    "TPU": "GFU99",
+    "PVA": "GFS99",
+    "HIPS": "GFS98",
+    "PLA-CF": "GFL98",
+    "PETG-CF": "GFG98",
+    "PA-CF": "GFN98",
+    "PETG HF": "GFG96",
+}
+
+
+async def apply_spool_to_slot_via_mqtt(
+    *,
+    db: AsyncSession,
+    current_user: User | None,
+    spool: Spool,
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    current_tray_info_idx: str = "",
+    current_tray_type: str = "",
+) -> bool:
+    """Publish ams_filament_setting + extrusion_cali_sel for a spool on a slot.
+
+    Shared by `assign_spool` (initial assign for a loaded slot) and
+    `on_ams_change` (re-fire when a SpoolBuddy-pre-assigned slot transitions
+    empty → loaded). Returns True when MQTT commands were published, False if
+    no client was available or setup failed mid-way.
+
+    `current_tray_info_idx` / `current_tray_type` describe the live tray state
+    used as fallback hints when the spool's slicer_filament can't be resolved.
+    Caller should not pass these for the empty-slot re-fire path (they'll be
+    the freshly-loaded values, which is the intended fallback).
+    """
+    from backend.app.services.printer_manager import printer_manager
+
+    client = printer_manager.get_client(printer_id)
+    if client is None:
+        return False
+
+    state = printer_manager.get_status(printer_id)
+
+    tray_type = spool.material
+    tray_sub_brands = (
+        f"{spool.brand} {spool.material} {spool.subtype}".strip()
+        if spool.brand
+        else f"{spool.material} {spool.subtype}"
+        if spool.subtype
+        else spool.material
+    )
+    tray_color = spool.rgba or "FFFFFFFF"
+
+    _generic_id_values = set(_GENERIC_FILAMENT_IDS.values())
+
+    tray_info_idx = ""
+    setting_id = ""
+    sf = spool.slicer_filament or ""
+
+    if sf:
+        base_sf = sf.split("_")[0] if "_" in sf else sf
+        if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
+            setting_id = base_sf
+            try:
+                from backend.app.api.routes.cloud import build_authenticated_cloud
+
+                cloud = await build_authenticated_cloud(db, current_user)
+                if cloud is not None and cloud.is_authenticated:
+                    try:
+                        detail = await cloud.get_setting_detail(base_sf)
+                        if detail.get("filament_id"):
+                            tray_info_idx = detail["filament_id"]
+                            cloud_name = detail.get("name", "")
+                            if cloud_name:
+                                tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
+                        elif detail.get("base_id"):
+                            bid = detail["base_id"].split("_")[0]
+                            if bid.startswith("GFS") and len(bid) >= 5:
+                                tray_info_idx = f"GF{bid[3:]}"
+                            else:
+                                tray_info_idx = bid
+                    finally:
+                        await cloud.close()
+                elif cloud is not None:
+                    await cloud.close()
+            except Exception as e:
+                logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
+
+            if not tray_info_idx:
+                tray_info_idx, setting_id = normalize_slicer_filament(sf)
+        elif base_sf.startswith("GF"):
+            tray_info_idx, setting_id = normalize_slicer_filament(sf)
+        else:
+            try:
+                local_id = int(sf)
+                from backend.app.models.local_preset import LocalPreset as LP
+
+                lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
+                lp = lp_result.scalar_one_or_none()
+                if lp:
+                    mat = (spool.material or lp.filament_type or "").upper().strip()
+                    tray_info_idx = (
+                        _GENERIC_FILAMENT_IDS.get(mat)
+                        or _GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0])
+                        or ""
+                    )
+                    if lp.name:
+                        tray_sub_brands = lp.name.split("@")[0].strip()
+            except (ValueError, TypeError):
+                tray_info_idx, setting_id = normalize_slicer_filament(sf)
+
+    if tray_info_idx and spool.slicer_filament_name:
+        from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
+
+        expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
+        if expected_name and expected_name != spool.slicer_filament_name:
+            for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
+                if fname == spool.slicer_filament_name:
+                    tray_info_idx = fid
+                    setting_id = filament_id_to_setting_id(fid)
+                    break
+
+    if not tray_info_idx:
+        if (
+            current_tray_info_idx
+            and current_tray_info_idx not in _generic_id_values
+            and current_tray_type
+            and current_tray_type.upper() == tray_type.upper()
+        ):
+            tray_info_idx = current_tray_info_idx
+        elif tray_type:
+            material = tray_type.upper().strip()
+            generic = (
+                _GENERIC_FILAMENT_IDS.get(material)
+                or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
+                or ""
+            )
+            if generic:
+                tray_info_idx = generic
+
+    temp_min, temp_max = MATERIAL_TEMPS.get((spool.material or "").upper(), (200, 240))
+    if spool.nozzle_temp_min is not None:
+        temp_min = spool.nozzle_temp_min
+    if spool.nozzle_temp_max is not None:
+        temp_max = spool.nozzle_temp_max
+
+    nozzle_diameter = "0.4"
+    if state and state.nozzles:
+        nd = state.nozzles[0].nozzle_diameter
+        if nd:
+            nozzle_diameter = nd
+
+    slot_extruder = None
+    if state and state.ams_extruder_map:
+        if ams_id == 255:
+            slot_extruder = 1 - tray_id  # ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
+        else:
+            slot_extruder = state.ams_extruder_map.get(str(ams_id))
+
+    # Prefer exact extruder match, fall back to extruder-agnostic kp for the
+    # same nozzle. Hard-skipping on mismatch silently drops valid stored
+    # profiles when the AMS-extruder mapping has shifted.
+    exact_kp = None
+    fallback_kp = None
+    for kp in spool.k_profiles:
+        if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter:
+            continue
+        if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
+            exact_kp = kp
+            break
+        if fallback_kp is None:
+            fallback_kp = kp
+    matching_kp = exact_kp or fallback_kp
+
+    # Resolve the printer-side calibration entry by looking up the cali_idx
+    # in state.kprofiles. The printer keys its calibration table by
+    # (filament_id, cali_idx) — for the cali_idx to stick, the slot's
+    # filament_id must match the kp's. PFUS-prefix cloud user presets are
+    # rejected by the slicer in tray_info_idx; the printer-reported
+    # filament_id is typically a P-prefix local preset which is valid.
+    printer_kp = None
+    if matching_kp and matching_kp.cali_idx is not None and state and getattr(state, "kprofiles", None):
+        for pkp in state.kprofiles:
+            if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
+                printer_kp = pkp
+                break
+
+    effective_tray_info_idx = tray_info_idx
+    effective_setting_id = setting_id
+    if printer_kp and printer_kp.filament_id:
+        effective_tray_info_idx = printer_kp.filament_id
+    target_setting_id = (printer_kp.setting_id if printer_kp else None) or (
+        matching_kp.setting_id if matching_kp else None
+    )
+    if target_setting_id:
+        effective_setting_id = target_setting_id
+    if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
+        logger.info(
+            "Spool assign: realigning tray_info_idx %r → %r, setting_id %r → %r (source=%s)",
+            tray_info_idx,
+            effective_tray_info_idx,
+            setting_id,
+            effective_setting_id,
+            "printer" if printer_kp else "stored",
+        )
+
+    client.ams_set_filament_setting(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=effective_tray_info_idx,
+        tray_type=tray_type,
+        tray_sub_brands=tray_sub_brands,
+        tray_color=tray_color,
+        nozzle_temp_min=temp_min,
+        nozzle_temp_max=temp_max,
+        setting_id=effective_setting_id,
+    )
+
+    if matching_kp and matching_kp.cali_idx is not None:
+        # filament_id for cali_sel must match the preset under which the kp
+        # was registered. Priority: live printer kp > stored kp.setting_id >
+        # spool.slicer_filament > realigned tray_info_idx.
+        if printer_kp and printer_kp.filament_id:
+            cali_filament_id = printer_kp.filament_id
+        elif matching_kp.setting_id:
+            cali_filament_id = normalize_slicer_filament(matching_kp.setting_id)[0] or matching_kp.setting_id
+        else:
+            cali_filament_id = spool.slicer_filament or effective_tray_info_idx
+        client.extrusion_cali_sel(
+            ams_id=ams_id,
+            tray_id=tray_id,
+            cali_idx=matching_kp.cali_idx,
+            filament_id=cali_filament_id,
+            nozzle_diameter=nozzle_diameter,
+        )
+    else:
+        # No stored K-profile for this slot — preserve the slot's current live
+        # cali_idx if the printer has one. cali_idx is read from state.raw_data
+        # using the same idiom as the route's `current_tray_info_idx` lookup.
+        # Negative values (e.g. -1) mean "no calibration recorded" and must not
+        # be sent.
+        live_cali_idx: int | None = None
+        if state and getattr(state, "raw_data", None):
+            if ams_id == 255:
+                for vt in state.raw_data.get("vt_tray") or []:
+                    if isinstance(vt, dict) and int(vt.get("id", 254)) == (tray_id + 254):
+                        raw = vt.get("cali_idx")
+                        if isinstance(raw, int):
+                            live_cali_idx = raw
+                        break
+            else:
+                ams_section = state.raw_data.get("ams", {})
+                ams_list = (
+                    ams_section.get("ams", [])
+                    if isinstance(ams_section, dict)
+                    else ams_section
+                    if isinstance(ams_section, list)
+                    else []
+                )
+                tray_dict = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
+                if tray_dict:
+                    raw = tray_dict.get("cali_idx")
+                    if isinstance(raw, int):
+                        live_cali_idx = raw
+        if live_cali_idx is not None and live_cali_idx >= 0:
+            cali_filament_id = spool.slicer_filament or effective_tray_info_idx
+            client.extrusion_cali_sel(
+                ams_id=ams_id,
+                tray_id=tray_id,
+                cali_idx=live_cali_idx,
+                filament_id=cali_filament_id,
+                nozzle_diameter=nozzle_diameter,
+            )
+            logger.info(
+                "No stored K-profile for spool %d — preserved live cali_idx=%d",
+                spool.id,
+                live_cali_idx,
+            )
+
+    # Persist slot preset mapping for UI display (preset_name on hover card).
+    try:
+        from backend.app.models.slot_preset import SlotPresetMapping
+
+        preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
+        preset_source = "cloud"
+        if sf:
+            base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
+            try:
+                int(base_sf_mapping)
+                preset_id_to_save = f"local_{base_sf_mapping}"
+                preset_source = "local"
+            except (ValueError, TypeError):
+                preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
+        else:
+            preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
+
+        if preset_id_to_save:
+            existing_mapping = await db.execute(
+                select(SlotPresetMapping).where(
+                    SlotPresetMapping.printer_id == printer_id,
+                    SlotPresetMapping.ams_id == ams_id,
+                    SlotPresetMapping.tray_id == tray_id,
+                )
+            )
+            mapping = existing_mapping.scalar_one_or_none()
+            if mapping:
+                mapping.preset_id = preset_id_to_save
+                mapping.preset_name = preset_name
+                mapping.preset_source = preset_source
+            else:
+                mapping = SlotPresetMapping(
+                    printer_id=printer_id,
+                    ams_id=ams_id,
+                    tray_id=tray_id,
+                    preset_id=preset_id_to_save,
+                    preset_name=preset_name,
+                    preset_source=preset_source,
+                )
+                db.add(mapping)
+            await db.commit()
+    except Exception as e:
+        logger.warning("Failed to save slot preset mapping for spool %d: %s", spool.id, e)
+
+    logger.info(
+        "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
+        ams_id,
+        tray_id,
+        spool.id,
+        printer_id,
+    )
+    return True
+
 
 # ── Spool Catalog Schemas ──────────────────────────────────────────────────
 
@@ -95,23 +435,52 @@ class ColorEntryResponse(BaseModel):
     hex_color: str
     material: str | None
     is_default: bool
+    extra_colors: str | None = None
+    effect_type: str | None = None
 
     class Config:
         from_attributes = True
 
 
+_HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$"
+
+
 class ColorEntryCreate(BaseModel):
     manufacturer: str
     color_name: str
-    hex_color: str
+    hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
     material: str | None = None
+    extra_colors: str | None = None
+    effect_type: str | None = None
+
+    @field_validator("extra_colors")
+    @classmethod
+    def _validate_extra_colors(cls, v: str | None) -> str | None:
+        return normalize_extra_colors(v)
+
+    @field_validator("effect_type")
+    @classmethod
+    def _validate_effect_type(cls, v: str | None) -> str | None:
+        return normalize_effect_type(v)
 
 
 class ColorEntryUpdate(BaseModel):
     manufacturer: str
     color_name: str
-    hex_color: str
+    hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
     material: str | None = None
+    extra_colors: str | None = None
+    effect_type: str | None = None
+
+    @field_validator("extra_colors")
+    @classmethod
+    def _validate_extra_colors(cls, v: str | None) -> str | None:
+        return normalize_extra_colors(v)
+
+    @field_validator("effect_type")
+    @classmethod
+    def _validate_effect_type(cls, v: str | None) -> str | None:
+        return normalize_effect_type(v)
 
 
 class ColorLookupResult(BaseModel):
@@ -286,6 +655,8 @@ async def add_color_entry(
         hex_color=entry.hex_color,
         material=entry.material,
         is_default=False,
+        extra_colors=entry.extra_colors,
+        effect_type=entry.effect_type,
     )
     db.add(row)
     await db.commit()
@@ -309,6 +680,8 @@ async def update_color_entry(
     row.color_name = entry.color_name
     row.hex_color = entry.hex_color
     row.material = entry.material
+    row.extra_colors = entry.extra_colors
+    row.effect_type = entry.effect_type
     await db.commit()
     await db.refresh(row)
     return row
@@ -799,10 +1172,17 @@ async def assign_spool(
     if spool.archived_at:
         raise HTTPException(400, "Cannot assign an archived spool")
 
-    # 2. Get current AMS tray state for fingerprint + existing filament ID
+    # 2. Get current AMS tray state for fingerprint + existing filament ID.
+    # tray_state: Bambu firmware reports 11=loaded, 9=empty, 10=spool present
+    # but filament not in feeder. Captured here so the empty-slot heuristic
+    # below can prefer it over tray_type — a manual "Reset slot" clears
+    # tray_type to "" while leaving state at 11 (filament still physically
+    # present), which would otherwise mislead the heuristic into the
+    # pending-config branch and skip MQTT forever (#1228 follow-up).
     fingerprint_color = None
     fingerprint_type = None
     current_tray_info_idx = ""
+    tray_state: int | None = None
     state = printer_manager.get_status(data.printer_id)
     if state and state.raw_data:
         if data.ams_id == 255:
@@ -814,6 +1194,9 @@ async def assign_spool(
                     fingerprint_color = vt.get("tray_color", "")
                     fingerprint_type = vt.get("tray_type", "")
                     current_tray_info_idx = vt.get("tray_info_idx", "")
+                    raw_state = vt.get("state")
+                    if isinstance(raw_state, int):
+                        tray_state = raw_state
                     break
         else:
             ams_data = state.raw_data.get("ams", {})
@@ -833,6 +1216,9 @@ async def assign_spool(
                 fingerprint_color = tray.get("tray_color", "")
                 fingerprint_type = tray.get("tray_type", "")
                 current_tray_info_idx = tray.get("tray_info_idx", "")
+                raw_state = tray.get("state")
+                if isinstance(raw_state, int):
+                    tray_state = raw_state
 
     # 3. Upsert assignment (replace if same printer+ams+tray)
     existing = await db.execute(
@@ -859,285 +1245,45 @@ async def assign_spool(
     await db.commit()
     await db.refresh(assignment)
 
-    # 4. Auto-configure AMS slot via MQTT
+    # 4. Auto-configure AMS slot via MQTT.
+    #
+    # Skip the publish entirely when the target slot is empty: Bambu firmware
+    # silently drops ams_filament_setting / extrusion_cali_sel for unloaded
+    # slots (there is no filament context for the cali_idx to attach to). The
+    # SpoolAssignment row is preserved with an empty fingerprint_type, which
+    # acts as the "pending config" marker — when the spool is physically
+    # inserted later, on_ams_change re-fires the full configuration. This is
+    # the SpoolBuddy primary workflow: weigh-then-assign before insertion.
+    #
+    # Empty-detection: prefer the printer's tray.state when it's reported
+    # (11=loaded, 9=empty, 10=spool present but filament not in feeder).
+    # tray_type alone is wrong post-"Reset slot" — that flow clears tray_type
+    # to "" while leaving filament physically loaded, and the old check
+    # would then mark it as a pending-config SpoolBuddy assignment, skip
+    # MQTT, and the slot would stay unconfigured forever because the
+    # on_ams_change replay only fires on an empty→loaded transition that
+    # never comes (the slot is already loaded). When state is not reported
+    # (older firmware), fall back to the tray_type heuristic.
+    if tray_state is not None:
+        slot_is_empty = tray_state != 11
+    else:
+        slot_is_empty = not (fingerprint_type and fingerprint_type.strip())
     configured = False
-    try:
-        client = printer_manager.get_client(data.printer_id)
-        if client:
-            # Build filament setting from spool data
-            tray_type = spool.material
-            tray_sub_brands = (
-                f"{spool.brand} {spool.material} {spool.subtype}".strip()
-                if spool.brand
-                else f"{spool.material} {spool.subtype}"
-                if spool.subtype
-                else spool.material
-            )
-            tray_color = spool.rgba or "FFFFFFFF"
-
-            _GENERIC_IDS = {
-                "PLA": "GFL99",
-                "PETG": "GFG99",
-                "ABS": "GFB99",
-                "ASA": "GFB98",
-                "PC": "GFC99",
-                "PA": "GFN99",
-                "NYLON": "GFN99",
-                "TPU": "GFU99",
-                "PVA": "GFS99",
-                "HIPS": "GFS98",
-                "PLA-CF": "GFL98",
-                "PETG-CF": "GFG98",
-                "PA-CF": "GFN98",
-                "PETG HF": "GFG96",
-            }
-            _GENERIC_ID_VALUES = set(_GENERIC_IDS.values())
-
-            # Resolve tray_info_idx + setting_id for the MQTT command.
-            # Three sources in priority order:
-            #   1. Cloud profile (if cloud connected) — resolve filament_id
-            #      from setting_id via cloud API
-            #   2. Local profile — use generic filament ID for material
-            #   3. Hard-coded fallback — generic Bambu filament IDs
-            tray_info_idx = ""
-            setting_id = ""
-            sf = spool.slicer_filament or ""
-
-            if sf:
-                # Check if it's a cloud preset (GFS*, PFUS*, or GF* official)
-                base_sf = sf.split("_")[0] if "_" in sf else sf
-                if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
-                    # Cloud setting_id — need to resolve real filament_id
-                    # Use base_sf (version suffix stripped) for cloud API + MQTT
-                    setting_id = base_sf
-                    try:
-                        from backend.app.api.routes.cloud import build_authenticated_cloud
-
-                        cloud = await build_authenticated_cloud(db, current_user)
-                        if cloud is not None and cloud.is_authenticated:
-                            try:
-                                detail = await cloud.get_setting_detail(base_sf)
-                                if detail.get("filament_id"):
-                                    tray_info_idx = detail["filament_id"]
-                                    logger.info(
-                                        "Spool assign: resolved filament_id=%r from cloud for setting_id=%r",
-                                        tray_info_idx,
-                                        sf,
-                                    )
-                                    # Use cloud preset name for tray_sub_brands if available
-                                    cloud_name = detail.get("name", "")
-                                    if cloud_name:
-                                        tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
-                                elif detail.get("base_id"):
-                                    # Derive from base_id (e.g. "GFSL05" → "GFL05")
-                                    bid = detail["base_id"].split("_")[0]
-                                    if bid.startswith("GFS") and len(bid) >= 5:
-                                        tray_info_idx = f"GF{bid[3:]}"
-                                    else:
-                                        tray_info_idx = bid
-                                    logger.info(
-                                        "Spool assign: derived filament_id=%r from base_id=%r",
-                                        tray_info_idx,
-                                        detail["base_id"],
-                                    )
-                            finally:
-                                await cloud.close()
-                        elif cloud is not None:
-                            await cloud.close()
-                    except Exception as e:
-                        logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
-
-                    if not tray_info_idx:
-                        # Cloud lookup failed — use normalize as fallback
-                        tray_info_idx, setting_id = normalize_slicer_filament(sf)
-                elif base_sf.startswith("GF"):
-                    # Official Bambu filament_id (e.g. "GFL05")
-                    tray_info_idx, setting_id = normalize_slicer_filament(sf)
-                    logger.info("Spool assign: using official filament_id=%r", tray_info_idx)
-
-                else:
-                    # Could be a local preset ID or material type — try local DB
-                    try:
-                        local_id = int(sf)
-                        from backend.app.models.local_preset import LocalPreset as LP
-
-                        lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
-                        lp = lp_result.scalar_one_or_none()
-                        if lp:
-                            mat = (spool.material or lp.filament_type or "").upper().strip()
-                            tray_info_idx = (
-                                _GENERIC_IDS.get(mat) or _GENERIC_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
-                            )
-                            # Use local preset name for tray_sub_brands
-                            if lp.name:
-                                tray_sub_brands = lp.name.split("@")[0].strip()
-                            logger.info(
-                                "Spool assign: local preset %d, material=%r, tray_info_idx=%r",
-                                local_id,
-                                mat,
-                                tray_info_idx,
-                            )
-                    except (ValueError, TypeError):
-                        # Not a numeric ID — treat as material type string
-                        tray_info_idx, setting_id = normalize_slicer_filament(sf)
-
-            # Cross-check: the cloud API returns the base filament_id for
-            # versioned setting_ids (e.g. GFSL99 → GFL99 for all PLA variants).
-            # If the spool has a specific preset name (e.g. "Generic PLA Silk"),
-            # reverse-lookup the correct filament_id from the built-in table.
-            if tray_info_idx and spool.slicer_filament_name:
-                from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
-
-                expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
-                if expected_name and expected_name != spool.slicer_filament_name:
-                    for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
-                        if fname == spool.slicer_filament_name:
-                            logger.info(
-                                "Spool assign: corrected filament_id %r→%r (name=%r)",
-                                tray_info_idx,
-                                fid,
-                                spool.slicer_filament_name,
-                            )
-                            tray_info_idx = fid
-                            setting_id = filament_id_to_setting_id(fid)
-                            break
-
-            if not tray_info_idx:
-                # Fallback: reuse slot's existing tray_info_idx or generic ID
-                if (
-                    current_tray_info_idx
-                    and current_tray_info_idx not in _GENERIC_ID_VALUES
-                    and fingerprint_type
-                    and fingerprint_type.upper() == tray_type.upper()
-                ):
-                    logger.info(
-                        "Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)",
-                        current_tray_info_idx,
-                        tray_type,
-                    )
-                    tray_info_idx = current_tray_info_idx
-                elif tray_type:
-                    material = tray_type.upper().strip()
-                    generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
-                    if generic:
-                        logger.info("Spool assign: falling back to generic %r for material %r", generic, tray_type)
-                        tray_info_idx = generic
-
-            # Temperature: use spool overrides if set, else material defaults
-            temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))
-            if spool.nozzle_temp_min is not None:
-                temp_min = spool.nozzle_temp_min
-            if spool.nozzle_temp_max is not None:
-                temp_max = spool.nozzle_temp_max
-
-            # a. Set filament setting
-            client.ams_set_filament_setting(
+    pending_config = slot_is_empty
+    if not slot_is_empty:
+        try:
+            configured = await apply_spool_to_slot_via_mqtt(
+                db=db,
+                current_user=current_user,
+                spool=spool,
+                printer_id=data.printer_id,
                 ams_id=data.ams_id,
                 tray_id=data.tray_id,
-                tray_info_idx=tray_info_idx,
-                tray_type=tray_type,
-                tray_sub_brands=tray_sub_brands,
-                tray_color=tray_color,
-                nozzle_temp_min=temp_min,
-                nozzle_temp_max=temp_max,
-                setting_id=setting_id,
-            )
-
-            # b. Look up K-profile for this spool + printer + nozzle + extruder
-            nozzle_diameter = "0.4"
-            if state and state.nozzles:
-                nd = state.nozzles[0].nozzle_diameter
-                if nd:
-                    nozzle_diameter = nd
-
-            # Determine slot's extruder from ams_extruder_map
-            slot_extruder = None
-            if state and state.ams_extruder_map:
-                if data.ams_id == 255:
-                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
-                    slot_extruder = 1 - data.tray_id  # 0→1, 1→0
-                else:
-                    slot_extruder = state.ams_extruder_map.get(str(data.ams_id))
-
-            matching_kp = None
-            for kp in spool.k_profiles:
-                if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter:
-                    if slot_extruder is not None and kp.extruder is not None and kp.extruder != slot_extruder:
-                        continue
-                    matching_kp = kp
-                    break
-
-            if matching_kp and matching_kp.cali_idx is not None:
-                client.extrusion_cali_sel(
-                    ams_id=data.ams_id,
-                    tray_id=data.tray_id,
-                    cali_idx=matching_kp.cali_idx,
-                    filament_id=tray_info_idx,
-                    nozzle_diameter=nozzle_diameter,
-                )
-
-            configured = True
-            logger.info(
-                "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
-                data.ams_id,
-                data.tray_id,
-                spool.id,
-                data.printer_id,
+                current_tray_info_idx=current_tray_info_idx,
+                current_tray_type=fingerprint_type or "",
             )
-
-            # Save slot preset mapping so the UI shows the correct preset name.
-            # Use slicer_filament_name (authoritative) with fallback to tray_sub_brands.
-            try:
-                from backend.app.models.slot_preset import SlotPresetMapping
-
-                preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
-                preset_source = "cloud"
-                if sf:
-                    base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
-                    try:
-                        local_id = int(base_sf_mapping)
-                        preset_id_to_save = f"local_{local_id}"
-                        preset_source = "local"
-                    except (ValueError, TypeError):
-                        # Cloud or builtin preset — convert filament_id to setting_id
-                        preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
-                else:
-                    preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
-
-                if preset_id_to_save:
-                    existing_mapping = await db.execute(
-                        select(SlotPresetMapping).where(
-                            SlotPresetMapping.printer_id == data.printer_id,
-                            SlotPresetMapping.ams_id == data.ams_id,
-                            SlotPresetMapping.tray_id == data.tray_id,
-                        )
-                    )
-                    mapping = existing_mapping.scalar_one_or_none()
-                    if mapping:
-                        mapping.preset_id = preset_id_to_save
-                        mapping.preset_name = preset_name
-                        mapping.preset_source = preset_source
-                    else:
-                        mapping = SlotPresetMapping(
-                            printer_id=data.printer_id,
-                            ams_id=data.ams_id,
-                            tray_id=data.tray_id,
-                            preset_id=preset_id_to_save,
-                            preset_name=preset_name,
-                            preset_source=preset_source,
-                        )
-                        db.add(mapping)
-                    await db.commit()
-                    logger.info(
-                        "Saved slot preset mapping: preset_id=%r, preset_name=%r",
-                        preset_id_to_save,
-                        preset_name,
-                    )
-            except Exception as e:
-                logger.warning("Failed to save slot preset mapping: %s", e)
-
-    except Exception as e:
-        logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
+        except Exception as e:
+            logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
 
     # Return assignment with spool data
     result = await db.execute(
@@ -1151,6 +1297,16 @@ async def assign_spool(
     resp = result.scalar_one()
     response = SpoolAssignmentResponse.model_validate(resp)
     response.configured = configured
+    response.pending_config = pending_config
+
+    if pending_config:
+        logger.info(
+            "Pre-configured assignment: spool %d → printer %d AMS%d-T%d (slot empty, will configure on insert)",
+            spool.id,
+            data.printer_id,
+            data.ams_id,
+            data.tray_id,
+        )
 
     await ws_manager.broadcast(
         {
@@ -1474,3 +1630,253 @@ def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict |
             if int(tray.get("id", -1)) == tray_id:
                 return tray
     return None
+
+
+# ── Filament SKU Settings (reorder forecasting) ───────────────────────────────
+
+
+class FilamentSkuSettingsResponse(BaseModel):
+    id: int
+    material: str
+    subtype: str | None
+    brand: str | None
+    lead_time_days: int
+    safety_margin_value: int
+    safety_margin_unit: str
+    alerts_snoozed: bool = False
+
+    class Config:
+        from_attributes = True
+
+
+class FilamentSkuSettingsUpsert(BaseModel):
+    material: str
+    subtype: str | None = None
+    brand: str | None = None
+    lead_time_days: int = 0
+    safety_margin_value: int = 14
+    safety_margin_unit: str = "days"
+    alerts_snoozed: bool = False
+
+
+@router.get("/sku-settings", response_model=list[FilamentSkuSettingsResponse])
+async def list_sku_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
+):
+    """List all filament SKU reorder settings."""
+    from backend.app.models.filament_sku_settings import FilamentSkuSettings
+
+    result = await db.execute(
+        select(FilamentSkuSettings).order_by(FilamentSkuSettings.material, FilamentSkuSettings.brand)
+    )
+    return list(result.scalars().all())
+
+
+@router.post("/sku-settings", response_model=FilamentSkuSettingsResponse)
+async def upsert_sku_settings(
+    data: FilamentSkuSettingsUpsert,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequireAnyPermissionIfAuthEnabled(
+        Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
+    ),
+):
+    """Create or update reorder settings for a filament SKU (material/subtype/brand)."""
+    from backend.app.models.filament_sku_settings import FilamentSkuSettings
+
+    result = await db.execute(
+        select(FilamentSkuSettings).where(
+            FilamentSkuSettings.material == data.material,
+            FilamentSkuSettings.subtype == data.subtype,
+            FilamentSkuSettings.brand == data.brand,
+        )
+    )
+    row = result.scalar_one_or_none()
+    if row:
+        row.lead_time_days = data.lead_time_days
+        row.safety_margin_value = data.safety_margin_value
+        row.safety_margin_unit = data.safety_margin_unit
+        row.alerts_snoozed = data.alerts_snoozed
+    else:
+        row = FilamentSkuSettings(
+            material=data.material,
+            subtype=data.subtype,
+            brand=data.brand,
+            lead_time_days=data.lead_time_days,
+            safety_margin_value=data.safety_margin_value,
+            safety_margin_unit=data.safety_margin_unit,
+            alerts_snoozed=data.alerts_snoozed,
+        )
+        db.add(row)
+    await db.commit()
+    await db.refresh(row)
+    return row
+
+
+# ── Shopping List ─────────────────────────────────────────────────────────────
+
+
+class ShoppingListItemResponse(BaseModel):
+    id: int
+    material: str
+    subtype: str | None
+    brand: str | None
+    quantity_spools: int
+    note: str | None
+    status: str
+    purchased_at: str | None
+    added_at: str
+
+    class Config:
+        from_attributes = True
+
+
+class ShoppingListItemCreate(BaseModel):
+    material: str
+    subtype: str | None = None
+    brand: str | None = None
+    quantity_spools: int = 1
+    note: str | None = None
+
+
+class ShoppingListItemStatusUpdate(BaseModel):
+    status: str  # pending | purchased | received
+
+
+@router.get("/shopping-list", response_model=list[ShoppingListItemResponse])
+async def get_shopping_list(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
+):
+    """Get the filament shopping list."""
+    from backend.app.models.shopping_list import ShoppingListItem
+
+    result = await db.execute(select(ShoppingListItem).order_by(ShoppingListItem.added_at.desc()))
+    items = result.scalars().all()
+    return [
+        ShoppingListItemResponse(
+            id=i.id,
+            material=i.material,
+            subtype=i.subtype,
+            brand=i.brand,
+            quantity_spools=i.quantity_spools,
+            note=i.note,
+            status=i.status or "pending",
+            purchased_at=i.purchased_at.isoformat() if i.purchased_at else None,
+            added_at=i.added_at.isoformat() if i.added_at else "",
+        )
+        for i in items
+    ]
+
+
+@router.post("/shopping-list", response_model=ShoppingListItemResponse)
+async def add_to_shopping_list(
+    data: ShoppingListItemCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequireAnyPermissionIfAuthEnabled(
+        Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
+    ),
+):
+    """Add a filament SKU to the shopping list."""
+    from backend.app.models.shopping_list import ShoppingListItem
+
+    item = ShoppingListItem(
+        material=data.material,
+        subtype=data.subtype,
+        brand=data.brand,
+        quantity_spools=data.quantity_spools,
+        note=data.note,
+    )
+    db.add(item)
+    await db.commit()
+    await db.refresh(item)
+    return ShoppingListItemResponse(
+        id=item.id,
+        material=item.material,
+        subtype=item.subtype,
+        brand=item.brand,
+        quantity_spools=item.quantity_spools,
+        note=item.note,
+        status=item.status or "pending",
+        purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
+        added_at=item.added_at.isoformat() if item.added_at else "",
+    )
+
+
+@router.patch("/shopping-list/{item_id}/status", response_model=ShoppingListItemResponse)
+async def update_shopping_list_status(
+    item_id: int,
+    data: ShoppingListItemStatusUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequireAnyPermissionIfAuthEnabled(
+        Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
+    ),
+):
+    """Update the purchase status of a shopping list item."""
+    from datetime import datetime, timezone
+
+    from backend.app.models.shopping_list import ShoppingListItem
+
+    if data.status not in ("pending", "purchased", "received"):
+        raise HTTPException(400, "Invalid status")
+
+    result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(404, "Item not found")
+
+    item.status = data.status
+    if data.status in ("purchased", "received") and item.purchased_at is None:
+        item.purchased_at = datetime.now(timezone.utc)
+    elif data.status == "pending":
+        item.purchased_at = None
+
+    await db.commit()
+    await db.refresh(item)
+    return ShoppingListItemResponse(
+        id=item.id,
+        material=item.material,
+        subtype=item.subtype,
+        brand=item.brand,
+        quantity_spools=item.quantity_spools,
+        note=item.note,
+        status=item.status or "pending",
+        purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
+        added_at=item.added_at.isoformat() if item.added_at else "",
+    )
+
+
+@router.delete("/shopping-list/{item_id}")
+async def remove_from_shopping_list(
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequireAnyPermissionIfAuthEnabled(
+        Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
+    ),
+):
+    """Remove a single item from the shopping list."""
+    from backend.app.models.shopping_list import ShoppingListItem
+
+    result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(404, "Item not found")
+    await db.delete(item)
+    await db.commit()
+    return {"status": "deleted"}
+
+
+@router.delete("/shopping-list")
+async def clear_shopping_list(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequireAnyPermissionIfAuthEnabled(
+        Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
+    ),
+):
+    """Clear all items from the shopping list."""
+    from backend.app.models.shopping_list import ShoppingListItem
+
+    result = await db.execute(delete(ShoppingListItem).returning(ShoppingListItem.id))
+    deleted = len(result.fetchall())
+    await db.commit()
+    return {"deleted": deleted}

+ 4 - 3
backend/app/api/routes/kprofiles.py

@@ -113,9 +113,10 @@ async def set_kprofile(
     if not client or not client.state.connected:
         raise HTTPException(400, "Printer not connected")
 
-    # Detect dual-nozzle families by serial number prefix
-    # H2D series: "094"; X2D series: "20P9"
-    is_h2d = printer.serial_number.startswith(("094", "20P9"))
+    # Detect dual-nozzle families by serial number prefix.
+    # H2 series: legacy "094"; post-2026 H2C batches ship with "31B8B" (#1105).
+    # X2D series: "20P9".
+    is_h2d = printer.serial_number.startswith(("094", "20P9", "31B8B"))
 
     if is_edit and is_h2d:
         # H2D in-place edit: use cali_idx with slot_id=0 and empty setting_id

+ 211 - 0
backend/app/api/routes/labels.py

@@ -0,0 +1,211 @@
+"""Spool label printing routes (#809).
+
+Two endpoints, one per inventory backend:
+
+- ``POST /inventory/labels``  — local-DB spools
+- ``POST /spoolman/labels``   — Spoolman-backed spools
+
+Both accept ``{spool_ids: [int], template: str}`` and return a PDF stream.
+The QR code on each label deep-links to ``/inventory?spool=<id>`` so a phone
+scan jumps straight back into Bambuddy at that spool's row.
+"""
+
+from __future__ import annotations
+
+import io
+import logging
+from typing import Literal
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel, Field
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes.settings import get_setting
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.spool import Spool
+from backend.app.models.user import User
+from backend.app.services.label_renderer import LabelData, TemplateName, render_labels
+from backend.app.services.spoolman import get_spoolman_client
+from backend.app.utils.http import build_content_disposition
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(tags=["labels"])
+
+_VALID_TEMPLATES: tuple[TemplateName, ...] = (
+    "ams_30x15",
+    "box_40x30",
+    "box_62x29",
+    "avery_5160",
+    "avery_l7160",
+)
+
+# Cap how many labels can be requested in one go. Sane upper bound for the
+# largest realistic batch (an Avery sheet at 30/page × ~10 pages).
+MAX_LABELS_PER_REQUEST = 500
+
+
+class LabelRequest(BaseModel):
+    spool_ids: list[int] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
+    template: Literal["ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160"]
+
+
+def _split_extra_colors(raw: str | None) -> list[str] | None:
+    """Parse ``Spool.extra_colors`` (comma-separated hex tokens) into a list."""
+    if not raw:
+        return None
+    parts = [p.strip().lstrip("#") for p in raw.split(",") if p.strip()]
+    return parts or None
+
+
+async def _resolve_deeplink_base(request: Request, db: AsyncSession) -> str:
+    """Where the QR codes should point. Prefers `external_url` when set so a
+    phone scan reaches the user's public Bambuddy URL rather than an internal
+    address; falls back to the request's own scheme+host when no setting is
+    configured.
+    """
+    external = (await get_setting(db, "external_url") or "").strip().rstrip("/")
+    if external:
+        return external
+    return f"{request.url.scheme}://{request.url.netloc}"
+
+
+def _spool_to_label_data(spool: Spool, deeplink_base: str) -> LabelData:
+    name = spool.color_name or spool.slicer_filament_name or f"{spool.brand or ''} {spool.material}".strip()
+    return LabelData(
+        spool_id=spool.id,
+        name=name or spool.material,
+        material=spool.material,
+        brand=spool.brand,
+        subtype=spool.subtype,
+        rgba=spool.rgba,
+        extra_colors=_split_extra_colors(spool.extra_colors),
+        storage_location=getattr(spool, "storage_location", None),
+        deeplink_url=f"{deeplink_base}/inventory?spool={spool.id}",
+    )
+
+
+def _spoolman_dict_to_label_data(s: dict, deeplink_base: str) -> LabelData:
+    """Build LabelData from a raw Spoolman /spool response dict.
+
+    Spoolman models don't have a native 'spool name' — we derive it from the
+    embedded filament. Material and brand come from filament/vendor.
+    """
+    filament = s.get("filament") or {}
+    vendor = filament.get("vendor") or {}
+    fname = filament.get("name") or ""
+    material = filament.get("material") or ""
+    brand = vendor.get("name")
+    color_hex = filament.get("color_hex")
+    rgba = color_hex.lstrip("#") if isinstance(color_hex, str) else None
+
+    multi_colors = filament.get("multi_color_hexes")
+    extra: list[str] | None = None
+    if isinstance(multi_colors, str) and multi_colors.strip():
+        extra = [tok.strip().lstrip("#") for tok in multi_colors.split(",") if tok.strip()]
+    elif isinstance(multi_colors, list):
+        extra = [str(t).strip().lstrip("#") for t in multi_colors if str(t).strip()]
+
+    return LabelData(
+        spool_id=int(s.get("id", 0)),
+        name=fname or material or "Spool",
+        material=material or "",
+        brand=brand,
+        subtype=None,
+        rgba=rgba,
+        extra_colors=extra,
+        storage_location=s.get("location"),
+        deeplink_url=f"{deeplink_base}/inventory?spool={int(s.get('id', 0))}",
+    )
+
+
+def _stream_pdf(pdf: bytes, filename: str) -> StreamingResponse:
+    return StreamingResponse(
+        io.BytesIO(pdf),
+        media_type="application/pdf",
+        headers={
+            "Content-Disposition": build_content_disposition(filename, disposition="inline"),
+            "Content-Length": str(len(pdf)),
+            # PDFs are deterministic per request; tell the browser not to cache
+            # so re-printing after edits picks up the new data.
+            "Cache-Control": "no-store",
+        },
+    )
+
+
+@router.post("/inventory/labels")
+async def render_local_inventory_labels(
+    body: LabelRequest,
+    request: Request,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> StreamingResponse:
+    """Render labels for spools in the local inventory."""
+    if body.template not in _VALID_TEMPLATES:
+        raise HTTPException(400, f"Unknown template: {body.template}")
+
+    result = await db.execute(select(Spool).where(Spool.id.in_(body.spool_ids)))
+    spools = list(result.scalars().all())
+
+    found_ids = {s.id for s in spools}
+    missing = [sid for sid in body.spool_ids if sid not in found_ids]
+    if missing:
+        raise HTTPException(404, f"Spool(s) not found: {missing}")
+
+    # Preserve caller's order so an Avery sheet print matches the on-screen list.
+    ordered = sorted(spools, key=lambda s: body.spool_ids.index(s.id))
+
+    deeplink_base = await _resolve_deeplink_base(request, db)
+    data_list = [_spool_to_label_data(s, deeplink_base) for s in ordered]
+
+    pdf = render_labels(body.template, data_list)
+    filename = f"bambuddy-labels-{body.template}.pdf"
+    return _stream_pdf(pdf, filename)
+
+
+@router.post("/spoolman/labels")
+async def render_spoolman_labels(
+    body: LabelRequest,
+    request: Request,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> StreamingResponse:
+    """Render labels for spools tracked in Spoolman.
+
+    The Spoolman client doesn't expose a per-id endpoint, so this fetches the
+    full spool list and filters in-memory. For typical libraries (~50 spools)
+    that's negligible; for very large libraries this is the trade-off until
+    Spoolman gains a bulk filter.
+    """
+    if body.template not in _VALID_TEMPLATES:
+        raise HTTPException(400, f"Unknown template: {body.template}")
+
+    spoolman_on = (await get_setting(db, "spoolman_enabled") or "").lower() == "true"
+    if not spoolman_on:
+        raise HTTPException(400, "Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if client is None or not client.is_connected:
+        raise HTTPException(503, "Spoolman not reachable")
+
+    try:
+        all_spools = await client.get_spools()
+    except Exception as exc:
+        logger.warning("Spoolman fetch failed during label render: %s", exc)
+        raise HTTPException(502, "Failed to fetch spools from Spoolman") from exc
+
+    by_id = {int(s.get("id", 0)): s for s in all_spools if s.get("id") is not None}
+    missing = [sid for sid in body.spool_ids if sid not in by_id]
+    if missing:
+        raise HTTPException(404, f"Spool(s) not found in Spoolman: {missing}")
+
+    deeplink_base = await _resolve_deeplink_base(request, db)
+    data_list = [_spoolman_dict_to_label_data(by_id[sid], deeplink_base) for sid in body.spool_ids]
+
+    pdf = render_labels(body.template, data_list)
+    filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
+    return _stream_pdf(pdf, filename)

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 1136 - 26
backend/app/api/routes/library.py


+ 281 - 0
backend/app/api/routes/library_trash.py

@@ -0,0 +1,281 @@
+"""Library trash bin + admin purge endpoints (#1008).
+
+Permission model:
+
+* **Admin purge** (``/library/purge/*``) and **retention settings**
+  (``/library/trash/settings``) require :attr:`Permission.LIBRARY_PURGE` —
+  admin-only.
+* **Per-user trash** (list / restore / hard-delete / empty own trash) is
+  gated by the existing :attr:`Permission.LIBRARY_DELETE_ALL` /
+  :attr:`Permission.LIBRARY_DELETE_OWN` ownership pair, so a regular user
+  sees their own trashed files and an admin sees everyone's.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import timedelta
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.user import User
+from backend.app.schemas.library_trash import (
+    EmptyTrashResponse,
+    PurgePreviewResponse,
+    PurgeRequest,
+    PurgeResponse,
+    TrashFile,
+    TrashListResponse,
+    TrashSettings,
+)
+from backend.app.services.library_trash import (
+    MAX_RETENTION_DAYS,
+    MIN_RETENTION_DAYS,
+    library_trash_service,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/library", tags=["library-trash"])
+
+
+# ===================== Admin purge =====================
+
+
+@router.get("/purge/preview", response_model=PurgePreviewResponse)
+async def preview_purge(
+    older_than_days: int = Query(ge=1, le=3650),
+    include_never_printed: bool = True,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
+):
+    """Preview how many files would move to trash for the given age threshold.
+
+    Read-only — safe to call repeatedly as the admin adjusts the slider.
+    """
+    result = await library_trash_service.preview_purge(
+        db,
+        older_than_days=older_than_days,
+        include_never_printed=include_never_printed,
+    )
+    return PurgePreviewResponse(**result)
+
+
+@router.post("/purge", response_model=PurgeResponse)
+async def execute_purge(
+    body: PurgeRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
+):
+    """Move matching files to trash. Idempotent — already-trashed rows skip."""
+    moved = await library_trash_service.purge_older_than(
+        db,
+        older_than_days=body.older_than_days,
+        include_never_printed=body.include_never_printed,
+    )
+    return PurgeResponse(moved_to_trash=moved)
+
+
+# ===================== Trash list + per-item ops =====================
+
+
+@router.get("/trash", response_model=TrashListResponse)
+async def list_trash(
+    limit: int = Query(default=100, ge=1, le=500),
+    offset: int = Query(default=0, ge=0),
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
+):
+    """List trashed files.
+
+    Admins (``LIBRARY_DELETE_ALL``) see every user's trash; regular users
+    (``LIBRARY_DELETE_OWN``) see only rows they created.
+    """
+    user, can_modify_all = auth_result
+    retention_days = await library_trash_service.get_retention_days(db)
+
+    # Base query: trashed files + their folder name (for the UI) + creator.
+    base_conditions = [LibraryFile.deleted_at.isnot(None)]
+    if not can_modify_all:
+        if user is None:
+            # Defensive: ownership checker only returns user=None when auth is off,
+            # in which case can_modify_all=True. If we somehow land here, err safe.
+            raise HTTPException(status_code=403, detail="Authentication required")
+        base_conditions.append(LibraryFile.created_by_id == user.id)
+
+    total_result = await db.execute(select(func.count(LibraryFile.id)).where(*base_conditions))
+    total = int(total_result.scalar() or 0)
+
+    rows_result = await db.execute(
+        select(LibraryFile, LibraryFolder.name, User.username)
+        .outerjoin(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
+        .outerjoin(User, LibraryFile.created_by_id == User.id)
+        .where(*base_conditions)
+        .order_by(LibraryFile.deleted_at.desc())
+        .limit(limit)
+        .offset(offset)
+    )
+
+    items: list[TrashFile] = []
+    for file, folder_name, username in rows_result.all():
+        # deleted_at is not-null by construction above; narrow for the typechecker.
+        assert file.deleted_at is not None
+        auto_purge_at = file.deleted_at + timedelta(days=retention_days)
+        items.append(
+            TrashFile(
+                id=file.id,
+                filename=file.filename,
+                file_size=file.file_size,
+                thumbnail_path=file.thumbnail_path,
+                folder_id=file.folder_id,
+                folder_name=folder_name,
+                created_by_id=file.created_by_id,
+                created_by_username=username,
+                deleted_at=file.deleted_at,
+                auto_purge_at=auto_purge_at,
+            )
+        )
+
+    return TrashListResponse(items=items, total=total, retention_days=retention_days)
+
+
+async def _load_trashed_file(
+    db: AsyncSession,
+    file_id: int,
+    user: User | None,
+    can_modify_all: bool,
+) -> LibraryFile:
+    """Fetch a trashed file, enforcing ownership for non-admins."""
+    result = await db.execute(
+        select(LibraryFile).where(
+            LibraryFile.id == file_id,
+            LibraryFile.deleted_at.isnot(None),
+        )
+    )
+    file = result.scalar_one_or_none()
+    if file is None:
+        raise HTTPException(status_code=404, detail="Trashed file not found")
+    if not can_modify_all:
+        if user is None or file.created_by_id != user.id:
+            raise HTTPException(status_code=403, detail="You can only manage your own trashed files")
+    return file
+
+
+@router.post("/trash/{file_id}/restore")
+async def restore_from_trash(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
+):
+    user, can_modify_all = auth_result
+    file = await _load_trashed_file(db, file_id, user, can_modify_all)
+    await library_trash_service.restore(db, file)
+    return {"status": "success", "id": file.id}
+
+
+@router.delete("/trash/{file_id}")
+async def hard_delete_from_trash(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
+):
+    """Permanently delete a single trashed file + its bytes. Irreversible."""
+    user, can_modify_all = auth_result
+    file = await _load_trashed_file(db, file_id, user, can_modify_all)
+    await library_trash_service.hard_delete_now(db, file)
+    return {"status": "success"}
+
+
+@router.delete("/trash", response_model=EmptyTrashResponse)
+async def empty_trash(
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
+):
+    """Permanently delete all trashed files in the caller's scope.
+
+    Regular users empty only their own trash; admins empty everyone's.
+    """
+    user, can_modify_all = auth_result
+    conditions = [LibraryFile.deleted_at.isnot(None)]
+    if not can_modify_all:
+        if user is None:
+            raise HTTPException(status_code=403, detail="Authentication required")
+        conditions.append(LibraryFile.created_by_id == user.id)
+
+    rows_result = await db.execute(select(LibraryFile).where(*conditions))
+    rows = rows_result.scalars().all()
+    deleted = 0
+    for row in rows:
+        await library_trash_service.hard_delete_now(db, row)
+        deleted += 1
+    return EmptyTrashResponse(deleted=deleted)
+
+
+# ===================== Retention settings (admin only) =====================
+
+
+@router.get("/trash/settings", response_model=TrashSettings)
+async def get_trash_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
+):
+    retention = await library_trash_service.get_retention_days(db)
+    auto = await library_trash_service.get_auto_purge_settings(db)
+    return TrashSettings(
+        retention_days=retention,
+        auto_purge_enabled=auto["enabled"],
+        auto_purge_days=auto["days"],
+        auto_purge_include_never_printed=auto["include_never_printed"],
+    )
+
+
+@router.put("/trash/settings", response_model=TrashSettings)
+async def update_trash_settings(
+    body: TrashSettings,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_PURGE)),
+):
+    if body.retention_days < MIN_RETENTION_DAYS or body.retention_days > MAX_RETENTION_DAYS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"retention_days must be between {MIN_RETENTION_DAYS} and {MAX_RETENTION_DAYS}",
+        )
+    saved_retention = await library_trash_service.set_retention_days(db, body.retention_days)
+    saved_auto = await library_trash_service.set_auto_purge_settings(
+        db,
+        enabled=body.auto_purge_enabled,
+        days=body.auto_purge_days,
+        include_never_printed=body.auto_purge_include_never_printed,
+    )
+    return TrashSettings(
+        retention_days=saved_retention,
+        auto_purge_enabled=saved_auto["enabled"],
+        auto_purge_days=saved_auto["days"],
+        auto_purge_include_never_printed=saved_auto["include_never_printed"],
+    )

+ 433 - 0
backend/app/api/routes/makerworld.py

@@ -0,0 +1,433 @@
+"""MakerWorld integration routes.
+
+User pastes a MakerWorld URL → Bambuddy resolves it → shows plate list →
+one-click import/print. The URL-paste flow covers the actual discovery
+pattern (Reddit/YouTube/shared links) without needing to replicate
+MakerWorld's whole search UI.
+
+Search/browse endpoints are intentionally NOT exposed: the public-facing
+``design/search`` endpoint returns empty results from server-originated
+requests (see memory/makerworld-integration.md for the investigation).
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from urllib.parse import unquote
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import Response
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes.cloud import get_stored_token
+from backend.app.api.routes.library import save_3mf_bytes_to_library
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.user import User
+from backend.app.schemas.makerworld import (
+    MakerWorldImportRequest,
+    MakerWorldImportResponse,
+    MakerWorldRecentImport,
+    MakerWorldResolvedModel,
+    MakerWorldResolveRequest,
+    MakerWorldStatus,
+)
+from backend.app.services.makerworld import (
+    MakerWorldAuthError,
+    MakerWorldError,
+    MakerWorldForbiddenError,
+    MakerWorldNotFoundError,
+    MakerWorldService,
+    MakerWorldUnavailableError,
+    MakerWorldUrlError,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/makerworld", tags=["makerworld"])
+
+_SOURCE_TYPE = "makerworld"
+
+
+async def _build_service(db: AsyncSession, user: User | None) -> MakerWorldService:
+    """Construct a per-request MakerWorldService seeded with the caller's
+    stored Bambu Cloud bearer token when available.
+
+    Mirrors ``cloud.build_authenticated_cloud`` — the token is entirely
+    optional; anonymous calls (metadata, URL resolution) still work.
+    """
+    token, _email, _region = await get_stored_token(db, user)
+    return MakerWorldService(auth_token=token)
+
+
+def _canonical_url(model_id: int, profile_id: int | None = None) -> str:
+    """Build a stable source_url we use for dedupe.
+
+    Dedupe is keyed per *plate* (profile) rather than per model, since the
+    ``/iot-service/.../profile/{profileId}`` download returns a specific
+    plate — not the full multi-plate zip — so two different plates of the
+    same design should become two separate library entries. Canonical
+    shape uses the locale-free path with the ``#profileId-`` fragment so
+    all URL variants of the same plate still collapse (e.g. ``/en/models/
+    123-slug?from=search#profileId-456`` and ``/de/models/123#profileId-
+    456`` both map to ``https://makerworld.com/models/123#profileId-
+    456``). Plate-less imports (legacy or whole-design) keep the old
+    model-only shape for backwards compatibility with existing rows.
+    """
+    if profile_id:
+        return f"https://makerworld.com/models/{model_id}#profileId-{profile_id}"
+    return f"https://makerworld.com/models/{model_id}"
+
+
+def _map_service_error(exc: MakerWorldError) -> HTTPException:
+    """Translate service exceptions into HTTP responses."""
+    if isinstance(exc, MakerWorldUrlError):
+        return HTTPException(status_code=400, detail=str(exc))
+    if isinstance(exc, MakerWorldAuthError):
+        return HTTPException(status_code=401, detail=str(exc))
+    if isinstance(exc, MakerWorldForbiddenError):
+        # 403 forwards MakerWorld's own refusal message (content-gated,
+        # region-locked, requires points, etc.) — UI surfaces it verbatim.
+        return HTTPException(status_code=403, detail=str(exc))
+    if isinstance(exc, MakerWorldNotFoundError):
+        return HTTPException(status_code=404, detail=str(exc))
+    if isinstance(exc, MakerWorldUnavailableError):
+        return HTTPException(status_code=502, detail=str(exc))
+    return HTTPException(status_code=500, detail=f"MakerWorld error: {exc}")
+
+
+@router.get("/thumbnail")
+async def proxy_thumbnail(
+    url: str = Query(..., description="MakerWorld CDN image URL (makerworld.bblmw.com or public-cdn.bblmw.com)"),
+):
+    """Proxy a MakerWorld CDN thumbnail.
+
+    The SPA's ``img-src`` CSP only allows ``'self' data: blob:`` — hotlinking
+    from makerworld.bblmw.com is blocked. This endpoint refetches the image
+    server-side and returns it with a long cache window.
+
+    **Unauthenticated on purpose**: ``<img>`` tags can't send Authorization
+    headers, so requiring a Bearer token here would break the whole feature
+    (browsers would get 401 on every image, rendering as broken-image
+    placeholders). The thumbnails being proxied are MakerWorld's *public*
+    CDN — any visitor to makerworld.com can fetch them without auth — so no
+    data is exposed. The SSRF guard inside ``fetch_thumbnail`` restricts
+    the upstream host to the MakerWorld CDN allowlist, so this can't be
+    abused as a generic open proxy.
+
+    URLs are content-addressable (filename contains a hash), so the
+    aggressive ``immutable`` cache-control is safe.
+    """
+    service = MakerWorldService()
+    try:
+        payload, content_type = await service.fetch_thumbnail(url)
+    except MakerWorldError as exc:
+        raise _map_service_error(exc) from exc
+    finally:
+        await service.close()
+
+    return Response(
+        content=payload,
+        media_type=content_type,
+        headers={
+            "Cache-Control": "public, max-age=86400, immutable",
+        },
+    )
+
+
+@router.get("/status", response_model=MakerWorldStatus)
+async def get_status(
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
+):
+    """Report whether the caller can import 3MFs (needs a Bambu Cloud token)."""
+    token, _email, _region = await get_stored_token(db, current_user)
+    has_token = bool(token)
+    return MakerWorldStatus(has_cloud_token=has_token, can_download=has_token)
+
+
+@router.post("/resolve", response_model=MakerWorldResolvedModel)
+async def resolve_url(
+    body: MakerWorldResolveRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
+):
+    """Resolve a MakerWorld URL to full model metadata + plate list.
+
+    The response also tells the caller which (if any) LibraryFile rows already
+    exist for the same model URL, so the UI can show an "Already imported"
+    badge and skip a redundant download.
+    """
+    try:
+        model_id, profile_id = MakerWorldService.parse_url(body.url)
+    except MakerWorldError as exc:
+        raise _map_service_error(exc) from exc
+
+    service = await _build_service(db, current_user)
+    try:
+        design = await service.get_design(model_id)
+        instances_envelope = await service.get_design_instances(model_id)
+    except MakerWorldError as exc:
+        raise _map_service_error(exc) from exc
+    finally:
+        await service.close()
+
+    # MakerWorld's instances payload is ``{"total": N, "hits": [...]}``; callers
+    # only care about the hits, and we normalise the null case to an empty list
+    # so the frontend doesn't have to handle null vs [] both ways.
+    instances = instances_envelope.get("hits") or []
+    if not isinstance(instances, list):
+        instances = []
+
+    # /instances/hits omits the per-instance printer compatibility info that
+    # /design.instances[].extention.modelInfo carries (compatibility +
+    # otherCompatibility). Merge it in so the frontend can show "this
+    # instance was sliced for A1" + "also marked compatible with: H2D, P1S,
+    # …" before the user picks one — without that, every instance row looks
+    # identical in the UI and users blindly pick the first one regardless of
+    # whether it matches their printer.
+    design_instances = design.get("instances") or []
+    if isinstance(design_instances, list):
+        compat_by_id = {}
+        for di in design_instances:
+            if not isinstance(di, dict):
+                continue
+            iid = di.get("id")
+            if iid is None:
+                continue
+            ext = (di.get("extention") or {}).get("modelInfo") or {}
+            compat_by_id[iid] = {
+                "compatibility": ext.get("compatibility"),
+                "otherCompatibility": ext.get("otherCompatibility"),
+            }
+        for inst in instances:
+            if not isinstance(inst, dict):
+                continue
+            iid = inst.get("id")
+            extra = compat_by_id.get(iid)
+            if extra:
+                inst["compatibility"] = extra["compatibility"]
+                inst["otherCompatibility"] = extra["otherCompatibility"]
+
+    # Find every library row whose source_url is either the model-level
+    # canonical URL (legacy whole-model imports) or any plate-level URL
+    # (``...#profileId-{n}``) under this model. The frontend surfaces this
+    # to mark imported plates in the instance picker.
+    model_prefix = _canonical_url(model_id)
+    existing_q = await db.execute(
+        select(LibraryFile.id).where(
+            (LibraryFile.source_url == model_prefix) | (LibraryFile.source_url.like(f"{model_prefix}#profileId-%")),
+            LibraryFile.deleted_at.is_(None),
+        )
+    )
+    already_imported = [row[0] for row in existing_q.all()]
+
+    return MakerWorldResolvedModel(
+        model_id=model_id,
+        profile_id=profile_id,
+        design=design,
+        instances=instances,
+        already_imported_library_ids=already_imported,
+    )
+
+
+@router.post("/import", response_model=MakerWorldImportResponse)
+async def import_instance(
+    body: MakerWorldImportRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_IMPORT),
+):
+    """Download a specific MakerWorld instance (plate configuration) and save
+    the 3MF into the library.
+
+    De-duplicates by canonicalised source URL — if the same MakerWorld model
+    was imported before (any plate), that existing LibraryFile is returned and
+    no new download happens.
+    """
+    if body.folder_id is not None:
+        folder_q = await db.execute(select(LibraryFolder).where(LibraryFolder.id == body.folder_id))
+        target_folder = folder_q.scalar_one_or_none()
+        if target_folder is None:
+            raise HTTPException(status_code=404, detail="Folder not found")
+        if target_folder.is_external and target_folder.external_readonly:
+            raise HTTPException(
+                status_code=403,
+                detail="Cannot import into a read-only external folder",
+            )
+        effective_folder_id: int | None = body.folder_id
+    else:
+        # Default destination: a dedicated top-level "MakerWorld" folder. Keeps
+        # imports out of the library root so power users can still organise
+        # manually in subfolders, and auto-creates the folder on the first
+        # import so users don't have to set it up themselves.
+        mw_folder_q = await db.execute(
+            select(LibraryFolder).where(
+                LibraryFolder.name == "MakerWorld",
+                LibraryFolder.parent_id.is_(None),
+                LibraryFolder.is_external.is_(False),
+            )
+        )
+        mw_folder = mw_folder_q.scalar_one_or_none()
+        if mw_folder is None:
+            mw_folder = LibraryFolder(name="MakerWorld", parent_id=None)
+            db.add(mw_folder)
+            await db.flush()
+        effective_folder_id = mw_folder.id
+
+    service = await _build_service(db, current_user)
+
+    # YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
+    # (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N}.
+    # Fetch design metadata to resolve it, and — in the same call — pick a
+    # default profileId from the response if the frontend didn't specify one.
+    try:
+        design = await service.get_design(body.model_id)
+    except MakerWorldError as exc:
+        await service.close()
+        raise _map_service_error(exc) from exc
+
+    alphanumeric_model_id = design.get("modelId")
+    if not isinstance(alphanumeric_model_id, str) or not alphanumeric_model_id:
+        await service.close()
+        raise HTTPException(
+            status_code=502,
+            detail="MakerWorld design metadata missing the modelId field",
+        )
+
+    profile_id = body.profile_id
+    if profile_id is None:
+        for instance in design.get("instances") or []:
+            pid = instance.get("profileId")
+            if isinstance(pid, int) and pid > 0:
+                profile_id = pid
+                break
+        if profile_id is None:
+            try:
+                envelope = await service.get_design_instances(body.model_id)
+            except MakerWorldError as exc:
+                await service.close()
+                raise _map_service_error(exc) from exc
+            for hit in envelope.get("hits") or []:
+                pid = hit.get("profileId")
+                if isinstance(pid, int) and pid > 0:
+                    profile_id = pid
+                    break
+        if profile_id is None:
+            await service.close()
+            raise HTTPException(
+                status_code=502,
+                detail="MakerWorld returned no instances for this model",
+            )
+
+    # Canonical URL includes profile_id so each plate gets its own library
+    # entry (see ``_canonical_url`` docstring).
+    source_url = _canonical_url(body.model_id, profile_id)
+
+    try:
+        manifest = await service.get_profile_download(profile_id, alphanumeric_model_id)
+    except MakerWorldError as exc:
+        await service.close()
+        raise _map_service_error(exc) from exc
+
+    signed_url = manifest.get("url")
+    # Basename-strip any path components from the upstream filename so a
+    # malicious response (``name: "../../evil.3mf"``) can't persist a suspect
+    # string into the library row or the UI. On-disk storage uses a UUID
+    # filename regardless (see library.py), so this is defence-in-depth.
+    raw_name = manifest.get("name")
+    if isinstance(raw_name, str) and raw_name.strip():
+        # MakerWorld emits percent-encoded names (`%20` for spaces, etc.)
+        # because the same string round-trips through HTTP URLs in the
+        # CDN download path. Decode before persisting so the library
+        # row, the slice toast, and every later UI surface show the
+        # human-readable form.
+        suggested_name = os.path.basename(unquote(raw_name.strip())) or f"makerworld-{body.model_id}.3mf"
+    else:
+        suggested_name = f"makerworld-{body.model_id}.3mf"
+    if not signed_url or not isinstance(signed_url, str):
+        await service.close()
+        raise HTTPException(status_code=502, detail="MakerWorld did not return a download URL")
+
+    # Dedupe check upfront so we don't burn bandwidth re-downloading.
+    if source_url:
+        existing_q = await db.execute(LibraryFile.active().where(LibraryFile.source_url == source_url).limit(1))
+        existing_row = existing_q.scalar_one_or_none()
+        if existing_row is not None:
+            await service.close()
+            return MakerWorldImportResponse(
+                library_file_id=existing_row.id,
+                filename=existing_row.filename,
+                folder_id=existing_row.folder_id,
+                profile_id=profile_id,
+                was_existing=True,
+            )
+
+    try:
+        file_bytes, download_filename = await service.download_3mf(signed_url)
+    except MakerWorldError as exc:
+        await service.close()
+        raise _map_service_error(exc) from exc
+    finally:
+        await service.close()
+
+    # Prefer the server-provided human-readable filename; the signed URL's
+    # path ends in a UUID that's not meaningful to users. Decode the
+    # fallback path-tail too — same percent-encoding round-trip applies
+    # there as on the manifest-supplied name.
+    filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download_filename)
+
+    library_file, was_existing = await save_3mf_bytes_to_library(
+        db,
+        file_bytes=file_bytes,
+        filename=filename,
+        folder_id=effective_folder_id,
+        source_type=_SOURCE_TYPE,
+        source_url=source_url,
+        owner_id=current_user.id if current_user else None,
+    )
+
+    return MakerWorldImportResponse(
+        library_file_id=library_file.id,
+        filename=library_file.filename,
+        folder_id=library_file.folder_id,
+        profile_id=profile_id,
+        was_existing=was_existing,
+    )
+
+
+@router.get("/recent-imports", response_model=list[MakerWorldRecentImport])
+async def recent_imports(
+    limit: int = 10,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
+):
+    """Last N MakerWorld imports, newest first.
+
+    Surfaces files whose ``source_type`` is ``"makerworld"`` so the MakerWorld
+    page can show a 'Recent imports' sidebar that persists across resolves.
+    ``limit`` is clamped to ``[1, 50]`` to keep payloads sensible.
+    """
+    _ = current_user  # permission gate only
+    capped = max(1, min(50, int(limit)))
+    result = await db.execute(
+        LibraryFile.active()
+        .where(LibraryFile.source_type == _SOURCE_TYPE)
+        .order_by(LibraryFile.created_at.desc())
+        .limit(capped)
+    )
+    rows = result.scalars().all()
+    return [
+        MakerWorldRecentImport(
+            library_file_id=row.id,
+            filename=row.filename,
+            folder_id=row.folder_id,
+            thumbnail_path=row.thumbnail_path,
+            source_url=row.source_url,
+            created_at=row.created_at.isoformat() if row.created_at else "",
+        )
+        for row in rows
+    ]

+ 255 - 40
backend/app/api/routes/mfa.py

@@ -58,6 +58,7 @@ from backend.app.models.user import User
 from backend.app.models.user_otp_code import UserOTPCode
 from backend.app.models.user_totp import UserTOTP
 from backend.app.schemas.auth import (
+    AUTO_LINK_REQUIREMENTS_ERROR,
     AdminDisable2FARequest,
     BackupCodesResponse,
     EmailOTPDisableRequest,
@@ -373,6 +374,125 @@ def _assert_totp_not_replayed(totp_obj: pyotp.TOTP, totp_record: UserTOTP, code:
     totp_record.accept_counter(accepted_counter)
 
 
+# ---------------------------------------------------------------------------
+# OIDC helpers
+# ---------------------------------------------------------------------------
+_EMAIL_SHAPE_RE = re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+")
+
+
+def _is_valid_email_shaped(value: str | None) -> bool:
+    # SEC-2: shape check for non-standard claims (upn, preferred_username).
+    # Requires local@domain.tld — rejects "@", "x@", "@domain", "x@nodot".
+    if not value or len(value) > 255:
+        return False
+    return _EMAIL_SHAPE_RE.fullmatch(value) is not None
+
+
+def _enforce_auto_link_safety(provider: OIDCProvider) -> None:
+    """Raise HTTP 422 if auto_link_existing_accounts is on with an unsafe combined state.
+
+    SEC-1: only Fall B (email_claim='email' + require_email_verified=False) is unsafe —
+    an attacker-controlled IdP could present an unverified email that matches a local account.
+    Fall C (custom claim) never performs an email_verified check, so auto_link is safe there.
+    Called after ORM construction (create) and after the setattr loop (update).
+    """
+    if provider.auto_link_existing_accounts and provider.email_claim == "email" and not provider.require_email_verified:
+        raise HTTPException(
+            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+            detail=AUTO_LINK_REQUIREMENTS_ERROR,
+        )
+
+
+def _resolve_provider_email(provider: OIDCProvider, claims: dict, provider_sub: str) -> str | None:
+    """Extract and normalise the email address from OIDC ID-token claims.
+
+    Implements three resolution paths (Fall A/B/C):
+      Fall C — custom email_claim (!= "email"): shape-check only, no email_verified gate.
+               Recommended for Azure Entra ID (preferred_username or upn).
+      Fall A — email_claim="email" + require_email_verified=True: strict, email_verified must be True.
+      Fall B — email_claim="email" + require_email_verified=False: permissive, explicit False drops email.
+
+    Returns a lowercase-stripped email string, or None when the claim is absent/invalid.
+    """
+    provider_id = provider.id
+    raw_claim_value = claims.get(provider.email_claim)
+    if raw_claim_value is not None and not isinstance(raw_claim_value, str):
+        # TYPE-GUARD: non-string claim (e.g. list, int) would raise AttributeError on .lower().
+        logger.warning(
+            "OIDC provider %d: email_claim %r has unexpected type %s for sub=%r, ignoring",
+            provider_id,
+            provider.email_claim,
+            type(raw_claim_value).__name__,
+            provider_sub,
+        )
+        raw_claim_value = None
+    raw_email: str | None = raw_claim_value.lower().strip() if raw_claim_value else None
+
+    if provider.email_claim != "email":
+        # Fall C: custom claim (preferred_username, upn, …) — no email_verified check.
+        # SEC-2: _is_valid_email_shaped instead of bare '"@" in value'.
+        # Recommended for Azure Entra ID: set email_claim="preferred_username" or "upn".
+        if raw_email and _is_valid_email_shaped(raw_email):
+            return raw_email
+        if raw_email:
+            logger.warning(
+                "OIDC provider %d: email_claim %r value failed shape check for sub=%r, ignoring",
+                provider_id,
+                provider.email_claim,
+                provider_sub,
+            )
+        return None
+
+    email_verified = claims.get("email_verified")
+    if provider.require_email_verified:
+        # Fall A: standard C1-Guard — fail closed unless email_verified is True.
+        # SEC-2: apply shape check to standard email claim — providers may set
+        # email_verified=True on non-email values (e.g. numeric user IDs).
+        # SEC-3 normalisation applies; existing mixed-case provider_email records
+        # were normalised to lowercase by run_migrations at startup.
+        if raw_email and not _is_valid_email_shaped(raw_email):
+            logger.warning(
+                "OIDC provider %d: email claim failed shape check for sub=%r, ignoring",
+                provider_id,
+                provider_sub,
+            )
+            return None
+        if email_verified is True:
+            return raw_email
+        if raw_email:
+            logger.info(
+                "OIDC provider %d: ignoring email for sub=%r because email_verified=%r",
+                provider_id,
+                provider_sub,
+                email_verified,
+            )
+        return None
+
+    # Fall B: permissive — explicit False drops email, absent/None keeps it.
+    # Required for Azure Entra ID which never sends email_verified.
+    # SEC-2: apply shape check before the email_verified=False drop so malformed
+    # values are rejected regardless of the email_verified claim.
+    if raw_email and not _is_valid_email_shaped(raw_email):
+        logger.warning(
+            "OIDC provider %d: email claim failed shape check for sub=%r, ignoring",
+            provider_id,
+            provider_sub,
+        )
+        return None
+    if email_verified is False:
+        return None
+    if email_verified is not True:
+        # SEC-5: log only when the permissive path actually fires (ev absent/None),
+        # not on every successful login.
+        logger.info(
+            "OIDC provider %r (%d): accepting email for sub=%r without email_verified claim (permissive mode)",
+            provider.name,
+            provider.id,
+            provider_sub,
+        )
+    return raw_email
+
+
 # ---------------------------------------------------------------------------
 # Settings helpers (email 2FA flag)
 # ---------------------------------------------------------------------------
@@ -435,14 +555,27 @@ async def setup_totp(
     if existing and existing.is_enabled:
         await check_rate_limit(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
         supplied_code = (body.code if body else None) or ""
-        if not pyotp.TOTP(existing.secret).verify(supplied_code, valid_window=1):
+        # S4: narrow the RuntimeError catch to ONLY the property access — that
+        # is the single line that raises on key-loss. The previous wide try
+        # block also covered record_failed_attempt, clear_failed_attempts,
+        # and _assert_totp_not_replayed, so a future RuntimeError from any
+        # of those would have been misreported as "TOTP secret unavailable".
+        try:
+            secret_plain = existing.secret
+        except RuntimeError:
+            logger.exception("TOTP decryption failed for user_id=%s", current_user.id)
+            raise HTTPException(
+                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+                detail="TOTP secret unavailable",
+            )
+        if not pyotp.TOTP(secret_plain).verify(supplied_code, valid_window=1):
             await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Current TOTP code required to replace an active authenticator",
             )
         await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
-        _assert_totp_not_replayed(pyotp.TOTP(existing.secret), existing, supplied_code)
+        _assert_totp_not_replayed(pyotp.TOTP(secret_plain), existing, supplied_code)
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
 
     secret = pyotp.random_base32()
@@ -484,7 +617,12 @@ async def enable_totp(
             status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP setup not initiated. Call /auth/2fa/totp/setup first."
         )
 
-    if not pyotp.TOTP(totp_record.secret).verify(body.code, valid_window=1):
+    try:
+        totp_verify = pyotp.TOTP(totp_record.secret).verify(body.code, valid_window=1)
+    except RuntimeError:
+        logger.exception("TOTP decryption failed for user_id=%s", totp_record.user_id)
+        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="TOTP secret unavailable")
+    if not totp_verify:
         await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP code")
 
@@ -518,10 +656,25 @@ async def disable_totp(
     if not totp_record or not totp_record.is_enabled:
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled")
 
-    # Accept either a valid TOTP code or a valid backup code
-    totp_obj = pyotp.TOTP(totp_record.secret)
-    code_valid = totp_obj.verify(body.code, valid_window=1)
-    if code_valid:
+    # Accept either a valid TOTP code or a valid backup code. When the secret
+    # cannot be decrypted (encryption key lost), fall through to the backup-
+    # code path so the user can still disable 2FA with their printed codes.
+    totp_obj: pyotp.TOTP | None = None
+    code_valid = False
+    decryption_failed = False
+    try:
+        totp_obj = pyotp.TOTP(totp_record.secret)
+        code_valid = totp_obj.verify(body.code, valid_window=1)
+    except RuntimeError:
+        # S3: track that the failure was server-side so we don't penalise
+        # the user with a fail-counter increment for a problem they can't fix.
+        decryption_failed = True
+        logger.exception(
+            "TOTP decryption failed for user_id=%s — falling through to backup-code check",
+            totp_record.user_id,
+        )
+
+    if code_valid and totp_obj is not None:
         _assert_totp_not_replayed(totp_obj, totp_record, body.code)
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
     else:
@@ -532,7 +685,12 @@ async def disable_totp(
                 code_valid = True
 
     if not code_valid:
-        await record_failed_attempt(db, current_user.username)
+        # S3: skip the fail-counter debit when the cause was a server-side
+        # decryption failure (key loss / rotation). The user submitted a
+        # wrong backup code on top of a broken TOTP, but locking them out
+        # of the recovery path for an admin's mistake is not the right move.
+        if not decryption_failed:
+            await record_failed_attempt(db, current_user.username)
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid code")
 
     await db.execute(delete(UserTOTP).where(UserTOTP.user_id == current_user.id))
@@ -560,9 +718,24 @@ async def regenerate_backup_codes(
     if not totp_record or not totp_record.is_enabled:
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled")
 
-    totp_obj = pyotp.TOTP(totp_record.secret)
-    code_valid = totp_obj.verify(body.code, valid_window=1)
-    if code_valid:
+    # Same recovery contract as disable_totp: when the TOTP secret cannot be
+    # decrypted, fall through to the backup-code branch so the user can
+    # rotate their codes with a printed backup code.
+    totp_obj: pyotp.TOTP | None = None
+    code_valid = False
+    decryption_failed = False
+    try:
+        totp_obj = pyotp.TOTP(totp_record.secret)
+        code_valid = totp_obj.verify(body.code, valid_window=1)
+    except RuntimeError:
+        # S3: track server-side failure so we skip the fail-counter debit.
+        decryption_failed = True
+        logger.exception(
+            "TOTP decryption failed for user_id=%s — falling through to backup-code check",
+            totp_record.user_id,
+        )
+
+    if code_valid and totp_obj is not None:
         _assert_totp_not_replayed(totp_obj, totp_record, body.code)
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
     else:
@@ -572,7 +745,10 @@ async def regenerate_backup_codes(
             if pwd_context.verify(body.code, hashed) and matched_index is None:
                 matched_index = idx
         if matched_index is None:
-            await record_failed_attempt(db, current_user.username)
+            # S3: skip fail-counter debit when the cause was a server-side
+            # decryption failure (key loss / rotation).
+            if not decryption_failed:
+                await record_failed_attempt(db, current_user.username)
             raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP or backup code")
         # Remove the used backup code
         totp_record.backup_code_hashes = [c for i, c in enumerate(totp_record.backup_code_hashes) if i != matched_index]
@@ -868,7 +1044,11 @@ async def verify_2fa(
         if not totp_record or not totp_record.is_enabled:
             await record_failed_attempt(db, username)
             raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled for this user")
-        totp_obj = pyotp.TOTP(totp_record.secret)
+        try:
+            totp_obj = pyotp.TOTP(totp_record.secret)
+        except RuntimeError:
+            logger.exception("TOTP decryption failed for user_id=%s", totp_record.user_id)
+            raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="TOTP secret unavailable")
         if not totp_obj.verify(body.code, valid_window=1):
             await record_failed_attempt(db, username)
             raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid TOTP code")
@@ -1048,6 +1228,13 @@ async def create_oidc_provider(
     db: AsyncSession = Depends(get_db),
 ) -> OIDCProviderResponse:
     """Create a new OIDC provider (admin only)."""
+    if body.default_group_id is not None:
+        grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
+        if not grp_chk.scalar_one_or_none():
+            raise HTTPException(
+                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+                detail="default_group_id references a non-existent group",
+            )
     provider = OIDCProvider(
         name=body.name,
         issuer_url=body.issuer_url.rstrip("/"),
@@ -1056,8 +1243,15 @@ async def create_oidc_provider(
         scopes=body.scopes,
         is_enabled=body.is_enabled,
         auto_create_users=body.auto_create_users,
+        auto_link_existing_accounts=body.auto_link_existing_accounts,
+        email_claim=body.email_claim,
+        require_email_verified=body.require_email_verified,
         icon_url=body.icon_url,
+        default_group_id=body.default_group_id,
     )
+    # SEC-1 + SEC-6: runtime guard mirrors the OIDCProviderCreate model_validator in schemas/auth.py.
+    # Catches any future path that bypasses Pydantic validation (direct ORM, scripts).
+    _enforce_auto_link_safety(provider)
     db.add(provider)
     await db.commit()
     await db.refresh(provider)
@@ -1077,11 +1271,24 @@ async def update_oidc_provider(
     if not provider:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
 
+    if body.default_group_id is not None:
+        grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
+        if not grp_chk.scalar_one_or_none():
+            raise HTTPException(
+                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+                detail="default_group_id references a non-existent group",
+            )
+
     for field, value in body.model_dump(exclude_none=True).items():
         if field == "issuer_url" and value:
             value = value.rstrip("/")
         setattr(provider, field, value)
 
+    # SEC-1 + SEC-6: Combined-State-Guard after setattr loop.
+    # Checks the final in-memory state (DB values + newly set values combined) to catch
+    # partial updates that each pass schema validation individually but are unsafe together.
+    _enforce_auto_link_safety(provider)
+
     await db.commit()
     await db.refresh(provider)
     return OIDCProviderResponse.model_validate(provider)
@@ -1370,23 +1577,8 @@ async def oidc_callback(
         if not provider_sub:
             return RedirectResponse(url=f"{frontend_error_url}missing_sub_claim", status_code=302)
 
-        # C1: Only trust the email claim when the provider explicitly marks it verified.
-        # Treating absent email_verified as verified enables account-takeover: an attacker
-        # could register an unverified email with an IdP and auto-link to an existing account.
-        # Fail closed: require email_verified == True; absent/False both drop the email.
-        raw_email: str | None = claims.get("email")
-        email_verified = claims.get("email_verified")
-        if email_verified is not True:
-            if raw_email:
-                logger.info(
-                    "OIDC provider %d: ignoring email for sub=%r because email_verified=%r",
-                    provider_id,
-                    provider_sub,
-                    email_verified,
-                )
-            provider_email: str | None = None
-        else:
-            provider_email = raw_email
+        # SEC-3: resolve email via Fall A/B/C logic (see _resolve_provider_email).
+        provider_email = _resolve_provider_email(provider, claims, provider_sub)
 
         # ── Step 4: Resolve / create user ────────────────────────────────────
         try:
@@ -1456,7 +1648,21 @@ async def oidc_callback(
                     if provider_email:
                         raw = provider_email.split("@")[0]
                     else:
-                        raw = provider_sub[:30]
+                        # Prefer a human-readable IdP claim over the opaque sub.
+                        # isinstance guards are required: claims may carry non-string
+                        # values (e.g. a list) that would break .strip().
+                        # Sanitization is applied per-candidate so that a value that
+                        # strips to empty (e.g. "!!!") correctly falls through to the
+                        # next candidate rather than silently becoming "oidcuser".
+                        _pref = claims.get("preferred_username")
+                        _name = claims.get("name")
+                        raw = ""
+                        if isinstance(_pref, str):
+                            raw = re.sub(r"[^a-zA-Z0-9._-]", "", _pref.strip())[:30]
+                        if not raw and isinstance(_name, str):
+                            raw = re.sub(r"[^a-zA-Z0-9._-]", "", _name.strip())[:30]
+                        if not raw:
+                            raw = provider_sub[:30]
                     candidate = re.sub(r"[^a-zA-Z0-9._-]", "", raw)[:30] or "oidcuser"
 
                     username = candidate
@@ -1468,13 +1674,21 @@ async def oidc_callback(
                         username = f"{candidate}{counter}"
                         counter += 1
 
-                    # I9: Assign new OIDC users to the default "Viewers" group so they
-                    # have read-only access rather than starting with no permissions.
-                    # Fetch the group BEFORE creating the user so we can set the
-                    # relationship before flush — accessing new_user.groups after a
-                    # flush triggers a lazy-load which fails in async context.
-                    viewers_result = await db.execute(select(Group).where(Group.name == "Viewers"))
-                    viewers_group = viewers_result.scalar_one_or_none()
+                    # I9: Assign new OIDC users to a group before flush — accessing
+                    # new_user.groups after a flush triggers a lazy-load which fails
+                    # in async context.  Resolution order:
+                    #   1. provider.default_group_id (operator-configured)
+                    #   2. "Viewers" (system fallback for read-only access)
+                    #   3. no group (last resort if Viewers was deleted)
+                    # SQLite does not enforce ON DELETE SET NULL, so a dangling
+                    # default_group_id returns None here and falls through to Viewers.
+                    default_group: Group | None = None
+                    if provider.default_group_id is not None:
+                        dg_result = await db.execute(select(Group).where(Group.id == provider.default_group_id))
+                        default_group = dg_result.scalar_one_or_none()
+                    if default_group is None:
+                        viewers_result = await db.execute(select(Group).where(Group.name == "Viewers"))
+                        default_group = viewers_result.scalar_one_or_none()
 
                     new_user = User(
                         username=username,
@@ -1485,7 +1699,7 @@ async def oidc_callback(
                         password_hash=None,  # OIDC users never use password auth
                         role="user",
                         is_active=True,
-                        groups=[viewers_group] if viewers_group else [],
+                        groups=[default_group] if default_group else [],
                     )
                     db.add(new_user)
                     await db.flush()
@@ -1549,7 +1763,8 @@ async def oidc_callback(
         logger.error("Unexpected error in OIDC callback (%s): %s", type(exc).__name__, exc, exc_info=True)
         try:
             return RedirectResponse(url=f"{frontend_error_url}internal_error", status_code=302)
-        except Exception:
+        except Exception as redirect_exc:
+            logger.error("Failed to construct error redirect in OIDC callback: %s", redirect_exc, exc_info=True)
             raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="OIDC callback failed")
 
 

+ 56 - 3
backend/app/api/routes/pending_uploads.py

@@ -13,7 +13,7 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.user import User
-from backend.app.services.archive import ArchiveService
+from backend.app.services.archive import ArchiveService, resolve_display_stem
 
 router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
 
@@ -31,6 +31,7 @@ class PendingUploadResponse(BaseModel):
 
     id: int
     filename: str
+    display_name: str  # Resolved name that mirrors the eventual archive's print_name (#1152 follow-up)
     file_size: int
     source_ip: str | None
     status: str
@@ -43,6 +44,50 @@ class PendingUploadResponse(BaseModel):
         from_attributes = True
 
 
+def _resolve_display_name(pending: PendingUpload, prefer_filename: bool) -> str:
+    """Compute the name the review card should show, matching what archive_print
+    will eventually write to ``PrintArchive.print_name`` so the user sees the
+    same name in both places (#1152 follow-up).
+
+    Mirrors ``ArchiveService.archive_print``:
+      - ``prefer_filename=True`` → stripped filename stem.
+      - ``prefer_filename=False`` → ``metadata_print_name`` if set, else stem.
+    """
+    stem = resolve_display_stem(pending.filename)
+    if prefer_filename:
+        return stem
+    return (pending.metadata_print_name or "").strip() or stem
+
+
+async def _augment_with_display_name(
+    db: AsyncSession,
+    pendings: list[PendingUpload],
+) -> list[PendingUploadResponse]:
+    """Build response objects with display_name resolved against the toggle.
+
+    Reads the ``virtual_printer_archive_name_source`` setting once per request
+    rather than per row.
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    prefer_filename = (await get_setting(db, "virtual_printer_archive_name_source")) == "filename"
+    return [
+        PendingUploadResponse(
+            id=p.id,
+            filename=p.filename,
+            display_name=_resolve_display_name(p, prefer_filename),
+            file_size=p.file_size,
+            source_ip=p.source_ip,
+            status=p.status,
+            tags=p.tags,
+            notes=p.notes,
+            project_id=p.project_id,
+            uploaded_at=p.uploaded_at,
+        )
+        for p in pendings
+    ]
+
+
 @router.get("/", response_model=list[PendingUploadResponse])
 async def list_pending_uploads(
     db: AsyncSession = Depends(get_db),
@@ -53,7 +98,7 @@ async def list_pending_uploads(
         select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc())
     )
 
-    return result.scalars().all()
+    return await _augment_with_display_name(db, list(result.scalars().all()))
 
 
 @router.get("/count")
@@ -78,6 +123,8 @@ async def archive_all_pending(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
     """Archive all pending uploads."""
+    from backend.app.api.routes.settings import get_setting
+
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     pending_uploads = result.scalars().all()
 
@@ -85,6 +132,7 @@ async def archive_all_pending(
     failed = 0
 
     service = ArchiveService(db)
+    prefer_filename = (await get_setting(db, "virtual_printer_archive_name_source")) == "filename"
 
     for pending in pending_uploads:
         file_path = Path(pending.file_path)
@@ -102,6 +150,7 @@ async def archive_all_pending(
                     "source": "virtual_printer",
                     "source_ip": pending.source_ip,
                 },
+                prefer_filename_for_name=prefer_filename,
             )
 
             if archive:
@@ -168,7 +217,7 @@ async def get_pending_upload(
     if not pending:
         raise HTTPException(status_code=404, detail="Upload not found")
 
-    return pending
+    return (await _augment_with_display_name(db, [pending]))[0]
 
 
 @router.post("/{upload_id}/archive")
@@ -193,6 +242,9 @@ async def archive_pending_upload(
         raise HTTPException(status_code=404, detail="Upload file not found on disk")
 
     # Archive the file
+    from backend.app.api.routes.settings import get_setting
+
+    prefer_filename = (await get_setting(db, "virtual_printer_archive_name_source")) == "filename"
     service = ArchiveService(db)
     archive = await service.archive_print(
         printer_id=None,
@@ -202,6 +254,7 @@ async def archive_pending_upload(
             "source": "virtual_printer",
             "source_ip": pending.source_ip,
         },
+        prefer_filename_for_name=prefer_filename,
     )
 
     if not archive:

+ 1 - 1
backend/app/api/routes/print_queue.py

@@ -383,7 +383,7 @@ async def add_to_queue(
     # Validate library file exists (if provided) and get it for filament extraction
     library_file = None
     if data.library_file_id:
-        result = await db.execute(select(LibraryFile).where(LibraryFile.id == data.library_file_id))
+        result = await db.execute(LibraryFile.active().where(LibraryFile.id == data.library_file_id))
         library_file = result.scalar_one_or_none()
         if not library_file:
             raise HTTPException(400, "Library file not found")

+ 463 - 81
backend/app/api/routes/printers.py

@@ -19,6 +19,7 @@ from backend.app.schemas.printer import (
     AmsLabelBody,
     AMSTray,
     AMSUnit,
+    FilaSwitchResponse,
     HMSErrorResponse,
     NozzleInfoResponse,
     NozzleRackSlot,
@@ -39,11 +40,12 @@ from backend.app.services.bambu_ftp import (
 )
 from backend.app.services.printer_manager import (
     get_derived_status_name,
-    parse_plate_id,
     printer_manager,
+    resolve_plate_id,
     supports_chamber_temp,
     supports_drying,
 )
+from backend.app.utils.http import build_content_disposition
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -299,6 +301,7 @@ async def delete_printer(
 
     from backend.app.models.archive import PrintArchive
     from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance
+    from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -316,6 +319,9 @@ async def delete_printer(
 
         await db.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
 
+    # Delete slot assignments for this printer (SQLite doesn't enforce FK cascades)
+    await db.execute(sql_delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id))
+
     # Delete maintenance history and items for this printer
     # (SQLite doesn't enforce FK cascades, so do it explicitly)
     maintenance_ids = (
@@ -569,7 +575,7 @@ async def get_printer_status(
     current_archive_id: int | None = None
     current_plate_id: int | None = None
     if state.state in ("RUNNING", "PAUSE"):
-        current_plate_id = parse_plate_id(state.gcode_file)
+        current_plate_id = resolve_plate_id(state)
         if state.subtask_id:
             from backend.app.models.archive import PrintArchive
 
@@ -635,6 +641,17 @@ async def get_printer_status(
         supports_drying=supports_drying(printer.model, state.firmware_version),
         current_archive_id=current_archive_id,
         current_plate_id=current_plate_id,
+        fila_switch=(
+            FilaSwitchResponse(
+                installed=state.fila_switch.installed,
+                in_slots=list(state.fila_switch.in_slots),
+                out_extruders=list(state.fila_switch.out_extruders),
+                stat=state.fila_switch.stat,
+                info=state.fila_switch.info,
+            )
+            if state.fila_switch and state.fila_switch.installed
+            else None
+        ),
     )
 
 
@@ -726,7 +743,9 @@ async def test_printer_connection(
     return result
 
 
-# Cache for cover images (printer_id -> {(subtask_name, plate_num, view) -> image_bytes})
+# Cache for cover images (printer_id -> {(subtask_name, view_key) -> image_bytes}).
+# Cleared on every print start by main.py::on_print_start, so re-dispatches with
+# different plates always fetch a fresh thumbnail without needing plate in the key.
 _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
 
 
@@ -762,21 +781,28 @@ async def get_printer_cover(
     if not subtask_name:
         raise HTTPException(404, f"No subtask_name in printer state (state={state.state})")
 
-    # Extract plate number from gcode_file (e.g., "/data/Metadata/plate_12.gcode" -> 12)
-    plate_num = 1
-    gcode_file = state.gcode_file
-    if gcode_file:
-        match = re.search(r"plate_(\d+)\.gcode", gcode_file)
-        if match:
-            plate_num = int(match.group(1))
-            logger.info("Detected plate number %s from gcode_file: %s", plate_num, gcode_file)
+    # Resolve the active plate. Precedence (#1166):
+    #   1. The plate Bambuddy dispatched (authoritative when we sent the print)
+    #   2. plate_(\d+)\.gcode regex on state.gcode_file (works on firmware that
+    #      reflects the full path, e.g. some X1C builds)
+    #   3. Scan the downloaded 3MF for a unique Metadata/plate_*.gcode (covers
+    #      per-plate archives sliced separately in Bambu Studio, where the
+    #      printer's gcode_file echo is just the .3mf filename)
+    #   4. Fall back to plate 1
+    # The 3MF-scan fallback runs later — after the file is on disk.
+    plate_num = resolve_plate_id(state)
+    if plate_num is not None:
+        logger.info("Cover: resolved plate %s before download (subtask=%s)", plate_num, subtask_name)
 
     # Normalize view parameter
     view_key = view or "default"
 
-    # Check cache - include plate_num in cache key for multi-plate projects
+    # Check cache. Cache by (subtask_name, view_key) only — clear_cover_cache()
+    # runs on every print start, so a re-dispatch with a different plate gets
+    # a fresh image regardless. Pre-#1166 the key included plate_num, but with
+    # late plate resolution the cache check would always miss.
     if printer_id in _cover_cache:
-        cache_key = (subtask_name, plate_num, view_key)
+        cache_key = (subtask_name, view_key)
         if cache_key in _cover_cache[printer_id]:
             return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
 
@@ -895,6 +921,21 @@ async def get_printer_cover(
             raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.")
 
         try:
+            # 3MF-scan fallback for plate detection (#1166). Per-plate archives
+            # sliced separately in Bambu Studio contain a single
+            # Metadata/plate_N.gcode for the active plate, even though
+            # thumbnails for all plates are bundled. Using that gcode's plate
+            # number prevents falling back to plate_1.png.
+            if plate_num is None:
+                plate_gcodes = [name for name in zf.namelist() if re.match(r"^Metadata/plate_\d+\.gcode$", name)]
+                if len(plate_gcodes) == 1:
+                    match = re.search(r"plate_(\d+)\.gcode", plate_gcodes[0])
+                    if match:
+                        plate_num = int(match.group(1))
+                        logger.info("Cover: detected plate %s from 3MF contents", plate_num)
+            if plate_num is None:
+                plate_num = 1
+
             # Try common thumbnail paths in 3MF files
             # Use plate_num to get the correct plate's thumbnail for multi-plate projects
             # Use top-down view if requested (better for skip objects modal)
@@ -922,10 +963,9 @@ async def get_printer_cover(
             for thumb_path in thumbnail_paths:
                 try:
                     image_data = zf.read(thumb_path)
-                    # Cache the result - include plate_num in cache key
                     if printer_id not in _cover_cache:
                         _cover_cache[printer_id] = {}
-                    _cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data
+                    _cover_cache[printer_id][(subtask_name, view_key)] = image_data
                     return Response(content=image_data, media_type="image/png")
                 except KeyError:
                     continue
@@ -936,7 +976,7 @@ async def get_printer_cover(
                     image_data = zf.read(name)
                     if printer_id not in _cover_cache:
                         _cover_cache[printer_id] = {}
-                    _cover_cache[printer_id][(subtask_name, plate_num, view_key)] = image_data
+                    _cover_cache[printer_id][(subtask_name, view_key)] = image_data
                     return Response(content=image_data, media_type="image/png")
 
             raise HTTPException(404, "No thumbnail found in 3MF file")
@@ -1018,7 +1058,7 @@ async def download_printer_file(
     return Response(
         content=data,
         media_type=content_type,
-        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+        headers={"Content-Disposition": build_content_disposition(filename)},
     )
 
 
@@ -1905,6 +1945,7 @@ async def configure_ams_slot(
     kprofile_filament_id: str = Query(""),
     kprofile_setting_id: str = Query(""),
     k_value: float = Query(0.0),
+    db: AsyncSession = Depends(get_db),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
     """Configure an AMS slot with a specific filament setting and K profile.
@@ -2025,6 +2066,39 @@ async def configure_ams_slot(
     # Send filament setting + K-profile commands
     filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else effective_tray_info_idx
 
+    # Realign the slot's filament context to the K-profile's calibration
+    # context. The printer's calibration table is keyed by (filament_id,
+    # cali_idx) — so for the cali_idx selected via extrusion_cali_sel to
+    # actually stick to the slot, ams_filament_setting must declare the
+    # slot under the SAME filament_id.
+    #
+    # Without this, configure_ams_slot would send:
+    #   ams_filament_setting → tray_info_idx=GFL99 (generic from material)
+    #   extrusion_cali_sel    → filament_id=P4d64437 (kp's preset)
+    # ...and the cali_idx would silently be dropped to default because the
+    # slot's filament context (GFL99) doesn't match the kp's (P4d64437).
+    #
+    # This realignment fires only when the kp is targeted at a different
+    # preset than the user's filament selection AND the kp's preset is a
+    # valid tray_info_idx (GF* official, P* local — not PFUS* cloud-user
+    # which the slicer rejects in tray_info_idx).
+    effective_setting_id = setting_id
+    if (
+        kprofile_filament_id
+        and kprofile_filament_id != effective_tray_info_idx
+        and not kprofile_filament_id.startswith("PFUS")
+    ):
+        logger.info(
+            "[configure_ams_slot] realigning slot filament context to kp: tray_info_idx %r → %r, setting_id %r → %r",
+            effective_tray_info_idx,
+            kprofile_filament_id,
+            setting_id,
+            kprofile_setting_id or setting_id,
+        )
+        effective_tray_info_idx = kprofile_filament_id
+        if kprofile_setting_id:
+            effective_setting_id = kprofile_setting_id
+
     # Always send ams_set_filament_setting — the user explicitly clicked
     # "Configure Slot", so honor that.  Previous versions skipped this for
     # RFID-tagged slots to preserve the slicer eye icon, but printers cache
@@ -2039,7 +2113,7 @@ async def configure_ams_slot(
         tray_color=tray_color,
         nozzle_temp_min=nozzle_temp_min,
         nozzle_temp_max=nozzle_temp_max,
-        setting_id=setting_id,
+        setting_id=effective_setting_id,
     )
 
     if not success:
@@ -2082,6 +2156,144 @@ async def configure_ams_slot(
             cali_idx=cali_idx,
         )
 
+    # Persist the user's K-profile choice so it survives RFID re-reads and
+    # session restarts. Pre-Phase-13 this was ephemeral — the MQTT command
+    # took effect on the printer but bambuddy never recorded it, so the next
+    # `_apply_pa_after_refresh` cycle had no stored profile to re-assert.
+    if cali_idx >= 0:
+        try:
+            from sqlalchemy.orm import selectinload
+
+            from backend.app.models.spool_assignment import SpoolAssignment
+            from backend.app.models.spool_k_profile import SpoolKProfile
+            from backend.app.models.spoolman_k_profile import SpoolmanKProfile
+            from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+            # Resolve slot's extruder index for the K-profile match key. Same
+            # logic as _apply_pa_after_refresh: external slots invert tray→extruder,
+            # AMS slots come from ams_extruder_map. Falls back to 0 (single-nozzle).
+            slot_state = printer_manager.get_status(printer_id)
+            slot_extruder: int | None = None
+            if slot_state and slot_state.ams_extruder_map:
+                if ams_id == 255:
+                    slot_extruder = 1 - tray_id
+                else:
+                    slot_extruder = slot_state.ams_extruder_map.get(str(ams_id))
+            kp_extruder = slot_extruder if slot_extruder is not None else 0
+
+            # Spoolman SlotAssignment first — has UniqueConstraint, idempotent.
+            sm_result = await db.execute(
+                select(SpoolmanSlotAssignment).where(
+                    SpoolmanSlotAssignment.printer_id == printer_id,
+                    SpoolmanSlotAssignment.ams_id == ams_id,
+                    SpoolmanSlotAssignment.tray_id == tray_id,
+                )
+            )
+            sm_assignment = sm_result.scalar_one_or_none()
+            if sm_assignment:
+                existing = await db.execute(
+                    select(SpoolmanKProfile).where(
+                        SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id,
+                        SpoolmanKProfile.printer_id == printer_id,
+                        SpoolmanKProfile.extruder == kp_extruder,
+                        SpoolmanKProfile.nozzle_diameter == nozzle_diameter,
+                    )
+                )
+                kp = existing.scalar_one_or_none()
+                if kp:
+                    kp.cali_idx = cali_idx
+                    kp.k_value = k_value or 0.0
+                    kp.setting_id = kprofile_setting_id or None
+                    kp.name = tray_sub_brands or None
+                else:
+                    db.add(
+                        SpoolmanKProfile(
+                            spoolman_spool_id=sm_assignment.spoolman_spool_id,
+                            printer_id=printer_id,
+                            extruder=kp_extruder,
+                            nozzle_diameter=nozzle_diameter,
+                            k_value=k_value or 0.0,
+                            name=tray_sub_brands or None,
+                            cali_idx=cali_idx,
+                            setting_id=kprofile_setting_id or None,
+                        )
+                    )
+                await db.commit()
+                logger.info(
+                    "[configure_ams_slot] Persisted Spoolman K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
+                    sm_assignment.spoolman_spool_id,
+                    printer_id,
+                    ams_id,
+                    tray_id,
+                    cali_idx,
+                )
+            else:
+                # Local SpoolAssignment + SpoolKProfile (no UNIQUE — use .first())
+                local_result = await db.execute(
+                    select(SpoolAssignment)
+                    .options(selectinload(SpoolAssignment.spool))
+                    .where(
+                        SpoolAssignment.printer_id == printer_id,
+                        SpoolAssignment.ams_id == ams_id,
+                        SpoolAssignment.tray_id == tray_id,
+                    )
+                )
+                local_assignment = local_result.scalar_one_or_none()
+                if local_assignment and local_assignment.spool:
+                    existing = await db.execute(
+                        select(SpoolKProfile).where(
+                            SpoolKProfile.spool_id == local_assignment.spool.id,
+                            SpoolKProfile.printer_id == printer_id,
+                            SpoolKProfile.extruder == kp_extruder,
+                            SpoolKProfile.nozzle_diameter == nozzle_diameter,
+                        )
+                    )
+                    # SpoolKProfile has no unique constraint on this tuple, so
+                    # multiple rows could theoretically exist (shouldn't, but
+                    # don't crash if they do). Update the first match, leave
+                    # any duplicates alone.
+                    kp = existing.scalars().first()
+                    if kp:
+                        kp.cali_idx = cali_idx
+                        kp.k_value = k_value or 0.0
+                        kp.setting_id = kprofile_setting_id or None
+                        kp.name = tray_sub_brands or None
+                    else:
+                        db.add(
+                            SpoolKProfile(
+                                spool_id=local_assignment.spool.id,
+                                printer_id=printer_id,
+                                extruder=kp_extruder,
+                                nozzle_diameter=nozzle_diameter,
+                                k_value=k_value or 0.0,
+                                name=tray_sub_brands or None,
+                                cali_idx=cali_idx,
+                                setting_id=kprofile_setting_id or None,
+                            )
+                        )
+                    await db.commit()
+                    logger.info(
+                        "[configure_ams_slot] Persisted local K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
+                        local_assignment.spool.id,
+                        printer_id,
+                        ams_id,
+                        tray_id,
+                        cali_idx,
+                    )
+        except Exception:
+            # MQTT command was already sent successfully — DB persist is best-effort.
+            logger.exception(
+                "[configure_ams_slot] Failed to persist K-profile (printer=%d ams=%d tray=%d cali_idx=%d)",
+                printer_id,
+                ams_id,
+                tray_id,
+                cali_idx,
+            )
+            try:
+                await db.rollback()
+            except Exception:
+                pass
+
     # Request fresh status push from printer so frontend gets updated data via WebSocket
     logger.info("[configure_ams_slot] Requesting status update from printer")
     update_result = client.request_status_update()
@@ -2323,6 +2535,17 @@ async def stop_print(
     if not success:
         raise HTTPException(500, "Failed to stop print")
 
+    # Mark this printer as user-stopped so on_print_complete reclassifies
+    # the resulting "failed"/"aborted" MQTT status as "cancelled" — otherwise
+    # the HMS heuristic in _dispatch_archive_update mislabels user-cancels
+    # (e.g. the H2D's cancel-sequence module-0x0C HMS) as "Layer shift".
+    try:
+        from backend.app.main import mark_printer_stopped_by_user
+
+        mark_printer_stopped_by_user(printer_id)
+    except Exception as _mark_err:
+        logger.warning("Failed to mark printer %s as user-stopped: %s", printer_id, _mark_err)
+
     return {"success": True, "message": "Print stop command sent"}
 
 
@@ -2799,7 +3022,17 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
         from backend.app.core.database import async_session
         from backend.app.models.spool import Spool
         from backend.app.models.spool_assignment import SpoolAssignment as SA
-        from backend.app.services.spool_tag_matcher import is_bambu_tag
+        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
+        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+        from backend.app.services.spool_tag_matcher import (
+            ZERO_TAG_UID,
+            ZERO_TRAY_UUID,
+            is_bambu_tag,
+        )
+        from backend.app.utils.tag_normalization import (
+            normalize_tag_uid,
+            normalize_tray_uuid,
+        )
 
         client = printer_manager.get_client(printer_id)
         if not client:
@@ -2825,88 +3058,237 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
         if not is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
             return
 
+        # Compute nozzle/extruder once — used by both local and Spoolman lookup.
+        nozzle_diameter = "0.4"
+        if state.nozzles:
+            nd = state.nozzles[0].nozzle_diameter
+            if nd:
+                nozzle_diameter = nd
+
+        slot_extruder = None
+        if state.ams_extruder_map:
+            if ams_id == 255:
+                # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
+                slot_extruder = 1 - slot_id
+            else:
+                slot_extruder = state.ams_extruder_map.get(str(ams_id))
+
+        # 3-stage K-profile cascade: local SpoolKProfile → Spoolman SpoolmanKProfile
+        # → live tray.cali_idx fallback. Pre-Phase-13 only handled the local path
+        # and exited silently if no SpoolKProfile match; Spoolman-assigned slots
+        # were ignored entirely and live cali_idx was never re-asserted.
+        matching_cali_idx: int | None = None
+        matching_filament_id: str = tray_info_idx
+
         async with async_session() as db:
-            from sqlalchemy import select as sa_select
+            from sqlalchemy import or_, select as sa_select
             from sqlalchemy.orm import selectinload
 
+            # Stage 1: local SpoolAssignment + SpoolKProfile match
             result = await db.execute(
                 sa_select(SA)
                 .options(selectinload(SA.spool).selectinload(Spool.k_profiles))
                 .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id)
             )
             assignment = result.scalar_one_or_none()
-            if not assignment or not assignment.spool or not assignment.spool.k_profiles:
-                return
-
-            spool = assignment.spool
-            nozzle_diameter = "0.4"
-            if state.nozzles:
-                nd = state.nozzles[0].nozzle_diameter
-                if nd:
-                    nozzle_diameter = nd
-
-            # Determine slot's extruder from ams_extruder_map
-            slot_extruder = None
-            if state.ams_extruder_map:
-                if ams_id == 255:
-                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
-                    slot_extruder = 1 - slot_id  # 0→1, 1→0
-                else:
-                    slot_extruder = state.ams_extruder_map.get(str(ams_id))
-
-            matching_kp = None
-            for kp in spool.k_profiles:
-                if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:
-                    if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder:
+            spool: Spool | None = assignment.spool if assignment else None
+
+            # Stage 1b: tag-based fallback. The slot may have just been reset
+            # (SpoolAssignment row deleted) before the user triggered a re-read.
+            # The live tray already carries the spool's tray_uuid/tag_uid from
+            # the RFID re-read, but the SA row hasn't been re-created yet.
+            # Without this fallback we miss the stored SpoolKProfile and Stage 3
+            # ends up re-asserting whatever cali_idx the firmware reset to
+            # (typically the default profile).
+            if spool is None:
+                norm_uuid = normalize_tray_uuid(tray_uuid) if tray_uuid else ""
+                norm_tag = normalize_tag_uid(tag_uid) if tag_uid else ""
+                tag_filters = []
+                if norm_uuid and norm_uuid != ZERO_TRAY_UUID:
+                    tag_filters.append(Spool.tray_uuid == norm_uuid)
+                if norm_tag and norm_tag != ZERO_TAG_UID:
+                    tag_filters.append(Spool.tag_uid == norm_tag)
+                if tag_filters:
+                    tag_lookup = await db.execute(
+                        sa_select(Spool).options(selectinload(Spool.k_profiles)).where(or_(*tag_filters)).limit(1)
+                    )
+                    spool = tag_lookup.scalar_one_or_none()
+                    if spool is not None:
+                        logger.info(
+                            "PA re-apply AMS%d-T%d: matched spool %d via tag fallback "
+                            "(SpoolAssignment row missing, likely after slot reset)",
+                            ams_id,
+                            slot_id,
+                            spool.id,
+                        )
+
+            if spool is not None and spool.k_profiles:
+                # Prefer exact extruder match, fall back to extruder-agnostic kp
+                # for the same printer + nozzle. Hard-skipping on extruder
+                # mismatch made the cascade refuse perfectly valid stored
+                # profiles whenever the AMS-extruder mapping had shifted since
+                # calibration time, falling all the way through to Stage 3 and
+                # re-asserting the firmware default.
+                exact_kp = None
+                fallback_kp = None
+                for kp in spool.k_profiles:
+                    if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
                         continue
-                    matching_kp = kp
-                    break
-
-            if not matching_kp or matching_kp.cali_idx is None:
-                return
-
-            # The filament_id in extrusion_cali_sel must match the filament preset
-            # under which the K-profile was calibrated. Use spool.slicer_filament
-            # (the preset assigned in inventory), falling back to tray's RFID value.
-            kp_filament_id = spool.slicer_filament or tray_info_idx
-
-            logger.info(
-                "PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s",
+                    if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
+                        exact_kp = kp
+                        break
+                    if fallback_kp is None:
+                        fallback_kp = kp
+                chosen_kp = exact_kp or fallback_kp
+                if chosen_kp is not None:
+                    matching_cali_idx = chosen_kp.cali_idx
+                    # The filament_id in extrusion_cali_sel must match the preset
+                    # under which the K-profile was calibrated. Prefer the spool's
+                    # slicer_filament setting, falling back to the tray's RFID value.
+                    matching_filament_id = spool.slicer_filament or tray_info_idx
+
+            # Stage 2: Spoolman SpoolmanSlotAssignment + SpoolmanKProfile match
+            # (only when no local spool was matched — local takes priority,
+            # including the tag-based fallback above)
+            if matching_cali_idx is None and spool is None:
+                sm_result = await db.execute(
+                    sa_select(SpoolmanSlotAssignment).where(
+                        SpoolmanSlotAssignment.printer_id == printer_id,
+                        SpoolmanSlotAssignment.ams_id == ams_id,
+                        SpoolmanSlotAssignment.tray_id == slot_id,
+                    )
+                )
+                sm_assignment = sm_result.scalar_one_or_none()
+                if sm_assignment:
+                    kp_result = await db.execute(
+                        sa_select(SpoolmanKProfile).where(
+                            SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id,
+                            SpoolmanKProfile.printer_id == printer_id,
+                        )
+                    )
+                    for kp in kp_result.scalars().all():
+                        if kp.nozzle_diameter == nozzle_diameter:
+                            if slot_extruder is not None and kp.extruder is not None and kp.extruder != slot_extruder:
+                                continue
+                            if kp.cali_idx is not None:
+                                matching_cali_idx = kp.cali_idx
+                                # Spoolman has no slicer_filament — use the tray's RFID value
+                                matching_filament_id = tray_info_idx
+                            break
+
+        # Stage 3: live tray.cali_idx fallback. Re-asserts the printer's current
+        # selection so the value sticks across the RFID re-read (otherwise some
+        # firmwares clear cali_idx back to -1 mid-cycle).
+        if matching_cali_idx is None:
+            live_cali_idx = tray.get("cali_idx")
+            if live_cali_idx is not None and live_cali_idx >= 0:
+                matching_cali_idx = live_cali_idx
+
+        if matching_cali_idx is None:
+            logger.debug(
+                "PA re-apply AMS%d-T%d: no stored or live cali_idx — skipping MQTT",
                 ams_id,
                 slot_id,
-                matching_kp.cali_idx,
-                kp_filament_id,
             )
+            return
 
-            # 1. Select K-profile
-            # NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware
-            # "this is a manual config" which destroys the RFID-detected spool state
-            # (changes eye icon to pen icon in slicer).
-            client.extrusion_cali_sel(
-                ams_id=ams_id,
-                tray_id=slot_id,
-                cali_idx=matching_kp.cali_idx,
-                filament_id=kp_filament_id,
-                nozzle_diameter=nozzle_diameter,
-            )
+        logger.info(
+            "PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s",
+            ams_id,
+            slot_id,
+            matching_cali_idx,
+            matching_filament_id,
+        )
+
+        # NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware
+        # "this is a manual config" which destroys the RFID-detected spool state
+        # (changes eye icon to pen icon in slicer).
+        client.extrusion_cali_sel(
+            ams_id=ams_id,
+            tray_id=slot_id,
+            cali_idx=matching_cali_idx,
+            filament_id=matching_filament_id,
+            nozzle_diameter=nozzle_diameter,
+        )
 
-            # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
-            # selected the correct profile by cali_idx. Sending extrusion_cali_set with
-            # the same cali_idx would MODIFY the existing profile's metadata (extruder_id,
-            # nozzle_id, name), corrupting it.
+        # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
+        # selected the correct profile by cali_idx. Sending extrusion_cali_set with
+        # the same cali_idx would MODIFY the existing profile's metadata (extruder_id,
+        # nozzle_id, name), corrupting it.
 
-            logger.info(
-                "Applied PA profile cali_idx=%d k=%.3f to printer %d AMS%d-T%d",
-                matching_kp.cali_idx,
-                matching_kp.k_value or 0,
-                printer_id,
-                ams_id,
-                slot_id,
-            )
+        logger.info(
+            "Applied PA profile cali_idx=%d to printer %d AMS%d-T%d",
+            matching_cali_idx,
+            printer_id,
+            ams_id,
+            slot_id,
+        )
     except Exception as e:
         logger.warning("Failed to apply PA profile after RFID re-read: %s", e)
 
 
+@router.post("/{printer_id}/ams/load")
+async def ams_load(
+    printer_id: int,
+    tray_id: int = Query(..., description="Tray ID: 0-15 for AMS slots (ams_id*4+slot_id), 254 for external spool"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Load filament from a specific AMS slot or external spool.
+
+    Tray ID encoding (matches Bambu firmware convention):
+    - 0..15: AMS slot, computed as ams_id * 4 + slot_id
+    - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
+    - 255: Ext-R on dual-nozzle H2D
+    """
+    if tray_id not in range(16) and tray_id not in (254, 255):
+        raise HTTPException(400, "tray_id must be 0..15 (AMS slot), 254 (external / Ext-L), or 255 (Ext-R)")
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.ams_load_filament(tray_id)
+    if not success:
+        raise HTTPException(500, "Failed to send load command")
+
+    if tray_id == 254:
+        target = "external spool"
+    elif tray_id == 255:
+        target = "Ext-R"
+    else:
+        target = f"AMS {tray_id // 4} slot {tray_id % 4 + 1}"
+    return {"success": True, "message": f"Loading filament from {target}"}
+
+
+@router.post("/{printer_id}/ams/unload")
+async def ams_unload(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Unload the currently loaded filament."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.ams_unload_filament()
+    if not success:
+        raise HTTPException(500, "Failed to send unload command")
+
+    return {"success": True, "message": "Unloading filament"}
+
+
 @router.get("/{printer_id}/runtime-debug")
 async def get_runtime_debug(
     printer_id: int,

+ 174 - 5
backend/app/api/routes/projects.py

@@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.library import get_library_dir
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -40,6 +40,7 @@ from backend.app.schemas.project import (
     ProjectUpdate,
     TimelineEvent,
 )
+from backend.app.utils.http import build_content_disposition
 
 logger = logging.getLogger(__name__)
 
@@ -255,6 +256,8 @@ async def list_projects(
                 queue_count=queue_count,
                 progress_percent=progress_percent,
                 archives=archive_previews,
+                url=project.url,
+                cover_image_filename=project.cover_image_filename,
             )
         )
 
@@ -289,6 +292,7 @@ async def create_project(
         priority=data.priority,
         budget=data.budget,
         parent_id=data.parent_id,
+        url=data.url,
     )
     db.add(project)
     await db.flush()
@@ -306,6 +310,8 @@ async def create_project(
         target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
+        url=project.url,
+        cover_image_filename=project.cover_image_filename,
         tags=project.tags,
         due_date=project.due_date,
         priority=project.priority,
@@ -355,6 +361,8 @@ async def list_templates(
                 queue_count=0,
                 progress_percent=None,
                 archives=[],
+                url=project.url,
+                cover_image_filename=project.cover_image_filename,
             )
         )
 
@@ -391,6 +399,7 @@ async def create_project_from_template(
         budget=template.budget,
         is_template=False,
         template_source_id=template.id,
+        url=template.url,
     )
     db.add(project)
     await db.flush()
@@ -428,6 +437,8 @@ async def create_project_from_template(
         target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
+        url=project.url,
+        cover_image_filename=project.cover_image_filename,
         tags=project.tags,
         due_date=project.due_date,
         priority=project.priority,
@@ -511,6 +522,8 @@ async def get_project(
         target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
+        url=project.url,
+        cover_image_filename=project.cover_image_filename,
         tags=project.tags,
         due_date=project.due_date,
         priority=project.priority,
@@ -567,6 +580,9 @@ async def update_project(
         project.priority = data.priority
     if "budget" in data.model_fields_set:
         project.budget = data.budget
+    if "url" in data.model_fields_set:
+        # Pydantic validator already guarantees http(s) prefix or None.
+        project.url = data.url
     if data.parent_id is not None:
         # Verify parent exists and prevent circular reference
         if data.parent_id == project_id:
@@ -603,6 +619,8 @@ async def update_project(
         target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
+        url=project.url,
+        cover_image_filename=project.cover_image_filename,
         tags=project.tags,
         due_date=project.due_date,
         priority=project.priority,
@@ -650,10 +668,15 @@ async def list_project_archives(
     if not result.scalar_one_or_none():
         raise HTTPException(status_code=404, detail="Project not found")
 
-    # Get archives with project relationship eagerly loaded
+    # Get archives with both ``project`` and ``created_by`` eagerly loaded.
+    # ``archive_to_response`` accesses ``archive.created_by.username`` to
+    # surface the creator on the archive card; without selectinload that's
+    # a lazy attribute access on a closed async session, which throws
+    # ``MissingGreenlet`` and produces a 500. ``ArchiveService.list_archives``
+    # already loads both — this route just got out of step.
     query = (
         select(PrintArchive)
-        .options(selectinload(PrintArchive.project))
+        .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
         .where(PrintArchive.project_id == project_id)
         .order_by(PrintArchive.created_at.desc())
         .limit(limit)
@@ -768,6 +791,19 @@ def get_project_attachments_dir(project_id: int) -> Path:
     return base_dir / "projects" / str(project_id) / "attachments"
 
 
+# Cover-image upload accepts only common web-renderable image types (#1155).
+# Subset of ALLOWED_ATTACHMENT_EXTENSIONS minus .svg/.ico because those don't
+# render well as a card thumbnail.
+COVER_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
+COVER_IMAGE_CONTENT_TYPES = {
+    ".jpg": "image/jpeg",
+    ".jpeg": "image/jpeg",
+    ".png": "image/png",
+    ".gif": "image/gif",
+    ".webp": "image/webp",
+}
+
+
 # Allowed file extensions for attachments
 ALLOWED_ATTACHMENT_EXTENSIONS = {
     # Images
@@ -985,6 +1021,132 @@ async def delete_attachment(
     }
 
 
+# ============ #1155: Cover image ============
+
+
+@router.post("/{project_id}/cover-image")
+async def upload_project_cover_image(
+    project_id: int,
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
+):
+    """Upload (or replace) the project's cover image (#1155).
+
+    Stored alongside other attachments but tracked via Project.cover_image_filename
+    so swap/delete operations don't touch the attachments list. Replaces any
+    existing cover image — the prior file is deleted on disk before the new one
+    lands so a stuck filesystem reference can't accumulate orphaned images.
+    """
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    original_name = file.filename or "cover"
+    ext = os.path.splitext(original_name)[1].lower()
+    if ext not in COVER_IMAGE_EXTENSIONS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"Cover image must be one of {sorted(COVER_IMAGE_EXTENSIONS)}",
+        )
+
+    attachments_dir = get_project_attachments_dir(project_id)
+    attachments_dir.mkdir(parents=True, exist_ok=True)
+
+    # Remove the previous cover-image file from disk first so we don't accumulate
+    # orphans when users repeatedly replace it. Best-effort: a missing/locked file
+    # shouldn't block a successful replacement.
+    if project.cover_image_filename:
+        old_path = attachments_dir / project.cover_image_filename
+        if old_path.exists():
+            try:
+                os.remove(old_path)
+            except OSError as e:
+                logger.warning("Failed to delete old cover image %s: %s", old_path, e)
+
+    unique_filename = f"cover_{uuid.uuid4().hex}{ext}"
+    file_path = attachments_dir / unique_filename
+    try:
+        with open(file_path, "wb") as f:
+            content = await file.read()
+            f.write(content)
+    except OSError as e:
+        logger.error("Failed to save cover image: %s", e)
+        raise HTTPException(status_code=500, detail="Failed to save cover image")
+
+    project.cover_image_filename = unique_filename
+    db.add(project)
+    await db.flush()
+    await db.commit()
+
+    return {
+        "status": "success",
+        "filename": unique_filename,
+        "size": len(content),
+    }
+
+
+@router.get("/{project_id}/cover-image")
+async def get_project_cover_image(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
+):
+    """Stream the project's cover image (#1155).
+
+    Browsers can't attach `Authorization: Bearer ...` to `<img src>` requests,
+    so this route accepts the same `?token=` stream-credential as
+    /archives/{id}/thumbnail. The frontend wraps URLs with `withStreamToken`."""
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+    if not project.cover_image_filename:
+        raise HTTPException(status_code=404, detail="No cover image set")
+
+    file_path = get_project_attachments_dir(project_id) / project.cover_image_filename
+    if not file_path.exists():
+        # DB references a file that vanished from disk — clear the dangling
+        # reference so future GETs get a clean 404 instead of repeatedly
+        # touching the filesystem.
+        logger.warning("Cover image file missing for project %s: %s", project_id, file_path)
+        project.cover_image_filename = None
+        await db.commit()
+        raise HTTPException(status_code=404, detail="Cover image file not found")
+
+    ext = os.path.splitext(project.cover_image_filename)[1].lower()
+    media_type = COVER_IMAGE_CONTENT_TYPES.get(ext, "application/octet-stream")
+    return FileResponse(file_path, media_type=media_type)
+
+
+@router.delete("/{project_id}/cover-image")
+async def delete_project_cover_image(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
+):
+    """Remove the project's cover image (#1155)."""
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    if project.cover_image_filename:
+        file_path = get_project_attachments_dir(project_id) / project.cover_image_filename
+        if file_path.exists():
+            try:
+                os.remove(file_path)
+            except OSError as e:
+                logger.warning("Failed to delete cover image file %s: %s", file_path, e)
+        project.cover_image_filename = None
+        db.add(project)
+        await db.flush()
+        await db.commit()
+
+    return {"status": "success"}
+
+
 # ============ Phase 7: BOM Endpoints ============
 
 
@@ -1213,6 +1375,7 @@ async def create_template_from_project(
         budget=source.budget,
         is_template=True,
         template_source_id=source.id,
+        url=source.url,
     )
     db.add(template)
     await db.flush()
@@ -1250,6 +1413,8 @@ async def create_template_from_project(
         target_parts_count=template.target_parts_count,
         notes=template.notes,
         attachments=template.attachments,
+        url=template.url,
+        cover_image_filename=template.cover_image_filename,
         tags=template.tags,
         due_date=template.due_date,
         priority=template.priority,
@@ -1414,7 +1579,7 @@ async def export_project(
     for folder in linked_folders:
         # Get files in this folder
         files_result = await db.execute(
-            select(LibraryFile).where(LibraryFile.folder_id == folder.id).order_by(LibraryFile.filename)
+            LibraryFile.active().where(LibraryFile.folder_id == folder.id).order_by(LibraryFile.filename)
         )
         files = files_result.scalars().all()
 
@@ -1487,7 +1652,7 @@ async def export_project(
     return StreamingResponse(
         zip_buffer,
         media_type="application/zip",
-        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+        headers={"Content-Disposition": build_content_disposition(filename)},
     )
 
 
@@ -1570,6 +1735,8 @@ async def import_project(
         target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
+        url=project.url,
+        cover_image_filename=project.cover_image_filename,
         tags=project.tags,
         due_date=project.due_date,
         priority=project.priority,
@@ -1743,6 +1910,8 @@ async def import_project_file(
         target_parts_count=project.target_parts_count,
         notes=project.notes,
         attachments=project.attachments,
+        url=project.url,
+        cover_image_filename=project.cover_image_filename,
         tags=project.tags,
         due_date=project.due_date,
         priority=project.priority,

+ 272 - 82
backend/app/api/routes/settings.py

@@ -10,7 +10,7 @@ from fastapi.responses import FileResponse, JSONResponse
 from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -22,9 +22,17 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/settings", tags=["settings"])
 
-# Default settings
 DEFAULT_SETTINGS = AppSettings()
 
+# Sensitive credential fields blanked for API-key callers
+_SENSITIVE_FIELDS_FOR_API_KEY = (
+    "mqtt_password",
+    "ha_token",
+    "prometheus_token",
+    "virtual_printer_access_code",
+    "ldap_bind_password",
+)
+
 
 async def get_setting(db: AsyncSession, key: str) -> str | None:
     """Get a single setting value by key."""
@@ -61,93 +69,100 @@ async def set_setting(db: AsyncSession, key: str, value: str) -> None:
     await upsert_setting(db, Settings, key, value)
 
 
-@router.get("", response_model=AppSettings)
-@router.get("/", response_model=AppSettings)
-async def get_settings(
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-):
-    """Get all application settings."""
+async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -> AppSettings:
+    """Build the full settings response, scrubbing secrets for API-key callers."""
     settings_dict = DEFAULT_SETTINGS.model_dump()
 
-    # Load saved settings from database
     result = await db.execute(select(Settings))
-    db_settings = result.scalars().all()
-
-    for setting in db_settings:
-        if setting.key in settings_dict:
-            # Parse the value based on the expected type
-            if setting.key in [
-                "auto_archive",
-                "save_thumbnails",
-                "capture_finish_photo",
-                "spoolman_enabled",
-                "spoolman_disable_weight_sync",
-                "spoolman_report_partial_usage",
-                "disable_filament_warnings",
-                "prefer_lowest_filament",
-                "check_updates",
-                "check_printer_firmware",
-                "include_beta_updates",
-                "virtual_printer_enabled",
-                "ftp_retry_enabled",
-                "mqtt_enabled",
-                "mqtt_use_tls",
-                "ha_enabled",
-                "per_printer_mapping_expanded",
-                "prometheus_enabled",
-                "user_notifications_enabled",
-                "queue_drying_enabled",
-                "queue_drying_block",
-                "ambient_drying_enabled",
-                "require_plate_clear",
-                "queue_shortest_first",
-                "default_bed_levelling",
-                "default_flow_cali",
-                "default_vibration_cali",
-                "default_layer_inspect",
-                "default_timelapse",
-                "ldap_enabled",
-                "ldap_auto_provision",
-            ]:
-                settings_dict[setting.key] = setting.value.lower() == "true"
-            elif setting.key in [
-                "default_filament_cost",
-                "energy_cost_per_kwh",
-                "ams_temp_good",
-                "ams_temp_fair",
-                "library_disk_warning_gb",
-                "low_stock_threshold",
-            ]:
-                settings_dict[setting.key] = float(setting.value)
-            elif setting.key in [
-                "ams_humidity_good",
-                "ams_humidity_fair",
-                "ams_history_retention_days",
-                "ftp_retry_count",
-                "ftp_retry_delay",
-                "ftp_timeout",
-                "mqtt_port",
-                "stagger_group_size",
-                "stagger_interval_minutes",
-            ]:
-                settings_dict[setting.key] = int(setting.value)
-            elif setting.key == "default_printer_id":
-                # Handle nullable integer
-                settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
-            else:
-                settings_dict[setting.key] = setting.value
+    for setting in result.scalars().all():
+        if setting.key not in settings_dict:
+            continue
+        if setting.key in [
+            "auto_archive",
+            "save_thumbnails",
+            "capture_finish_photo",
+            "spoolman_enabled",
+            "spoolman_disable_weight_sync",
+            "spoolman_report_partial_usage",
+            "disable_filament_warnings",
+            "prefer_lowest_filament",
+            "check_updates",
+            "check_printer_firmware",
+            "include_beta_updates",
+            "virtual_printer_enabled",
+            "ftp_retry_enabled",
+            "mqtt_enabled",
+            "mqtt_use_tls",
+            "ha_enabled",
+            "per_printer_mapping_expanded",
+            "prometheus_enabled",
+            "user_notifications_enabled",
+            "queue_drying_enabled",
+            "queue_drying_block",
+            "ambient_drying_enabled",
+            "require_plate_clear",
+            "queue_shortest_first",
+            "default_bed_levelling",
+            "default_flow_cali",
+            "default_vibration_cali",
+            "default_layer_inspect",
+            "default_timelapse",
+            "ldap_enabled",
+            "ldap_auto_provision",
+        ]:
+            settings_dict[setting.key] = setting.value.lower() == "true"
+        elif setting.key in [
+            "default_filament_cost",
+            "energy_cost_per_kwh",
+            "ams_temp_good",
+            "ams_temp_fair",
+            "library_disk_warning_gb",
+            "low_stock_threshold",
+        ]:
+            settings_dict[setting.key] = float(setting.value)
+        elif setting.key in [
+            "ams_humidity_good",
+            "ams_humidity_fair",
+            "ams_history_retention_days",
+            "ftp_retry_count",
+            "ftp_retry_delay",
+            "ftp_timeout",
+            "mqtt_port",
+            "stagger_group_size",
+            "stagger_interval_minutes",
+            "forecast_global_lead_time_days",
+        ]:
+            settings_dict[setting.key] = int(setting.value)
+        elif setting.key == "default_printer_id":
+            settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
+        else:
+            settings_dict[setting.key] = setting.value
 
-    # Get Home Assistant settings (with environment variable overrides)
     ha_settings = await get_homeassistant_settings(db)
     settings_dict.update(ha_settings)
 
-    # Never return LDAP bind password in API responses
+    # ldap_bind_password is never returned to any caller
     settings_dict["ldap_bind_password"] = ""
 
+    if is_api_key:
+        for field in _SENSITIVE_FIELDS_FOR_API_KEY:
+            if field in settings_dict:
+                settings_dict[field] = ""
+
     return AppSettings(**settings_dict)
 
 
+@router.get("", response_model=AppSettings)
+@router.get("/", response_model=AppSettings)
+async def get_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    _is_api_key: bool = Depends(caller_is_api_key),
+):
+    """Get all application settings."""
+    return await _build_settings_response(db, is_api_key=_is_api_key)
+
+
 @router.put("/", response_model=AppSettings)
 async def update_settings(
     settings_update: AppSettingsUpdate,
@@ -201,8 +216,8 @@ async def update_settings(
         except Exception:
             pass  # Don't fail the settings update if MQTT reconfiguration fails
 
-    # Return updated settings
-    return await get_settings(db)
+    # Return updated settings (never scrub secrets on PUT — caller has SETTINGS_UPDATE permission)
+    return await _build_settings_response(db, is_api_key=False)
 
 
 @router.patch("/", response_model=AppSettings)
@@ -454,6 +469,24 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
                 except PermissionError as e:
                     logger.warning("Permission denied copying %s: %s", name, e)
 
+        # Include the MFA encryption key as a ZIP top-level entry alongside
+        # bambuddy.db. Without it, encrypted client_secret / TOTP secret rows
+        # would be unrecoverable after restore on a host without MFA_ENCRYPTION_KEY set.
+        from backend.app.core.paths import resolve_data_dir
+
+        mfa_key_src = resolve_data_dir() / ".mfa_encryption_key"
+        if mfa_key_src.exists() and mfa_key_src.is_file():
+            try:
+                shutil.copy2(mfa_key_src, temp_path / ".mfa_encryption_key")
+            except OSError as exc:
+                logger.error(
+                    "Could not include MFA encryption key in backup (%s). "
+                    "The backup ZIP will not contain the key — restore on a "
+                    "keyless host will fail for encrypted secrets.",
+                    exc,
+                )
+                raise
+
         # Create ZIP
         if output_path is not None:
             zip_file = output_path / filename
@@ -541,7 +574,26 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
                     table.constraints.discard(fk)
 
         async with pg_engine.begin() as conn:
-            await conn.run_sync(metadata.drop_all)
+            # Drop every existing table in the public schema with CASCADE
+            # rather than `metadata.drop_all`. Two reasons:
+            #   1. The user's live DB may carry orphan tables from removed
+            #      features (e.g. the legacy `spoolman_slot_assignments`,
+            #      `spoolman_k_profile`) that hold FK constraints back to
+            #      ORM tables. `drop_all` doesn't know they exist and emits
+            #      `DROP TABLE printers` without CASCADE — Postgres refuses
+            #      and the whole restore aborts (#XXXX).
+            #   2. Even within the metadata, `drop_all` is FK-ordered and
+            #      breaks if a future schema rename leaves old constraints
+            #      around. CASCADE is the right tool for a destructive
+            #      restore: the user is intentionally wiping state.
+            await conn.execute(
+                text(
+                    "DO $$ DECLARE r RECORD; BEGIN "
+                    "FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP "
+                    "EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; "
+                    "END LOOP; END $$;"
+                )
+            )
             await conn.run_sync(metadata.create_all)
 
         # Restore FK definitions in metadata (needed for re-adding later)
@@ -703,6 +755,18 @@ async def restore_backup(
 
         try:
             with zipfile.ZipFile(io.BytesIO(content), "r") as zf:
+                for name in zf.namelist():
+                    # Reject path-traversal payloads: any entry whose resolved
+                    # path escapes temp_path would allow writing arbitrary files
+                    # on the host (ZipSlip / CVE-2006-5456).
+                    dest = (temp_path / name).resolve()
+                    # is_relative_to (Python 3.9+) covers both relative
+                    # path-traversal (../etc/passwd) and absolute-path overrides
+                    # (/etc/passwd) — str.startswith was vulnerable to
+                    # prefix-collision attacks (e.g. /tmp/abc_evil/file passing
+                    # a /tmp/abc prefix check).
+                    if not dest.is_relative_to(temp_path.resolve()):
+                        raise HTTPException(400, f"Invalid backup: unsafe path in ZIP: {name!r}")
                 zf.extractall(temp_path)
         except zipfile.BadZipFile:
             raise HTTPException(400, "Invalid backup file: not a valid ZIP")
@@ -728,11 +792,90 @@ async def restore_backup(
             logger.info("Closing database connections...")
             await close_all_connections()
 
+            # B1: Restore the MFA encryption key file BEFORE the database swap.
+            # If the key write fails (OSError, RO disk, full disk, EACCES) we
+            # can still abort while the live DB is intact. Doing this AFTER the
+            # DB swap would leave the database with rows encrypted under the
+            # backup's key but the running install holding only the old key —
+            # every encrypted secret becomes unrecoverable.
+            from backend.app.core.paths import resolve_data_dir
+
+            mfa_key_src = temp_path / ".mfa_encryption_key"
+            if mfa_key_src.exists() and mfa_key_src.is_file():
+                dst_key = resolve_data_dir() / ".mfa_encryption_key"
+                tmp_key = dst_key.parent / ".mfa_encryption_key.restore-tmp"
+                try:
+                    dst_key.parent.mkdir(parents=True, exist_ok=True)
+                    # S1: atomic write with restrictive mode from creation.
+                    # O_TRUNC because a stale tmp may exist from a prior
+                    # failed restore attempt — we want to overwrite it.
+                    fd = os.open(str(tmp_key), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+                    try:
+                        os.write(fd, mfa_key_src.read_bytes())
+                    finally:
+                        os.close(fd)
+                    # POSIX rename(2) — atomic when source/dest are on the
+                    # same filesystem (we're staying inside dst_key.parent).
+                    os.replace(str(tmp_key), str(dst_key))
+                    # S9: warn if the FS doesn't enforce 0o600
+                    actual_mode = dst_key.stat().st_mode & 0o777
+                    if actual_mode != 0o600:
+                        logger.warning(
+                            "Restored MFA key file %s: filesystem did not enforce 0o600 "
+                            "(actual: 0o%o). Key may be world-readable on Windows / SMB / FUSE.",
+                            dst_key,
+                            actual_mode,
+                        )
+                    logger.info("Restored .mfa_encryption_key from backup")
+                except OSError as e:
+                    logger.error(
+                        "Could not write restored MFA key file to %s: %s — "
+                        "aborting BEFORE database swap (DB unchanged).",
+                        dst_key,
+                        e,
+                        exc_info=True,
+                    )
+                    raise HTTPException(
+                        status_code=500,
+                        detail=("Restore aborted: MFA key write failed. Database is unchanged. Check server logs."),
+                    ) from e
+
             # 5. Replace database
             logger.info("Restoring database from backup...")
             if is_sqlite():
                 db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
-                shutil.copy2(backup_db, db_path)
+                # Use SQLite's online backup API instead of shutil.copy2.
+                # The pragma at database.py:19 runs the live DB in WAL mode,
+                # which means a naive file copy is unsafe: anything written
+                # to the live DB before this call that hasn't been
+                # checkpointed yet (seed_default_groups + init_db on first
+                # start, plus whatever background heartbeats wrote during
+                # the request window) sits in bambuddy.db-wal with valid
+                # checksums. The route handler's own `db: Depends(get_db)`
+                # session also keeps a connection checked out across
+                # engine.dispose(), holding fds to the WAL inode. With
+                # `shutil.copy2` SQLite finds the stale WAL on the next
+                # open and silently re-applies those page-level writes on
+                # top of the restored DB, partially clobbering it with
+                # fresh-install state — the user sees a "successful"
+                # restore where most rows and settings have reverted to
+                # defaults (#1211 / #668). The page-by-page backup API
+                # opens both DBs as real SQLite connections, takes the
+                # right locks, and routes new pages through the live DB's
+                # own WAL — so concurrent open sessions see their own
+                # snapshot until they close (transaction isolation) but
+                # can't corrupt the restored state.
+                import sqlite3
+
+                src_conn = sqlite3.connect(str(backup_db))
+                try:
+                    dst_conn = sqlite3.connect(str(db_path))
+                    try:
+                        src_conn.backup(dst_conn)
+                    finally:
+                        dst_conn.close()
+                finally:
+                    src_conn.close()
             else:
                 # Import SQLite backup into PostgreSQL
                 logger.info("Importing SQLite backup into PostgreSQL...")
@@ -777,7 +920,17 @@ async def restore_backup(
                         logger.warning("Could not restore %s directory: %s", name, e)
                         skipped_dirs.append(name)
 
-            # 7. Reinitialize the database engine and apply schema migrations so that
+            # 7. Reset the encryption singleton so the migration that runs
+            # inside init_db() picks up the restored key file (if a new one
+            # was written above). Without this reset, _get_fernet would
+            # return the cached Fernet instance built from the previous key.
+            import backend.app.core.encryption as _enc_mod
+
+            _enc_mod._fernet_instance = None
+            _enc_mod._key_source = None
+            _enc_mod._warn_shown = False
+
+            # 8. Reinitialize the database engine and apply schema migrations so that
             # tables added after the backup was created (e.g. ams_labels) exist
             # immediately, without requiring a manual restart.
             await reinitialize_database()
@@ -792,6 +945,12 @@ async def restore_backup(
                 "message": message,
             }
 
+        except HTTPException:
+            # Preserve specific HTTP error responses raised inside the restore
+            # body (e.g. the key-write OSError → 500). The blanket
+            # except Exception below would otherwise swallow them and replace
+            # the operator-facing detail with a generic message.
+            raise
         except Exception as e:
             logger.error("Restore failed: %s", e, exc_info=True)
             return JSONResponse(
@@ -844,6 +1003,8 @@ async def get_virtual_printer_settings(
     model = await get_setting(db, "virtual_printer_model")
     target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
     remote_interface_ip = await get_setting(db, "virtual_printer_remote_interface_ip")
+    tailscale_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
+    archive_name_source = await get_setting(db, "virtual_printer_archive_name_source")
 
     return {
         "enabled": enabled == "true" if enabled else False,
@@ -852,6 +1013,8 @@ async def get_virtual_printer_settings(
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "target_printer_id": int(target_printer_id) if target_printer_id else None,
         "remote_interface_ip": remote_interface_ip or "",
+        "tailscale_disabled": tailscale_disabled_raw == "true" if tailscale_disabled_raw else True,
+        "archive_name_source": archive_name_source if archive_name_source in ("metadata", "filename") else "metadata",
         "status": virtual_printer_manager.get_status(),
     }
 
@@ -864,6 +1027,8 @@ async def update_virtual_printer_settings(
     model: str = None,
     target_printer_id: int = None,
     remote_interface_ip: str = None,
+    tailscale_disabled: bool = None,
+    archive_name_source: str = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
@@ -890,6 +1055,9 @@ async def update_virtual_printer_settings(
     current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
     current_target_id = int(current_target_id_str) if current_target_id_str else None
     current_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
+    current_ts_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
+    # Default True (opt-in) when the setting has never been saved — matches the model default.
+    current_ts_disabled = current_ts_disabled_raw == "true" if current_ts_disabled_raw else True
 
     # Apply updates
     new_enabled = enabled if enabled is not None else current_enabled
@@ -898,6 +1066,7 @@ async def update_virtual_printer_settings(
     new_model = model if model is not None else current_model
     new_target_id = target_printer_id if target_printer_id is not None else current_target_id
     new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
+    new_ts_disabled = tailscale_disabled if tailscale_disabled is not None else current_ts_disabled
 
     # Validate mode
     # "review" is the new name for "queue" (pending review before archiving)
@@ -908,6 +1077,13 @@ async def update_virtual_printer_settings(
             status_code=400,
             content={"detail": "Mode must be 'immediate', 'review', 'print_queue', or 'proxy'"},
         )
+
+    # Validate archive_name_source
+    if archive_name_source is not None and archive_name_source not in ("metadata", "filename"):
+        return JSONResponse(
+            status_code=400,
+            content={"detail": "archive_name_source must be 'metadata' or 'filename'"},
+        )
     # Normalize legacy "queue" to "review" for storage
     if new_mode == "queue":
         new_mode = "review"
@@ -976,6 +1152,20 @@ async def update_virtual_printer_settings(
         await set_setting(db, "virtual_printer_target_printer_id", str(target_printer_id))
     if remote_interface_ip is not None:
         await set_setting(db, "virtual_printer_remote_interface_ip", remote_interface_ip)
+    if tailscale_disabled is not None:
+        await set_setting(db, "virtual_printer_tailscale_disabled", "true" if tailscale_disabled else "false")
+    if archive_name_source is not None:
+        await set_setting(db, "virtual_printer_archive_name_source", archive_name_source)
+
+    # Propagate tailscale_disabled to the first VirtualPrinter row so sync_from_db() picks it up
+    if tailscale_disabled is not None:
+        from backend.app.models.virtual_printer import VirtualPrinter as VPModel
+
+        vp_result = await db.execute(select(VPModel).order_by(VPModel.position).limit(1))
+        first_vp = vp_result.scalar_one_or_none()
+        if first_vp is not None:
+            first_vp.tailscale_disabled = new_ts_disabled
+
     await db.commit()
     db.expire_all()
 

+ 48 - 0
backend/app/api/routes/slice_jobs.py

@@ -0,0 +1,48 @@
+"""Polling endpoint for the in-memory slice-job dispatcher.
+
+POST /library/files/{id}/slice and POST /archives/{id}/slice return a
+job_id and a status_url pointing here. The frontend polls this until
+status flips to `completed` or `failed`.
+"""
+
+from fastapi import APIRouter, HTTPException
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.services.slice_dispatch import slice_dispatch
+
+router = APIRouter(prefix="/slice-jobs", tags=["slice-jobs"])
+
+
+@router.get("/{job_id}")
+async def get_slice_job(
+    job_id: int,
+    # Job IDs are sequential integers and the body leaks source filenames
+    # plus the resulting library_file_id / archive_id. Gate on LIBRARY_READ
+    # — same baseline a user needs to see slice sources or results.
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_READ),
+):
+    job = slice_dispatch.get(job_id)
+    if job is None:
+        raise HTTPException(status_code=404, detail="Slice job not found or expired")
+    body: dict = {
+        "job_id": job.id,
+        "status": job.status,
+        "kind": job.kind,
+        "source_id": job.source_id,
+        "source_name": job.source_name,
+        "created_at": job.created_at.isoformat(),
+        "started_at": job.started_at.isoformat() if job.started_at else None,
+        "completed_at": job.completed_at.isoformat() if job.completed_at else None,
+        # Live progress fed by the sidecar's --pipe channel. Null when
+        # the slicer hasn't emitted yet (early "Initializing" phase) or
+        # the sidecar doesn't support progress (older versions).
+        "progress": job.progress,
+    }
+    if job.status == "completed":
+        body["result"] = job.result
+    elif job.status == "failed":
+        body["error_status"] = job.error_status
+        body["error_detail"] = job.error_detail
+    return body

+ 552 - 0
backend/app/api/routes/slicer_presets.py

@@ -0,0 +1,552 @@
+"""Unified slicer-preset listing for the SliceModal (#wiki / Cloud-aware presets).
+
+Returns the printer/process/filament options grouped by source tier in
+priority order — cloud (per-user, live-fetched) > local (DB-backed
+imports) > standard (slicer-bundled stock fallback). Name-based dedup is
+applied so a preset that exists in multiple tiers only appears in the
+highest-priority one. Cloud failure modes (signed out / expired / network)
+are surfaced via a status field so the modal can render a precise banner
+without faking an "ok with empty list" response.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import time
+
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.config import settings as app_settings
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.local_preset import LocalPreset
+from backend.app.models.user import User
+from backend.app.schemas.slicer_presets import (
+    UnifiedPreset,
+    UnifiedPresetsBySlot,
+    UnifiedPresetsResponse,
+)
+from backend.app.services.bambu_cloud import (
+    BambuCloudAuthError,
+    BambuCloudError,
+    BambuCloudService,
+)
+from backend.app.services.slicer_api import (
+    BundleNotFoundError,
+    BundleSummary,
+    SlicerApiError,
+    SlicerApiService,
+    SlicerApiUnavailableError,
+    SlicerInputError,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/slicer", tags=["Slicer Presets"])
+
+
+# In-process cache for the bundled-profile list. The slicer sidecar walks a
+# read-only filesystem inside its own container, so the list only changes
+# across sidecar rebuilds — a long TTL is safe and avoids a sidecar round-trip
+# on every modal open. Per-user cache is unnecessary because bundled profiles
+# are global.
+_BUNDLED_TTL_S = 3600.0
+_bundled_cache: tuple[float, dict[str, list[UnifiedPreset]]] | None = None
+
+# Per-user cache for the cloud preset list. Cache key is (user_id, token_hash):
+# keying on the token hash means a logout/login or token-change automatically
+# invalidates the entry without needing the cloud-auth route handlers to call
+# back into this module. 5 minutes balances "users see their freshly-saved
+# presets quickly" against "a busy install doesn't hit the cloud once per
+# modal open per user".
+_CLOUD_TTL_S = 300.0
+_cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {}
+
+
+def _token_fingerprint(token: str) -> str:
+    """Short stable hash of the cloud token for use as a cache-key component.
+    Storing only the hash means we can safely keep multiple per-(user, token)
+    entries without leaking the token via the in-process dict."""
+    return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
+
+
+_CLOUD_TYPE_TO_SLOT = {
+    "filament": "filament",
+    "printer": "printer",
+    "print": "process",  # Bambu Cloud calls process presets "print"
+}
+
+
+def _empty_slots() -> dict[str, list[UnifiedPreset]]:
+    return {"printer": [], "process": [], "filament": []}
+
+
+async def _fetch_cloud_presets(db: AsyncSession, user: User | None) -> tuple[dict[str, list[UnifiedPreset]], str]:
+    """Return (slots, cloud_status). Slots are empty when cloud_status != 'ok'.
+
+    Defence-in-depth: even if a stored cloud_token survived a permission
+    revocation (admin reset, legacy state), users without ``CLOUD_AUTH`` are
+    treated as not-authenticated for this endpoint — the cloud tier never
+    surfaces for them. This keeps the per-tier visibility consistent with the
+    /cloud/* endpoint suite that already gates on CLOUD_AUTH.
+    """
+    if user is not None and not user.has_permission(Permission.CLOUD_AUTH.value):
+        return _empty_slots(), "not_authenticated"
+
+    token, _email, region = await get_stored_token(db, user)
+    if not token:
+        return _empty_slots(), "not_authenticated"
+
+    user_key = user.id if user is not None else 0
+    cache_key = (user_key, _token_fingerprint(token))
+    now = time.monotonic()
+    cached = _cloud_cache.get(cache_key)
+    if cached and now - cached[0] < _CLOUD_TTL_S:
+        return cached[1], "ok"
+
+    cloud = BambuCloudService(region=region)
+    cloud.set_token(token)
+    try:
+        try:
+            raw = await cloud.get_slicer_settings()
+        except BambuCloudAuthError:
+            # Don't clear the token here — the cloud-status endpoint owns that
+            # lifecycle. Just report expired so the UI can prompt re-auth.
+            return _empty_slots(), "expired"
+        except BambuCloudError as e:
+            logger.warning("Cloud preset fetch failed for user %s: %s", user_key, e)
+            return _empty_slots(), "unreachable"
+        except Exception as e:  # noqa: BLE001 — defensive: never crash the modal
+            logger.warning("Cloud preset fetch unexpected error for user %s: %s", user_key, e)
+            return _empty_slots(), "unreachable"
+
+        slots = _empty_slots()
+        for cloud_type, slot in _CLOUD_TYPE_TO_SLOT.items():
+            type_data = raw.get(cloud_type, {})
+            # The cloud splits presets into "private" (the user's own) and "public"
+            # (Bambu's stock cloud presets). Both are valid choices — surface them
+            # in the natural order private → public so a user's customisations
+            # appear above the stock entries with the same names. Stock entries
+            # that share names with private ones get deduped out within the cloud
+            # tier itself.
+            seen_names: set[str] = set()
+            for entry in type_data.get("private", []) + type_data.get("public", []):
+                name = entry.get("name")
+                setting_id = entry.get("setting_id") or entry.get("id")
+                if not name or not setting_id or name in seen_names:
+                    continue
+                seen_names.add(name)
+                slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud"))
+
+        # Cloud filament presets carry no metadata in this response on
+        # purpose: the per-preset detail endpoint
+        # (/v1/iot-service/api/slicer/setting/{id}) is rate-limited at roughly
+        # 10/sec per token, so fetching N filament presets to enrich them
+        # one-by-one trips Bambu's limiter and returns 429 on every request
+        # for users with large preset libraries (#1150 follow-up).
+        #
+        # The dedup pass (see _dedupe_by_name) compensates: when a cloud entry
+        # wins over a same-named local entry, the cloud entry inherits the
+        # local entry's filament_type / filament_colour. So cloud presets that
+        # also exist locally still get metadata-aware pre-pick in the
+        # SliceModal; cloud-only presets fall back to plain priority order.
+        _cloud_cache[cache_key] = (now, slots)
+        return slots, "ok"
+    finally:
+        await cloud.close()
+
+
+async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
+    """Local imports — no caching needed, single indexed DB read."""
+    result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
+    presets = result.scalars().all()
+    slots = _empty_slots()
+    type_to_slot = {"filament": "filament", "printer": "printer", "process": "process"}
+    for p in presets:
+        slot = type_to_slot.get(p.preset_type)
+        if slot is None:
+            continue
+        extra: dict[str, str | None] = {}
+        if slot == "filament":
+            extra["filament_type"], extra["filament_colour"] = _parse_filament_metadata(p.setting)
+        slots[slot].append(
+            UnifiedPreset(id=str(p.id), name=p.name, source="local", **extra),
+        )
+    return slots
+
+
+def _parse_filament_metadata(setting_json: str | None) -> tuple[str | None, str | None]:
+    """Extract first-slot ``filament_type`` and ``filament_colour`` from a
+    stored preset JSON. OrcaSlicer stores both as arrays (per-extruder) — we
+    take the first entry since pre-pick matching is one-slot-at-a-time.
+    Defensive parse: any error returns (None, None) so a corrupt row never
+    breaks the listing."""
+    if not setting_json:
+        return None, None
+    try:
+        data = json.loads(setting_json)
+    except (ValueError, TypeError):
+        return None, None
+    if not isinstance(data, dict):
+        return None, None
+    return _first_scalar(data.get("filament_type")), _first_scalar(data.get("filament_colour"))
+
+
+def _first_scalar(value: object) -> str | None:
+    if isinstance(value, list) and value:
+        return value[0] if isinstance(value[0], str) else None
+    if isinstance(value, str) and value:
+        return value
+    return None
+
+
+async def _fetch_bundled_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]:
+    """Standard slicer-bundled profiles via the sidecar's /profiles/bundled."""
+    global _bundled_cache
+    now = time.monotonic()
+    if _bundled_cache and now - _bundled_cache[0] < _BUNDLED_TTL_S:
+        return _bundled_cache[1]
+
+    api_url = await _resolve_slicer_api_url(db)
+    if not api_url:
+        # No sidecar configured at all — return empty rather than caching, so
+        # users who configure one mid-session see results on next open.
+        return _empty_slots()
+
+    try:
+        async with SlicerApiService(base_url=api_url) as svc:
+            raw = await svc.list_bundled_profiles()
+    except SlicerApiError as e:
+        logger.info("Bundled preset fetch from sidecar at %s failed: %s", api_url, e)
+        return _empty_slots()
+    except Exception as e:  # noqa: BLE001 — never break the modal on sidecar issues
+        logger.warning("Bundled preset fetch unexpected error: %s", e)
+        return _empty_slots()
+
+    slots = _empty_slots()
+    for slot in ("printer", "process", "filament"):
+        for entry in raw.get(slot, []) or []:
+            name = entry.get("name")
+            if not name:
+                continue
+            # Bundled presets are addressed by name (the slicer resolves them
+            # by name during the `inherits:` walk), so name doubles as id.
+            extra: dict[str, str | None] = {}
+            if slot == "filament":
+                extra["filament_type"] = entry.get("filament_type")
+                extra["filament_colour"] = entry.get("filament_colour")
+            slots[slot].append(
+                UnifiedPreset(id=name, name=name, source="standard", **extra),
+            )
+
+    _bundled_cache = (now, slots)
+    return slots
+
+
+async def _resolve_slicer_api_url(db: AsyncSession) -> str | None:
+    """Pick the sidecar URL the bundled-listing fetch should hit.
+
+    Mirrors the slice route's resolution at ``library.py:_run_slicer_with_fallback``:
+    the user's ``preferred_slicer`` setting decides which sidecar Bambuddy
+    talks to, and the per-install URL setting overrides the env default.
+    A user who prefers Bambu Studio gets the *bambu-studio-api* sidecar's
+    bundled list; a user who prefers OrcaSlicer gets the *orca-slicer-api*
+    sidecar's bundled list. Without this branch the listing would always
+    hit OrcaSlicer (port 3003) even for BambuStudio installs (port 3001),
+    leaving the Standard tier permanently empty for them.
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
+    if preferred == "orcaslicer":
+        configured = await get_setting(db, "orcaslicer_api_url")
+        url = (configured or app_settings.slicer_api_url).strip()
+    elif preferred == "bambu_studio":
+        configured = await get_setting(db, "bambu_studio_api_url")
+        url = (configured or app_settings.bambu_studio_api_url).strip()
+    else:
+        # Unknown preference — return None so the bundled tier is empty
+        # rather than crashing the modal. The slice route raises 400 here;
+        # we degrade silently because the modal's listing is informational.
+        logger.warning("Unknown preferred_slicer setting: %r — bundled tier disabled", preferred)
+        return None
+    return url or None
+
+
+def _dedupe_by_name(
+    cloud: dict[str, list[UnifiedPreset]],
+    local: dict[str, list[UnifiedPreset]],
+    standard: dict[str, list[UnifiedPreset]],
+) -> tuple[
+    dict[str, list[UnifiedPreset]],
+    dict[str, list[UnifiedPreset]],
+    dict[str, list[UnifiedPreset]],
+]:
+    """Filter so each preset name appears in exactly one tier (cloud > local > standard).
+
+    Order within each tier is preserved as-is — only "lower-priority duplicates"
+    are dropped. A preset shared across tiers (e.g. "Bambu PLA Basic" in cloud
+    public AND standard bundled) only renders once, in the cloud tier.
+
+    Filament metadata is **merged across tiers** during dedup: when a cloud
+    entry wins over a same-named local entry, the cloud entry inherits the
+    local entry's ``filament_type`` and ``filament_colour`` (cloud entries
+    carry no metadata themselves because we deliberately don't fetch each
+    setting's content — see _fetch_cloud_presets). Without this merge, the
+    SliceModal's metadata-aware pre-pick would silently lose match data for
+    every preset the user has both cloud-synced and locally imported, and
+    fall back to plain priority selection.
+    """
+    # Build a lookup: filament name → metadata from the highest-quality tier
+    # that has it. Local + standard both expose parsed metadata; cloud
+    # doesn't. Take whichever non-empty entry shows up first.
+    metadata_by_name: dict[str, tuple[str | None, str | None]] = {}
+    for tier in (local, standard):
+        for p in tier["filament"]:
+            if p.name in metadata_by_name:
+                continue
+            if p.filament_type or p.filament_colour:
+                metadata_by_name[p.name] = (p.filament_type, p.filament_colour)
+
+    # Backfill cloud entries that don't have their own metadata.
+    for p in cloud["filament"]:
+        if (p.filament_type is None or p.filament_colour is None) and p.name in metadata_by_name:
+            t, c = metadata_by_name[p.name]
+            if p.filament_type is None and t is not None:
+                p.filament_type = t
+            if p.filament_colour is None and c is not None:
+                p.filament_colour = c
+
+    deduped_local = _empty_slots()
+    deduped_standard = _empty_slots()
+    for slot in ("printer", "process", "filament"):
+        seen = {p.name for p in cloud[slot]}
+        for p in local[slot]:
+            if p.name in seen:
+                continue
+            deduped_local[slot].append(p)
+            seen.add(p.name)
+        for p in standard[slot]:
+            if p.name in seen:
+                continue
+            deduped_standard[slot].append(p)
+            seen.add(p.name)
+    return cloud, deduped_local, deduped_standard
+
+
+@router.get("/presets", response_model=UnifiedPresetsResponse)
+async def list_unified_presets(
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+    api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
+) -> UnifiedPresetsResponse:
+    """List slicer presets across cloud / local / standard tiers, deduped by name.
+
+    Drives the SliceModal preset dropdowns. Permission gate matches the
+    slice action itself (``LIBRARY_UPLOAD``) so any user who can slice can
+    see the preset options for the dialog. The cloud branch is independently
+    gated on ``CLOUD_AUTH`` inside ``_fetch_cloud_presets`` so a user with
+    only ``LIBRARY_UPLOAD`` doesn't see cloud presets they shouldn't have
+    access to.
+
+    API-keyed callers (which return None from ``current_user``) get the
+    owner User via ``resolve_api_key_cloud_owner`` when the key has the
+    cloud-access scope, so the cloud tier surfaces correctly for them
+    too — matching the slice route (#1182 follow-up).
+    """
+    cloud_token_user = current_user or api_key_cloud_owner
+    cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user)
+    local = await _fetch_local_presets(db)
+    standard = await _fetch_bundled_presets(db)
+
+    cloud, local, standard = _dedupe_by_name(cloud, local, standard)
+
+    return UnifiedPresetsResponse(
+        cloud=UnifiedPresetsBySlot(**cloud),
+        local=UnifiedPresetsBySlot(**local),
+        standard=UnifiedPresetsBySlot(**standard),
+        cloud_status=cloud_status,
+    )
+
+
+def _bundle_summary_to_dict(b: BundleSummary) -> dict:
+    """Serialize a BundleSummary for the JSON response. The frontend uses
+    these arrays to populate the preset dropdowns when a user picks the
+    bundle as the slice source.
+    """
+    return {
+        "id": b.id,
+        "printer_preset_name": b.printer_preset_name,
+        "printer": b.printer,
+        "process": b.process,
+        "filament": b.filament,
+        "version": b.version,
+    }
+
+
+@router.post("/bundles", status_code=201)
+async def import_slicer_bundle(
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+):
+    """Forward a BambuStudio Printer Preset Bundle (.bbscfg) to the sidecar.
+
+    The user exports their printer's preset bundle from BambuStudio (File
+    -> Export -> Export Preset Bundle, "Printer preset bundle" option).
+    Uploading it here unpacks the bundle on the sidecar and exposes its
+    inner printer / process / filament presets to subsequent slice
+    requests via the bundle-id selector.
+
+    Idempotent: re-uploading the same file yields the same id (sidecar
+    hashes the zip content), so duplicate uploads collapse rather than
+    accumulate.
+    """
+    api_url = await _resolve_slicer_api_url(db)
+    if not api_url:
+        raise HTTPException(status_code=503, detail="No slicer sidecar configured")
+
+    # Multer on the sidecar caps bundle uploads at 50MB. We don't enforce
+    # that here — let the sidecar's filter own the limit so it stays in
+    # one place — but we do reject empty / huge files at the FastAPI
+    # layer to avoid pointlessly streaming them to the sidecar first.
+    contents = await file.read()
+    if not contents:
+        raise HTTPException(status_code=400, detail="Bundle file is empty")
+    filename = file.filename or "bundle.bbscfg"
+
+    try:
+        async with SlicerApiService(base_url=api_url) as svc:
+            summary = await svc.import_bundle(contents, filename=filename)
+    except SlicerInputError as e:
+        # Sidecar's 4xx — most likely a non-.bbscfg upload, a corrupt zip,
+        # or a path-traversal entry that the manifest validator caught.
+        # Surface verbatim so the user sees the actual reason in the toast.
+        raise HTTPException(status_code=400, detail=str(e)) from e
+    except SlicerApiUnavailableError as e:
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except SlicerApiError as e:
+        # 5xx from the sidecar's import path is rare — usually a disk
+        # write failure inside DATA_PATH/bundles. 502 (bad gateway) is
+        # closer to the truth than 500 here, since we're proxying.
+        raise HTTPException(status_code=502, detail=str(e)) from e
+    return _bundle_summary_to_dict(summary)
+
+
+@router.get("/bundles")
+async def list_slicer_bundles(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+):
+    """List every Printer Preset Bundle currently stored on the sidecar.
+
+    Drives the SliceModal's "Bundle" tier and a Settings panel where
+    users can review / delete imported bundles. Returns ``[]`` when the
+    sidecar has no bundles imported yet.
+    """
+    api_url = await _resolve_slicer_api_url(db)
+    if not api_url:
+        # No sidecar configured: empty list rather than 503 so the modal
+        # renders cleanly. Same shape as the bundled-presets fallback.
+        return []
+    try:
+        async with SlicerApiService(base_url=api_url) as svc:
+            bundles = await svc.list_bundles()
+    except SlicerApiUnavailableError as e:
+        # Sidecar offline: surface as 503 so the frontend can show a
+        # banner. Differs from the bundled-tier behaviour because that
+        # path also has cloud + local fallbacks; bundles is the only
+        # source for its tier.
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except SlicerApiError as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+    return [_bundle_summary_to_dict(b) for b in bundles]
+
+
+@router.get("/bundles/{bundle_id}")
+async def get_slicer_bundle(
+    bundle_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+):
+    """Return one bundle by id. 404 if it doesn't exist on the sidecar."""
+    api_url = await _resolve_slicer_api_url(db)
+    if not api_url:
+        raise HTTPException(status_code=503, detail="No slicer sidecar configured")
+    try:
+        async with SlicerApiService(base_url=api_url) as svc:
+            summary = await svc.get_bundle(bundle_id)
+    except BundleNotFoundError as e:
+        raise HTTPException(status_code=404, detail=str(e)) from e
+    except SlicerApiUnavailableError as e:
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except SlicerApiError as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+    return _bundle_summary_to_dict(summary)
+
+
+@router.delete("/bundles/{bundle_id}", status_code=204)
+async def delete_slicer_bundle(
+    bundle_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+):
+    """Remove a stored bundle from the sidecar. Future slice requests
+    referencing this id will fail with 404 from the sidecar.
+    """
+    api_url = await _resolve_slicer_api_url(db)
+    if not api_url:
+        raise HTTPException(status_code=503, detail="No slicer sidecar configured")
+    try:
+        async with SlicerApiService(base_url=api_url) as svc:
+            await svc.delete_bundle(bundle_id)
+    except BundleNotFoundError as e:
+        raise HTTPException(status_code=404, detail=str(e)) from e
+    except SlicerApiUnavailableError as e:
+        raise HTTPException(status_code=503, detail=str(e)) from e
+    except SlicerApiError as e:
+        raise HTTPException(status_code=502, detail=str(e)) from e
+
+
+@router.get("/preview-progress/{request_id}")
+async def get_preview_slice_progress(
+    request_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_READ),
+):
+    """Proxy to the sidecar's ``GET /slice/progress/:requestId``.
+
+    The SliceModal's filament-requirements call kicks off a real preview
+    slice on the sidecar to discover which AMS slots the picked plate
+    actually consumes. That HTTP call holds open for the full slice
+    duration (multi-second to multi-minute on complex models), and the
+    browser can't reach the sidecar directly thanks to the same-origin
+    policy + the sidecar's CORS allowlist. This endpoint forwards the
+    poll so the modal's inline spinner can show "Generating G-code (45%)"
+    instead of an opaque elapsed-time counter while the preview runs.
+
+    Returns the sidecar's snapshot verbatim, or 404 when the request_id
+    is unknown / completed and grace-window-expired.
+    """
+    import httpx
+
+    api_url = await _resolve_slicer_api_url(db)
+    if not api_url:
+        raise HTTPException(status_code=503, detail="No slicer sidecar configured")
+    url = f"{api_url}/slice/progress/{request_id}"
+    try:
+        async with httpx.AsyncClient(timeout=5.0) as client:
+            response = await client.get(url)
+    except httpx.RequestError:
+        # Sidecar unreachable: surface as 503 instead of 500 so the
+        # frontend's poller can keep trying without flagging a hard error.
+        raise HTTPException(status_code=503, detail="Slicer sidecar unreachable") from None
+    if response.status_code == 404:
+        raise HTTPException(status_code=404, detail="Progress unavailable")
+    return response.json()

+ 536 - 110
backend/app/api/routes/spoolbuddy.py

@@ -1,12 +1,14 @@
 """SpoolBuddy device management API routes."""
 
 import asyncio
+import contextlib
 import json
 import logging
 import time
 from datetime import datetime, timedelta, timezone
 from urllib.parse import urlparse
 
+import httpx
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -34,10 +36,12 @@ from backend.app.schemas.spoolbuddy import (
     TagRemovedRequest,
     TagScannedRequest,
     UpdateSpoolWeightRequest,
+    UpdateStatusRequest,
     WriteTagRequest,
     WriteTagResultRequest,
 )
 from backend.app.services.spool_tag_matcher import get_spool_by_tag
+from backend.app.services.spoolman import SpoolmanClientError, SpoolmanNotFoundError, SpoolmanUnavailableError
 
 logger = logging.getLogger(__name__)
 
@@ -45,10 +49,75 @@ router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
 
 OFFLINE_THRESHOLD_SECONDS = 30
 ONLINE_BROADCAST_INTERVAL_SECONDS = 10
+_SSRF_WARN_THROTTLE_SECONDS = 60
 _spoolbuddy_online_last_broadcast: dict[str, float] = {}
+_ssrf_warn_last_broadcast: dict[str, float] = {}
 _diagnostic_results: dict[tuple[str, str], dict] = {}
 
 
+@contextlib.asynccontextmanager
+async def _translate_spoolbuddy_errors():
+    """Translate Spoolman typed exceptions to HTTP for SpoolBuddy endpoints."""
+    try:
+        yield
+    except SpoolmanNotFoundError as exc:
+        raise HTTPException(status_code=404, detail="Spool not found in Spoolman") from exc
+    except SpoolmanClientError as exc:
+        raise HTTPException(status_code=502, detail="Spoolman rejected the request") from exc
+    except SpoolmanUnavailableError as exc:
+        raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
+
+
+async def _get_spoolman_client_or_none(db: AsyncSession):
+    """Return a SpoolmanClient if Spoolman is enabled with a safe URL, else None."""
+    from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
+    from backend.app.models.settings import Settings
+    from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client
+
+    settings_result = await db.execute(select(Settings))
+    settings_dict = {s.key: s.value for s in settings_result.scalars().all()}
+    spoolman_url = settings_dict.get("spoolman_url", "").strip()
+    spoolman_enabled = settings_dict.get("spoolman_enabled", "false").lower() == "true" and bool(spoolman_url)
+
+    if not spoolman_enabled:
+        return None
+
+    # SSRF guard: reject dangerous schemes, cloud-metadata IPs (169.254.169.254, 100.100.100.200,
+    # fd00:ec2::254), multicast and unspecified addresses — loopback and RFC-1918 ranges are
+    # intentionally permitted (Spoolman commonly runs on the same host or home LAN).
+    try:
+        assert_safe_spoolman_url(spoolman_url)
+    except ValueError as exc:
+        logger.warning(
+            "Spoolman integration disabled: URL %r rejected by SSRF guard: %s",
+            spoolman_url,
+            exc,
+        )
+        now = time.monotonic()
+        if now - _ssrf_warn_last_broadcast.get(spoolman_url, 0) > _SSRF_WARN_THROTTLE_SECONDS:
+            _ssrf_warn_last_broadcast[spoolman_url] = now
+            await ws_manager.broadcast(
+                {
+                    "type": "spoolman_ssrf_blocked",
+                    "detail": "Spoolman URL was rejected by the SSRF guard",
+                }
+            )
+        return None
+
+    client = await get_spoolman_client()
+    if not client or client.base_url != spoolman_url.rstrip("/"):
+        try:
+            client = await init_spoolman_client(spoolman_url)
+        except ValueError as exc:
+            logger.warning(
+                "Spoolman integration disabled: URL %r rejected on re-initialisation: %s",
+                spoolman_url,
+                exc,
+            )
+            return None
+    return client
+
+
 def _is_online(device: SpoolBuddyDevice) -> bool:
     if not device.last_seen:
         return False
@@ -171,8 +240,8 @@ async def register_device(
         from backend.app.services.spoolbuddy_ssh import get_public_key
 
         response.ssh_public_key = await get_public_key()
-    except Exception:
-        pass  # Key not generated yet — daemon can still work without it
+    except Exception as exc:
+        logger.warning("Could not attach SSH public key to heartbeat response: %s", exc)
 
     return response
 
@@ -279,6 +348,17 @@ async def device_heartbeat(
     if was_offline:
         logger.info("SpoolBuddy device back online: %s", device.device_id)
 
+    # Include current SSH public key so the daemon can re-deploy it whenever
+    # Bambuddy's keypair rotates (data dir wiped, container recreated, etc.) —
+    # otherwise SSH updates fail until the daemon restarts.
+    ssh_public_key: str | None = None
+    try:
+        from backend.app.services.spoolbuddy_ssh import get_public_key
+
+        ssh_public_key = await get_public_key()
+    except Exception:
+        pass
+
     return HeartbeatResponse(
         pending_command=pending,
         pending_write_payload=pending_write,
@@ -287,6 +367,7 @@ async def device_heartbeat(
         calibration_factor=device.calibration_factor,
         display_brightness=device.display_brightness,
         display_blank_timeout=device.display_blank_timeout,
+        ssh_public_key=ssh_public_key,
     )
 
 
@@ -299,50 +380,142 @@ async def nfc_tag_scanned(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
-    """RPi reports NFC tag detected — lookup spool and broadcast."""
-    spool = await get_spool_by_tag(db, req.tag_uid, req.tray_uuid or "")
+    """RPi reports NFC tag detected — lookup spool and broadcast.
+
+    Routes the lookup to the inventory backend Bambuddy is configured for:
+    Spoolman exclusively when ``spoolman_enabled`` is true, local DB
+    exclusively otherwise. The previous implementation always tried local
+    first and only consulted Spoolman as a fallback on local-DB miss, which
+    meant a stale local copy of a tag would silently win over the
+    authoritative Spoolman row, and deleting the local copy was the only way
+    to surface the Spoolman match. Operators expect the SpoolBuddy lookup to
+    follow the inventory mode they selected in Bambuddy settings.
+    """
+    from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
 
-    if spool:
-        await ws_manager.broadcast(
-            {
-                "type": "spoolbuddy_tag_matched",
-                "device_id": req.device_id,
-                "tag_uid": req.tag_uid,
-                "spool": {
-                    "id": spool.id,
-                    "material": spool.material,
-                    "subtype": spool.subtype,
-                    "color_name": spool.color_name,
-                    "rgba": spool.rgba,
-                    "brand": spool.brand,
-                    "label_weight": spool.label_weight,
-                    "core_weight": spool.core_weight,
-                    "weight_used": spool.weight_used,
-                },
-            }
-        )
-        logger.info("SpoolBuddy tag matched: %s -> spool %d", req.tag_uid, spool.id)
+    # _get_spoolman_client_or_none returns a usable client when spoolman_enabled
+    # is true (and the URL passes the SSRF guard), None otherwise — so its
+    # return value doubles as the mode discriminator.
+    client = await _get_spoolman_client_or_none(db)
+
+    if client is not None:
+        # Spoolman mode — exclusive lookup, no local-DB fallback.
+        try:
+            cached_spools = await client.get_spools()
+            sm_spool: dict | None = None
+            if req.tray_uuid:
+                sm_spool = await client.find_spool_by_tag(req.tray_uuid, cached_spools=cached_spools)
+            if sm_spool is None and req.tag_uid:
+                sm_spool = await client.find_spool_by_tag(req.tag_uid, cached_spools=cached_spools)
+
+            if sm_spool is not None:
+                mapped = _map_spoolman_spool(sm_spool)
+                await ws_manager.broadcast(
+                    {
+                        "type": "spoolbuddy_tag_matched",
+                        "device_id": req.device_id,
+                        "tag_uid": req.tag_uid,
+                        "tray_uuid": req.tray_uuid,
+                        "spool": {
+                            "id": mapped["id"],
+                            "material": mapped["material"],
+                            "subtype": mapped["subtype"],
+                            "color_name": mapped["color_name"],
+                            "rgba": mapped["rgba"],
+                            "brand": mapped["brand"],
+                            "label_weight": mapped["label_weight"],
+                            "core_weight": mapped["core_weight"],
+                            "weight_used": mapped["weight_used"],
+                        },
+                    }
+                )
+                logger.info("SpoolBuddy tag matched (Spoolman): %s -> spool %d", req.tag_uid, mapped["id"])
+                return {"status": "ok", "matched": True, "spool_id": mapped["id"]}
+        except ValueError as exc:
+            logger.error(
+                "Spoolman returned malformed spool data during tag lookup for %s: %s",
+                req.tag_uid,
+                exc,
+            )
+            return {"status": "ok", "matched": False, "spool_id": None}
+        except (httpx.RequestError, httpx.HTTPStatusError, SpoolmanUnavailableError):
+            logger.warning(
+                "Spoolman unreachable during tag lookup for %s",
+                req.tag_uid,
+            )
+            # Broadcast a diagnostic event so the UI can surface "Spoolman down" to the user.
+            # Use a distinct type from spoolbuddy_unknown_tag — Spoolman outage != unregistered spool.
+            await ws_manager.broadcast(
+                {
+                    "type": "spoolman_unavailable",
+                    "device_id": req.device_id,
+                    "context": "nfc_tag_scanned",
+                }
+            )
+            return {"status": "ok", "matched": False, "spool_id": None}
+        except Exception as exc:
+            logger.error(
+                "Spoolman tag lookup failed unexpectedly for %s: %s",
+                req.tag_uid,
+                exc,
+            )
+            # Broadcast a distinct error event so operators can distinguish
+            # "unexpected backend error" from "unregistered tag".
+            await ws_manager.broadcast(
+                {
+                    "type": "spoolbuddy_lookup_error",
+                    "device_id": req.device_id,
+                }
+            )
+            # Same silent-return policy: an unexpected error must not break device operation
+            # or trigger spurious duplicate-registration flows in the UI.
+            return {"status": "ok", "matched": False, "spool_id": None}
     else:
-        await ws_manager.broadcast(
-            {
-                "type": "spoolbuddy_unknown_tag",
-                "device_id": req.device_id,
-                "tag_uid": req.tag_uid,
-                "sak": req.sak,
-                "tag_type": req.tag_type,
-            }
-        )
-        logger.info(
-            "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
-            req.tag_uid,
-            len(req.tag_uid or ""),
-            req.tray_uuid,
-            len(req.tray_uuid or ""),
-            req.tag_type,
-            req.sak,
-        )
+        # Local mode — exclusive lookup, no Spoolman fallback.
+        spool = await get_spool_by_tag(db, req.tag_uid, req.tray_uuid or "")
+        if spool:
+            await ws_manager.broadcast(
+                {
+                    "type": "spoolbuddy_tag_matched",
+                    "device_id": req.device_id,
+                    "tag_uid": req.tag_uid,
+                    "tray_uuid": req.tray_uuid,
+                    "spool": {
+                        "id": spool.id,
+                        "material": spool.material,
+                        "subtype": spool.subtype,
+                        "color_name": spool.color_name,
+                        "rgba": spool.rgba,
+                        "brand": spool.brand,
+                        "label_weight": spool.label_weight,
+                        "core_weight": spool.core_weight,
+                        "weight_used": spool.weight_used,
+                    },
+                }
+            )
+            logger.info("SpoolBuddy tag matched (local): %s -> spool %d", req.tag_uid, spool.id)
+            return {"status": "ok", "matched": True, "spool_id": spool.id}
 
-    return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None}
+    await ws_manager.broadcast(
+        {
+            "type": "spoolbuddy_unknown_tag",
+            "device_id": req.device_id,
+            "tag_uid": req.tag_uid,
+            "tray_uuid": req.tray_uuid,
+            "sak": req.sak,
+            "tag_type": req.tag_type,
+        }
+    )
+    logger.info(
+        "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
+        req.tag_uid,
+        len(req.tag_uid or ""),
+        req.tray_uuid,
+        len(req.tray_uuid or ""),
+        req.tag_type,
+        req.sak,
+    )
+    return {"status": "ok", "matched": False, "spool_id": None}
 
 
 @router.post("/nfc/tag-removed")
@@ -368,38 +541,98 @@ async def nfc_write_tag(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Queue an NFC tag write command for a SpoolBuddy device."""
-    import json
-
     from backend.app.models.spool import Spool
-    from backend.app.services.opentag3d import encode_opentag3d
-
-    # Find the spool
-    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
-    spool = result.scalar_one_or_none()
-    if not spool:
-        raise HTTPException(status_code=404, detail="Spool not found")
+    from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped
 
-    # Find the device
+    # Find the device first (required regardless of spool source)
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
     device = result.scalar_one_or_none()
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
 
-    # Encode OpenTag3D NDEF data
-    ndef_data = encode_opentag3d(spool)
+    # Try local DB first
+    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+    spool = result.scalar_one_or_none()
+
+    nfc_warnings: list[str] = []
+    if spool:
+        ndef_data = encode_opentag3d(spool)
+        data_origin = "local"
+    else:
+        # Local DB miss — fall back to Spoolman when enabled
+        from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
+
+        sm_client = await _get_spoolman_client_or_none(db)
+        if sm_client is None:
+            raise HTTPException(status_code=404, detail="Spool not found")
+
+        async with _translate_spoolbuddy_errors():
+            sm_spool = await sm_client.get_spool(req.spool_id)
+
+        try:
+            mapped = _map_spoolman_spool(sm_spool)
+        except ValueError as exc:
+            logger.warning("Spoolman returned invalid spool for write-tag: %s", exc)
+            raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data")
+
+        if not mapped.get("material"):
+            raise HTTPException(
+                status_code=400,
+                detail="Spoolman spool has no material set — cannot encode NFC tag",
+            )
+
+        ndef_data = encode_opentag3d_from_mapped(mapped)
+        data_origin = "spoolman"
+
+        # Warn when fields that drive NFC content are absent in Spoolman.
+        # color_name specifically must check the raw filament field, not the
+        # mapped value — _map_spoolman_spool falls back to the filament's
+        # subtype when color_name is unset (so LinkSpoolModal stops showing
+        # "Unknown color"), but the NFC tag should still warn when Spoolman
+        # has no genuine color_name on file. Without this, the fallback
+        # silently masks a real missing-data condition.
+        raw_filament: dict = sm_spool.get("filament") or {}
+        if not raw_filament.get("color_name"):
+            nfc_warnings.append("color_name not set in Spoolman — tag encodes empty color name")
+        if not mapped.get("nozzle_temp_min"):
+            nfc_warnings.append("nozzle_temp_min not set in Spoolman — tag encodes 0 °C")
+        if not mapped.get("subtype"):
+            nfc_warnings.append("subtype not set in Spoolman — tag encodes empty subtype")
+        if not mapped.get("brand"):
+            nfc_warnings.append("brand/vendor not set in Spoolman — tag encodes empty brand")
+        if not mapped.get("rgba"):
+            nfc_warnings.append("rgba not set in Spoolman — tag encodes default colour")
+        if not mapped.get("label_weight"):
+            nfc_warnings.append("label_weight not set in Spoolman — tag encodes 0 g")
+        if nfc_warnings:
+            logger.warning(
+                "NFC encode for Spoolman spool %d has incomplete data: %s",
+                req.spool_id,
+                "; ".join(nfc_warnings),
+            )
 
     # Store write payload and set pending command
     device.pending_write_payload = json.dumps(
         {
-            "spool_id": spool.id,
+            "spool_id": req.spool_id,
             "ndef_data_hex": ndef_data.hex(),
+            "data_origin": data_origin,
         }
     )
     device.pending_command = "write_tag"
     await db.commit()
 
-    logger.info("Write tag queued for device %s, spool %d (%d bytes)", req.device_id, spool.id, len(ndef_data))
-    return {"status": "queued"}
+    logger.info(
+        "Write tag queued for device %s, spool %d (%s, %d bytes)",
+        req.device_id,
+        req.spool_id,
+        data_origin,
+        len(ndef_data),
+    )
+    result: dict = {"status": "queued"}
+    if nfc_warnings:
+        result["warnings"] = nfc_warnings
+    return result
 
 
 @router.post("/nfc/write-result")
@@ -409,37 +642,168 @@ async def nfc_write_result(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Handle NFC tag write result from SpoolBuddy daemon."""
-    # Find the device and clear pending state
+    # Find the device
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
     device = result.scalar_one_or_none()
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
 
+    # Capture data_origin before clearing the payload
+    try:
+        payload_dict = json.loads(device.pending_write_payload or "{}")
+    except (json.JSONDecodeError, TypeError):
+        payload_dict = {}
+        logger.warning("Malformed pending_write_payload for device %s — treating as local", req.device_id)
+    data_origin = payload_dict.get("data_origin", "local")
+
     device.pending_command = None
     device.pending_write_payload = None
 
     if req.success:
-        # Link the tag to the spool
-        from backend.app.models.spool import Spool
+        if data_origin == "spoolman":
+            # Update Spoolman extra.tag with the written NFC UID using a safe merge
+            # (fetches current extra first to avoid overwriting other custom fields).
+            sm_client = await _get_spoolman_client_or_none(db)
+            if sm_client is None:
+                logger.warning("Spoolman not configured; cannot persist tag link for spool %d", req.spool_id)
+                await db.commit()
+                await ws_manager.broadcast(
+                    {
+                        "type": "spoolbuddy_tag_link_failed",
+                        "device_id": req.device_id,
+                        "spool_id": req.spool_id,
+                        "tag_uid": req.tag_uid,
+                        "message": "Spoolman not configured",
+                    }
+                )
+                raise HTTPException(
+                    status_code=502,
+                    detail="Tag written to NFC but Spoolman is not configured; link not persisted",
+                )
+
+            _tag_link_ok = False
+            try:
+                tag_value = json.dumps(req.tag_uid.upper())
+                # Tag uniqueness: a single physical NFC UID must map to at most
+                # one Spoolman spool, otherwise find_spool_by_tag returns
+                # whichever spool comes first in the cached list (usually the
+                # older one) and the dashboard shows the wrong spool when the
+                # tag is scanned. Before binding the new owner, clear the tag
+                # from any other spool that currently has it. Best-effort:
+                # cleanup failure does not block the write itself, but the
+                # warning surfaces in logs so a stale duplicate can be tracked
+                # down manually.
+                try:
+                    cached_spools = await sm_client.get_spools()
+                    duplicate = await sm_client.find_spool_by_tag(req.tag_uid, cached_spools=cached_spools)
+                    if duplicate is not None and duplicate.get("id") != req.spool_id:
+                        await sm_client.merge_spool_extra(int(duplicate["id"]), {"tag": ""})
+                        logger.info(
+                            "Spoolman: cleared tag %s from previous holder spool %d before binding to spool %d",
+                            req.tag_uid,
+                            duplicate["id"],
+                            req.spool_id,
+                        )
+                except (SpoolmanNotFoundError, SpoolmanUnavailableError, SpoolmanClientError) as cleanup_exc:
+                    logger.warning(
+                        "Spoolman: failed to clear duplicate tag %s before binding to spool %d (proceeding anyway): %s",
+                        req.tag_uid,
+                        req.spool_id,
+                        cleanup_exc,
+                    )
+                except Exception:
+                    logger.exception(
+                        "Spoolman: unexpected error clearing duplicate tag %s before binding to spool %d (proceeding anyway)",
+                        req.tag_uid,
+                        req.spool_id,
+                    )
+
+                await sm_client.merge_spool_extra(req.spool_id, {"tag": tag_value})
+                logger.info(
+                    "Spoolman tag written and linked: spool %d -> tag %s",
+                    req.spool_id,
+                    req.tag_uid,
+                )
+                _tag_link_ok = True
+            except (SpoolmanNotFoundError, SpoolmanUnavailableError, SpoolmanClientError) as exc:
+                logger.error(
+                    "Spoolman error during tag write-back for spool %d (type=%s, status=%s): %s",
+                    req.spool_id,
+                    type(exc).__name__,
+                    getattr(exc, "status_code", "N/A"),
+                    exc,
+                )
+                # fall through to broadcast + raise 502 below
+            except Exception:
+                logger.exception(
+                    "Unexpected error during Spoolman tag write-back for spool %d",
+                    req.spool_id,
+                )
+                # fall through to broadcast + raise 502 below
+
+            await db.commit()
+            if _tag_link_ok:
+                await ws_manager.broadcast(
+                    {
+                        "type": "spoolbuddy_tag_written",
+                        "device_id": req.device_id,
+                        "spool_id": req.spool_id,
+                        "tag_uid": req.tag_uid,
+                    }
+                )
+            else:
+                await ws_manager.broadcast(
+                    {
+                        "type": "spoolbuddy_tag_link_failed",
+                        "device_id": req.device_id,
+                        "spool_id": req.spool_id,
+                        "tag_uid": req.tag_uid,
+                        # Generic message — full exception (may contain internal URLs/hostnames)
+                        # is logged server-side only to prevent information leakage via WebSocket.
+                        "message": "Spoolman link failed",
+                    }
+                )
+                raise HTTPException(
+                    status_code=502,
+                    detail="Tag written to NFC but Spoolman link failed",
+                )
+        else:
+            # Link the tag to the local DB spool
+            from backend.app.models.spool import Spool
+
+            result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+            spool = result.scalar_one_or_none()
+            if spool is None:
+                logger.warning(
+                    "NFC tag written for spool %d but it no longer exists in local DB; tag is orphaned",
+                    req.spool_id,
+                )
+                await db.commit()
+                await ws_manager.broadcast(
+                    {
+                        "type": "spoolbuddy_tag_link_failed",
+                        "device_id": req.device_id,
+                        "spool_id": req.spool_id,
+                        "message": "Spool not found",
+                    }
+                )
+                return {"status": "ok", "linked": False, "message": "Spool not found"}
 
-        result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
-        spool = result.scalar_one_or_none()
-        if spool:
             spool.tag_uid = req.tag_uid.upper()
             spool.tag_type = "ntag"
             spool.data_origin = "opentag3d"
             spool.encode_time = datetime.now(timezone.utc)
             logger.info("Tag written and linked: spool %d -> tag %s", spool.id, req.tag_uid)
 
-        await db.commit()
-        await ws_manager.broadcast(
-            {
-                "type": "spoolbuddy_tag_written",
-                "device_id": req.device_id,
-                "spool_id": req.spool_id,
-                "tag_uid": req.tag_uid,
-            }
-        )
+            await db.commit()
+            await ws_manager.broadcast(
+                {
+                    "type": "spoolbuddy_tag_written",
+                    "device_id": req.device_id,
+                    "spool_id": req.spool_id,
+                    "tag_uid": req.tag_uid,
+                }
+            )
     else:
         await db.commit()
         await ws_manager.broadcast(
@@ -504,27 +868,66 @@ async def update_spool_weight(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Update spool's used weight from scale reading."""
+    from backend.app.api.routes._spoolman_helpers import _safe_float
     from backend.app.models.spool import Spool
 
-    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
-    spool = result.scalar_one_or_none()
-    if not spool:
+    # Try local DB first — local spool IDs must not be forwarded to Spoolman.
+    db_result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+    spool = db_result.scalar_one_or_none()
+
+    if spool:
+        net_filament = max(0, req.weight_grams - spool.core_weight)
+        spool.weight_used = max(0, spool.label_weight - net_filament)
+        spool.last_scale_weight = req.weight_grams
+        spool.last_weighed_at = datetime.now(timezone.utc)
+        await db.commit()
+        logger.info(
+            "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
+            spool.id,
+            req.weight_grams,
+            spool.weight_used,
+        )
+        return {"status": "ok", "weight_used": spool.weight_used}
+
+    # Local miss — fall back to Spoolman when enabled.
+    sm_client = await _get_spoolman_client_or_none(db)
+    if sm_client is None:
         raise HTTPException(status_code=404, detail="Spool not found")
 
-    # net weight = total on scale minus empty spool core
-    net_filament = max(0, req.weight_grams - spool.core_weight)
-    spool.weight_used = max(0, spool.label_weight - net_filament)
-    spool.last_scale_weight = req.weight_grams
-    spool.last_weighed_at = datetime.now(timezone.utc)
-    await db.commit()
+    async with _translate_spoolbuddy_errors():
+        sm_spool = await sm_client.get_spool(req.spool_id)
+
+    filament = sm_spool.get("filament") or {}
+    spool_tare = sm_spool.get("spool_weight")
+    raw_tare = spool_tare if spool_tare is not None else filament.get("spool_weight")
+    spool_weight_warning: str | None = None
+    if raw_tare is None:
+        logger.warning(
+            "Spoolman spool %d has no spool_weight set; using 250g fallback for tare",
+            req.spool_id,
+        )
+        spool_weight_warning = (
+            "spool_weight_not_set: Spoolman filament has no spool_weight configured; weight estimate uses 250g fallback"
+        )
+    core_weight = _safe_float(raw_tare, 250.0)
+    label_weight = _safe_float(filament.get("weight"), 1000.0)
+    remaining_weight = max(0.0, req.weight_grams - core_weight)
 
+    async with _translate_spoolbuddy_errors():
+        await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight)
+
+    weight_used = max(0.0, label_weight - remaining_weight)
     logger.info(
-        "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
-        spool.id,
+        "SpoolBuddy updated Spoolman spool %d: %.1fg on scale, core=%.1fg → %.1fg remaining",
+        req.spool_id,
         req.weight_grams,
-        spool.weight_used,
+        core_weight,
+        remaining_weight,
     )
-    return {"status": "ok", "weight_used": spool.weight_used}
+    result: dict = {"status": "ok", "weight_used": weight_used}
+    if spool_weight_warning:
+        result["warnings"] = [spool_weight_warning]
+    return result
 
 
 # --- Calibration endpoints ---
@@ -720,7 +1123,14 @@ async def queue_system_command(
     device_id: str,
     req: SystemCommandRequest,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    # Aligns with the rest of the kiosk-scoped device routes (calibration,
+    # display, cancel-write, command-result — all INVENTORY_UPDATE). The
+    # previous SETTINGS_UPDATE gate locked operators out of the QuickMenu's
+    # Restart-Daemon / Restart-Browser / Reboot / Shutdown buttons even
+    # though they had access to every other operation on the same device.
+    # Reboot and shutdown remain recoverable via physical access — the
+    # operator already has the kiosk in front of them.
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Queue a system command (reboot, shutdown, restart_daemon, restart_browser) for the SpoolBuddy device."""
     if req.command not in VALID_SYSTEM_COMMANDS:
@@ -909,7 +1319,14 @@ async def trigger_daemon_update(
     device_id: str,
     req: dict | None = None,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    # Aligns with the rest of the kiosk-scoped device routes (calibration,
+    # display, cancel-write, system/command — all INVENTORY_UPDATE).
+    # SETTINGS_UPDATE is on the API-key deny-list, which blocks the Update
+    # button from the kiosk's own Settings page even when the operator has
+    # physical access. Update only acts on the device the operator already
+    # controls (git fetch + pip install + systemctl restart on that one
+    # host) — same blast radius as the restart_daemon command.
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Trigger a SpoolBuddy update over SSH.
 
@@ -942,8 +1359,17 @@ async def trigger_daemon_update(
         }
     )
 
-    # Run the SSH update in the background
-    asyncio.create_task(perform_ssh_update(device_id, device.ip_address))
+    # Run the SSH update in the background — hold reference to prevent GC cancellation
+    _ssh_update_task = asyncio.create_task(perform_ssh_update(device_id, device.ip_address))
+    _ssh_update_task.add_done_callback(
+        lambda t: logger.error(
+            "SSH update task for device %s ended unexpectedly (cancelled=%s)",
+            device_id,
+            t.cancelled(),
+        )
+        if (t.cancelled() or t.exception() is not None)
+        else None
+    )
 
     return {"status": "ok", "message": "SSH update started"}
 
@@ -959,13 +1385,14 @@ async def get_ssh_public_key(
         key = await get_public_key()
         return {"public_key": key}
     except Exception as e:
-        raise HTTPException(status_code=500, detail=f"Failed to get SSH key: {e}") from e
+        logger.error("Failed to get SSH public key: %s", e)
+        raise HTTPException(status_code=500, detail="Failed to retrieve SSH public key") from e
 
 
 @router.post("/devices/{device_id}/update-status")
 async def report_update_status(
     device_id: str,
-    req: dict,
+    req: UpdateStatusRequest,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
@@ -975,25 +1402,24 @@ async def report_update_status(
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
 
-    status = req.get("status", "")
-    message = req.get("message", "")
-
-    if status in ("updating", "complete", "error"):
-        device.update_status = status
-        device.update_message = message[:255] if message else None
-        if status == "complete":
-            device.pending_command = None
-        await db.commit()
+    device.update_status = req.status
+    device.update_message = req.message
+    # Only "complete" clears pending_command here. "error" leaves it set so the user can retry
+    # via the UI. The SSH service's own _update_progress clears on both "complete" and "error"
+    # because it owns the full update lifecycle end-to-end.
+    if req.status == "complete":
+        device.pending_command = None
+    await db.commit()
 
-        logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, status, message)
-        await ws_manager.broadcast(
-            {
-                "type": "spoolbuddy_update",
-                "device_id": device_id,
-                "update_status": status,
-                "update_message": message,
-            }
-        )
+    logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, req.status, req.message)
+    await ws_manager.broadcast(
+        {
+            "type": "spoolbuddy_update",
+            "device_id": device_id,
+            "update_status": req.status,
+            "update_message": req.message,
+        }
+    )
 
     return {"status": "ok"}
 

+ 413 - 109
backend/app/api/routes/spoolman.py

@@ -2,26 +2,38 @@
 
 import json
 import logging
+from typing import Literal
 
 from fastapi import APIRouter, Depends, HTTPException
 from pydantic import BaseModel
-from sqlalchemy import select
+from sqlalchemy import delete, select, text
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_k_profile import SpoolmanKProfile
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.spoolman import (
+    SpoolmanClientError,
+    SpoolmanNotFoundError,
+    SpoolmanUnavailableError,
     close_spoolman_client,
     get_spoolman_client,
     init_spoolman_client,
 )
+from backend.app.utils.filament_ids import (
+    GENERIC_FILAMENT_IDS,
+    MATERIAL_TEMPS,
+    normalize_slicer_filament,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -39,10 +51,10 @@ class SpoolmanStatus(BaseModel):
 class SkippedSpool(BaseModel):
     """Information about a skipped spool during sync."""
 
-    location: str  # e.g., "AMS A1" or "External Spool"
-    reason: str  # e.g., "Not a Bambu Lab spool", "Empty tray"
-    filament_type: str | None = None  # e.g., "PLA", "PETG"
-    color: str | None = None  # Hex color
+    location: str
+    reason: Literal["No RFID tag and no slot assignment"]
+    filament_type: str | None = None
+    color: str | None = None
 
 
 class SyncResult(BaseModel):
@@ -129,9 +141,21 @@ async def connect_spoolman(
             )
 
         # Ensure the 'tag' extra field exists for RFID/UUID storage
-        await client.ensure_tag_extra_field()
+        field_ok = await client.ensure_tag_extra_field()
+        if not field_ok:
+            logger.error("Spoolman tag extra field registration failed — NFC tag links may not persist")
+        # Register slicer-preset extra fields (Spoolman rejects unknown extra keys).
+        for field_name in ("bambu_slicer_filament", "bambu_slicer_filament_name"):
+            if not await client.ensure_extra_field(field_name):
+                logger.warning(
+                    "Spoolman extra field %r registration failed — spool slicer-preset edits will return 502",
+                    field_name,
+                )
 
         return {"success": True, "message": f"Connected to Spoolman at {url}"}
+    except ValueError as exc:
+        logger.warning("Spoolman URL rejected: %s", exc)
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
     except Exception as e:
         logger.error("Failed to connect to Spoolman: %s", e)
         raise HTTPException(status_code=503, detail=str(e))
@@ -195,9 +219,6 @@ async def sync_printer_ams(
     synced = 0
     skipped: list[SkippedSpool] = []
     errors = []
-    # Track tray UUIDs currently in the AMS (for clearing removed spools)
-    current_tray_uuids: set[str] = set()
-    synced_spool_ids: set[int] = set()
 
     # Handle different AMS data structures
     # Traditional AMS: list of {"id": N, "tray": [...]} dicts
@@ -218,7 +239,10 @@ async def sync_printer_ams(
     if not ams_units:
         raise HTTPException(
             status_code=400,
-            detail=f"AMS data format not supported. Keys: {list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}",
+            detail=(
+                "AMS data format not supported. Keys: "
+                f"{list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}"
+            ),
         )
 
     # OPTIMIZATION: Fetch all spools once before processing trays
@@ -250,6 +274,20 @@ async def sync_printer_ams(
     except Exception as e:
         logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
 
+    # Load existing Spoolman slot assignments for the no-RFID fallback path
+    spoolman_slot_map: dict[tuple[int, int], int] = {}
+    try:
+        slot_result = await db.execute(
+            select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
+        )
+        for slot in slot_result.scalars().all():
+            spoolman_slot_map[(slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
+    except Exception as e:
+        logger.warning("Could not load Spoolman slot assignments for printer %s: %s", printer_id, e)
+
+    slot_changes: list[tuple[int, int, int]] = []  # (ams_id, tray_id, spoolman_spool_id)
+    empty_slots: list[tuple[int, int]] = []  # (ams_id, tray_id) now empty
+
     for ams_unit in ams_units:
         if not isinstance(ams_unit, dict):
             continue
@@ -261,33 +299,19 @@ async def sync_printer_ams(
             if not isinstance(tray_data, dict):
                 continue
 
+            tray_id_raw = int(tray_data.get("id", 0))
             tray = client.parse_ams_tray(ams_id, tray_data)
             if not tray:
-                continue  # Empty tray - nothing to sync
-
-            # Build location string for reporting
-            location = client.convert_ams_slot_to_location(ams_id, tray.tray_id)
-
-            # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
-            if not client.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
-                skipped.append(
-                    SkippedSpool(
-                        location=location,
-                        reason="Non-Bambu Lab spool (no RFID tag)",
-                        filament_type=tray.tray_type if tray.tray_type else None,
-                        color=tray.tray_color[:6] if tray.tray_color else None,
-                    )
-                )
+                empty_slots.append((ams_id, tray_id_raw))
                 continue
 
-            # Track this spool tag as currently present in the AMS (prefer tray_uuid, fallback to tag_uid)
             spool_tag = (
                 tray.tray_uuid
                 if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
                 else tray.tag_uid
             )
-            if spool_tag:
-                current_tray_uuids.add(spool_tag.upper())
+
+            hint = spoolman_slot_map.get((ams_id, tray.tray_id)) if not spool_tag else None
 
             try:
                 inv_remaining = inv_weights.get((ams_id, tray.tray_id))
@@ -297,12 +321,12 @@ async def sync_printer_ams(
                     disable_weight_sync=disable_weight_sync,
                     cached_spools=cached_spools,
                     inventory_remaining=inv_remaining,
+                    spoolman_spool_id_hint=hint,
                 )
                 if sync_result:
                     synced += 1
-                    # Add newly created spool to cache and track synced ID
                     if sync_result.get("id"):
-                        synced_spool_ids.add(sync_result["id"])
+                        slot_changes.append((ams_id, tray.tray_id, sync_result["id"]))
                         spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
                         if not spool_exists:
                             cached_spools.append(sync_result)
@@ -310,23 +334,49 @@ async def sync_printer_ams(
                     logger.info(
                         "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
                     )
-                else:
-                    # Bambu Lab spool that wasn't synced (not found in Spoolman)
+                elif spool_tag:
                     errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
+                elif not hint:
+                    skipped.append(
+                        SkippedSpool(
+                            location=f"AMS {ams_id} T{tray.tray_id}",
+                            reason="No RFID tag and no slot assignment",
+                            filament_type=tray.tray_type or None,
+                            color=tray.tray_color[:6] if tray.tray_color else None,
+                        )
+                    )
             except Exception as e:
                 error_msg = f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}"
                 logger.error(error_msg)
                 errors.append(error_msg)
 
-    # Clear location for spools that were removed from this printer's AMS
-    try:
-        cleared = await client.clear_location_for_removed_spools(
-            printer.name, current_tray_uuids, cached_spools=cached_spools, synced_spool_ids=synced_spool_ids
-        )
-        if cleared > 0:
-            logger.info("Cleared location for %s spools removed from %s", cleared, printer.name)
-    except Exception as e:
-        logger.error("Error clearing locations for removed spools: %s", e)
+    # Persist slot assignment changes to the local table
+    if slot_changes or empty_slots:
+        try:
+            for ams_id, tray_id, spool_id in slot_changes:
+                await db.execute(
+                    text(
+                        "INSERT INTO spoolman_slot_assignments"
+                        " (printer_id, ams_id, tray_id, spoolman_spool_id)"
+                        " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
+                        " ON CONFLICT(printer_id, ams_id, tray_id)"
+                        " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
+                    ),
+                    {"printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
+                )
+            for ams_id, tray_id in empty_slots:
+                await db.execute(
+                    delete(SpoolmanSlotAssignment).where(
+                        SpoolmanSlotAssignment.printer_id == printer_id,
+                        SpoolmanSlotAssignment.ams_id == ams_id,
+                        SpoolmanSlotAssignment.tray_id == tray_id,
+                    )
+                )
+            await db.commit()
+        except Exception as e:
+            await db.rollback()
+            logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
+            errors.append(f"Failed to persist slot assignments: {type(e).__name__}")
 
     return SyncResult(
         success=len(errors) == 0,
@@ -366,10 +416,6 @@ async def sync_all_printers(
     total_synced = 0
     all_skipped: list[SkippedSpool] = []
     all_errors = []
-    # Track tray UUIDs per printer (for clearing removed spools)
-    printer_tray_uuids: dict[str, set[str]] = {}
-    # Track synced spool IDs per printer (for location-based cleanup when no UUIDs available)
-    printer_synced_ids: dict[str, set[int]] = {}
 
     # OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
     # This eliminates redundant API calls across all printers
@@ -397,6 +443,20 @@ async def sync_all_printers(
     except Exception as e:
         logger.debug("Could not load inventory assignments for weight fallback: %s", e)
 
+    # Load all Spoolman slot assignments for the no-RFID fallback
+    # Key: (printer_id, ams_id, tray_id) → spoolman_spool_id
+    all_slot_map: dict[tuple[int, int, int], int] = {}
+    try:
+        slot_result = await db.execute(select(SpoolmanSlotAssignment))
+        for slot in slot_result.scalars().all():
+            all_slot_map[(slot.printer_id, slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
+    except Exception as e:
+        logger.warning("Could not load Spoolman slot assignments: %s", e)
+
+    # Collect slot changes across all printers for a single DB write at the end
+    all_slot_changes: list[tuple[int, int, int, int]] = []  # (printer_id, ams_id, tray_id, spool_id)
+    all_empty_slots: list[tuple[int, int, int]] = []  # (printer_id, ams_id, tray_id)
+
     for printer in printers:
         state = printer_manager.get_status(printer.id)
         if not state or not state.raw_data:
@@ -406,10 +466,6 @@ async def sync_all_printers(
         if not ams_data:
             continue
 
-        # Initialize tracking sets for this printer
-        printer_tray_uuids[printer.name] = set()
-        printer_synced_ids[printer.name] = set()
-
         # Handle different AMS data structures
         # Traditional AMS: list of {"id": N, "tray": [...]} dicts
         # H2D/newer printers: dict with different structure
@@ -442,36 +498,21 @@ async def sync_all_printers(
                 if not isinstance(tray_data, dict):
                     continue
 
+                tray_id_raw = int(tray_data.get("id", 0))
                 tray = client.parse_ams_tray(ams_id, tray_data)
                 if not tray:
+                    all_empty_slots.append((printer.id, ams_id, tray_id_raw))
                     continue
 
-                # Build location string for reporting
-                location = f"{printer.name} - {client.convert_ams_slot_to_location(ams_id, tray.tray_id)}"
-
-                # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
-                if not client.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
-                    all_skipped.append(
-                        SkippedSpool(
-                            location=location,
-                            reason="Non-Bambu Lab spool (no RFID tag)",
-                            filament_type=tray.tray_type if tray.tray_type else None,
-                            color=tray.tray_color[:6] if tray.tray_color else None,
-                        )
-                    )
-                    continue
-
-                # Track this spool tag as currently present in the AMS (prefer tray_uuid, fallback to tag_uid)
                 spool_tag = (
                     tray.tray_uuid
                     if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
                     else tray.tag_uid
                 )
-                if spool_tag:
-                    printer_tray_uuids[printer.name].add(spool_tag.upper())
+
+                hint = all_slot_map.get((printer.id, ams_id, tray.tray_id)) if not spool_tag else None
 
                 try:
-                    # Look up inventory weight as fallback when AMS data is invalid
                     inv_remaining = inventory_weights.get((printer.id, ams_id, tray.tray_id))
                     sync_result = await client.sync_ams_tray(
                         tray,
@@ -479,33 +520,57 @@ async def sync_all_printers(
                         disable_weight_sync=disable_weight_sync,
                         cached_spools=cached_spools,
                         inventory_remaining=inv_remaining,
+                        spoolman_spool_id_hint=hint,
                     )
                     if sync_result:
                         total_synced += 1
-                        # Track synced spool ID for cleanup
                         if sync_result.get("id"):
-                            printer_synced_ids[printer.name].add(sync_result["id"])
-                            # Add newly created spool to cache
+                            all_slot_changes.append((printer.id, ams_id, tray.tray_id, sync_result["id"]))
                             spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
                             if not spool_exists:
                                 cached_spools.append(sync_result)
                                 logger.debug("Added newly created spool %s to cache", sync_result["id"])
+                    elif spool_tag:
+                        all_errors.append(f"Spool not found in Spoolman: {printer.name} AMS {ams_id}:{tray.tray_id}")
+                    elif not hint:
+                        all_skipped.append(
+                            SkippedSpool(
+                                location=f"{printer.name} AMS {ams_id} T{tray.tray_id}",
+                                reason="No RFID tag and no slot assignment",
+                                filament_type=tray.tray_type or None,
+                                color=tray.tray_color[:6] if tray.tray_color else None,
+                            )
+                        )
                 except Exception as e:
                     all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
 
-    # Clear location for spools that were removed from each printer's AMS
-    for printer_name, current_tray_uuids in printer_tray_uuids.items():
+    # Persist slot assignment changes across all printers
+    if all_slot_changes or all_empty_slots:
         try:
-            cleared = await client.clear_location_for_removed_spools(
-                printer_name,
-                current_tray_uuids,
-                cached_spools=cached_spools,
-                synced_spool_ids=printer_synced_ids.get(printer_name, set()),
-            )
-            if cleared > 0:
-                logger.info("Cleared location for %s spools removed from %s", cleared, printer_name)
+            for p_id, ams_id, tray_id, spool_id in all_slot_changes:
+                await db.execute(
+                    text(
+                        "INSERT INTO spoolman_slot_assignments"
+                        " (printer_id, ams_id, tray_id, spoolman_spool_id)"
+                        " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
+                        " ON CONFLICT(printer_id, ams_id, tray_id)"
+                        " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
+                    ),
+                    {"printer_id": p_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
+                )
+            for p_id, ams_id, tray_id in all_empty_slots:
+                await db.execute(
+                    delete(SpoolmanSlotAssignment).where(
+                        SpoolmanSlotAssignment.printer_id == p_id,
+                        SpoolmanSlotAssignment.ams_id == ams_id,
+                        SpoolmanSlotAssignment.tray_id == tray_id,
+                    )
+                )
+            await db.commit()
         except Exception as e:
-            logger.error("Error clearing locations for %s: %s", printer_name, e)
+            await db.rollback()
+            logger.error("Error persisting Spoolman slot assignments: %s", e)
+            all_errors.append(f"Failed to persist slot assignments: {type(e).__name__}")
 
     return SyncResult(
         success=len(all_errors) == 0,
@@ -717,29 +782,249 @@ async def link_spool(
 
     spool_tag = spool_tag.upper()
 
-    # Build location like: "{Printer Name} - {AMS Name} {Slot Number}"
-    location: str | None = None
+    # Validate printer context when provided, but do NOT write spool.location —
+    # that field is user-managed in Spoolman. Slot assignment is stored locally.
+    printer_context: tuple[int, int, int] | None = None
     if request.printer_id is not None and request.ams_id is not None and request.tray_id is not None:
         printer_result = await db.execute(select(Printer).where(Printer.id == request.printer_id))
-        printer = printer_result.scalar_one_or_none()
-        if not printer:
+        if not printer_result.scalar_one_or_none():
             raise HTTPException(status_code=404, detail="Printer not found")
+        printer_context = (request.printer_id, request.ams_id, request.tray_id)
 
-        location = f"{printer.name} - {client.convert_ams_slot_to_location(request.ams_id, request.tray_id)}"
+    try:
+        await client.merge_spool_extra(spool_id, {"tag": json.dumps(spool_tag)})
+    except SpoolmanNotFoundError:
+        raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
+    except SpoolmanClientError:
+        raise HTTPException(status_code=502, detail="Spoolman rejected the request")
+    except SpoolmanUnavailableError:
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
-    # Update spool with tag
-    # Note: Spoolman extra field values must be valid JSON, so we encode the string
-    result = await client.update_spool(
-        spool_id=spool_id,
-        location=location,
-        extra={"tag": json.dumps(spool_tag)},
-    )
+    # Upsert slot assignment locally when printer context was supplied
+    if printer_context:
+        p_id, a_id, t_id = printer_context
+        try:
+            await db.execute(
+                text(
+                    "INSERT INTO spoolman_slot_assignments"
+                    " (printer_id, ams_id, tray_id, spoolman_spool_id)"
+                    " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
+                    " ON CONFLICT(printer_id, ams_id, tray_id)"
+                    " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
+                ),
+                {"printer_id": p_id, "ams_id": a_id, "tray_id": t_id, "spool_id": spool_id},
+            )
+            await db.commit()
+        except Exception as e:
+            await db.rollback()
+            logger.error(
+                "Linked spool %s in Spoolman but failed to persist local slot assignment "
+                "(printer=%s ams=%s tray=%s): %s",
+                spool_id,
+                p_id,
+                a_id,
+                t_id,
+                e,
+            )
+            raise HTTPException(
+                status_code=500,
+                detail=(
+                    "Spool linked in Spoolman but the local slot assignment could not be saved. "
+                    "Please re-open the link dialog to retry."
+                ),
+            ) from e
+
+    logger.info("Linked Spoolman spool %s to tag %s", spool_id, spool_tag)
+
+    # Auto-configure AMS slot via MQTT (best-effort; tag link and slot assignment already persisted)
+    if printer_context:
+        p_id, a_id, t_id = printer_context
+        try:
+            spool_data = await client.get_spool(spool_id)
+            mapped = _map_spoolman_spool(spool_data)
+
+            mqtt_client = printer_manager.get_client(p_id)
+            if mqtt_client:
+                tray_type = mapped.get("material") or ""
+                brand = mapped.get("brand") or ""
+                subtype = mapped.get("subtype") or ""
+                if brand:
+                    tray_sub_brands = f"{brand} {tray_type} {subtype}".strip()
+                elif subtype:
+                    tray_sub_brands = f"{tray_type} {subtype}".strip()
+                else:
+                    tray_sub_brands = tray_type
 
-    if result:
-        logger.info("Linked Spoolman spool %s to tag %s", spool_id, spool_tag)
-        return {"success": True, "message": f"Spool {spool_id} linked to AMS tag"}
-    else:
-        raise HTTPException(status_code=500, detail="Failed to update spool")
+                tray_color = (mapped.get("rgba") or "808080FF").upper()
+                if len(tray_color) == 6:
+                    tray_color = tray_color + "FF"
+
+                material_upper = tray_type.upper().strip()
+                tray_info_idx = (
+                    GENERIC_FILAMENT_IDS.get(material_upper)
+                    or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
+                    or ""
+                )
+                setting_id = ""
+                temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
+                temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
+                temp_max = temp_defaults[1]
+
+                # Pull printer state via printer_manager (mqtt_client.printer_state
+                # was a non-existent attribute — the hasattr check silently
+                # returned None, defeating every state-based lookup below).
+                state = printer_manager.get_status(p_id)
+                nozzle_diameter = "0.4"
+                if state and state.nozzles:
+                    nd = state.nozzles[0].nozzle_diameter
+                    if nd:
+                        nozzle_diameter = nd
+
+                kp_result = await db.execute(
+                    select(SpoolmanKProfile).where(
+                        SpoolmanKProfile.spoolman_spool_id == spool_id,
+                        SpoolmanKProfile.printer_id == p_id,
+                    )
+                )
+                kp_rows = kp_result.scalars().all()
+                slot_extruder = None
+                if state and state.ams_extruder_map:
+                    if a_id == 255:
+                        slot_extruder = 1 - t_id
+                    else:
+                        slot_extruder = state.ams_extruder_map.get(str(a_id))
+
+                # Prefer exact extruder match, fall back to extruder-agnostic kp
+                # for the same nozzle. Hard-skip on extruder mismatch silently
+                # dropped valid stored profiles when the AMS-extruder map
+                # shifted since calibration.
+                exact_kp = None
+                fallback_kp = None
+                for kp in kp_rows:
+                    if kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
+                        continue
+                    if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
+                        exact_kp = kp
+                        break
+                    if fallback_kp is None:
+                        fallback_kp = kp
+                matching_kp = exact_kp or fallback_kp
+
+                # Resolve printer-side calibration entry by cali_idx — the
+                # printer keys its calibration table by filament_id, not by
+                # setting_id. Stored kp.setting_id alone isn't enough.
+                printer_kp = None
+                if matching_kp and state and state.kprofiles:
+                    for pkp in state.kprofiles:
+                        if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
+                            printer_kp = pkp
+                            break
+
+                # Realign slot's filament context to the kp's calibration
+                # context so ams_filament_setting and extrusion_cali_sel
+                # reference the same preset; otherwise the printer drops the
+                # cali_idx to default. PFUS-prefix cloud-user presets are
+                # rejected by the slicer in tray_info_idx — skip realignment
+                # in that case.
+                effective_tray_info_idx = tray_info_idx
+                effective_setting_id = setting_id
+                if printer_kp and printer_kp.filament_id:
+                    if not printer_kp.filament_id.startswith("PFUS"):
+                        effective_tray_info_idx = printer_kp.filament_id
+                    if printer_kp.setting_id:
+                        effective_setting_id = printer_kp.setting_id
+                elif matching_kp and matching_kp.setting_id:
+                    derived = normalize_slicer_filament(matching_kp.setting_id)[0]
+                    if derived and not derived.startswith("PFUS"):
+                        effective_tray_info_idx = derived
+                    effective_setting_id = matching_kp.setting_id
+                if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
+                    logger.info(
+                        "Spoolman link: realigning tray_info_idx %r → %r, setting_id %r → %r (kp_id=%s, source=%s)",
+                        tray_info_idx,
+                        effective_tray_info_idx,
+                        setting_id,
+                        effective_setting_id,
+                        matching_kp.id if matching_kp else None,
+                        "printer" if printer_kp else "stored",
+                    )
+
+                mqtt_client.ams_set_filament_setting(
+                    ams_id=a_id,
+                    tray_id=t_id,
+                    tray_info_idx=effective_tray_info_idx,
+                    tray_type=tray_type,
+                    tray_sub_brands=tray_sub_brands,
+                    tray_color=tray_color,
+                    nozzle_temp_min=temp_min,
+                    nozzle_temp_max=temp_max,
+                    setting_id=effective_setting_id,
+                )
+
+                if matching_kp and matching_kp.cali_idx is not None:
+                    cali_filament_id = (
+                        printer_kp.filament_id if printer_kp and printer_kp.filament_id else None
+                    ) or effective_tray_info_idx
+                    mqtt_client.extrusion_cali_sel(
+                        ams_id=a_id,
+                        tray_id=t_id,
+                        cali_idx=matching_kp.cali_idx,
+                        filament_id=cali_filament_id,
+                        nozzle_diameter=nozzle_diameter,
+                    )
+                    logger.info(
+                        "Spoolman link: applied K-profile cali_idx=%d "
+                        "(kp_id=%d, filament_id=%s) for spool %d on printer %d AMS%d-T%d",
+                        matching_kp.cali_idx,
+                        matching_kp.id,
+                        cali_filament_id,
+                        spool_id,
+                        p_id,
+                        a_id,
+                        t_id,
+                    )
+                else:
+                    from backend.app.api.routes.inventory import _find_tray_in_ams_data  # noqa: PLC0415
+
+                    live_tray = None
+                    if state and state.raw_data:
+                        ams_raw = state.raw_data.get("ams", [])
+                        if isinstance(ams_raw, dict):
+                            ams_raw = ams_raw.get("ams", [])
+                        live_tray = _find_tray_in_ams_data(ams_raw, a_id, t_id)
+                    live_cali_idx = (live_tray or {}).get("cali_idx")
+                    if live_cali_idx is not None and live_cali_idx >= 0:
+                        mqtt_client.extrusion_cali_sel(
+                            ams_id=a_id,
+                            tray_id=t_id,
+                            cali_idx=live_cali_idx,
+                            filament_id=effective_tray_info_idx,
+                            nozzle_diameter=nozzle_diameter,
+                        )
+
+                logger.info(
+                    "Auto-configured AMS slot ams=%d tray=%d after linking Spoolman spool %d on printer %d",
+                    a_id,
+                    t_id,
+                    spool_id,
+                    p_id,
+                )
+        except (SpoolmanNotFoundError, SpoolmanUnavailableError) as e:
+            logger.warning(
+                "Could not fetch Spoolman spool %d for MQTT configure after tag link: %s",
+                spool_id,
+                e,
+            )
+        except Exception:
+            logger.exception(
+                "Failed to auto-configure AMS slot after linking Spoolman spool %d (printer=%d ams=%d tray=%d)",
+                spool_id,
+                p_id,
+                a_id,
+                t_id,
+            )
+
+    return {"success": True, "message": f"Spool {spool_id} linked to AMS tag"}
 
 
 @router.post("/spools/{spool_id}/unlink")
@@ -764,14 +1049,33 @@ async def unlink_spool(
     if not await client.health_check():
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
-    result = await client.update_spool(
-        spool_id=spool_id,
-        clear_location=True,
-        extra={"tag": json.dumps("")},
-    )
+    # Spoolman PATCHes the extra dict by MERGING with the existing keys —
+    # popping "tag" from a copy of the dict and sending the rest doesn't
+    # clear it; Spoolman keeps the old value because the key wasn't in the
+    # payload. To actually clear a key we must explicitly send it as the
+    # JSON-encoded empty string ('""'), which the read-side filters in
+    # _map_spoolman_spool and get_linked_spools strip via .strip('"').
+    #
+    # merge_spool_extra acquires extra_lock(spool_id) internally — wrapping
+    # this call in another `async with client.extra_lock(spool_id)` would
+    # deadlock (asyncio.Lock is not reentrant).
+    try:
+        await client.merge_spool_extra(spool_id, {"tag": json.dumps("")})
+    except SpoolmanNotFoundError:
+        raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
+    except SpoolmanClientError:
+        raise HTTPException(status_code=502, detail="Spoolman rejected the request")
+    except SpoolmanUnavailableError:
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
-    if result:
-        logger.info("Unlinked Spoolman spool %s", spool_id)
-        return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
-    else:
-        raise HTTPException(status_code=500, detail="Failed to update spool")
+    # Remove local slot assignment for this spool (all slots — a spool can only be in one at a time)
+    try:
+        await db.execute(delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spool_id))
+        await db.commit()
+    except Exception:
+        await db.rollback()
+        logger.exception("DB error removing slot assignment for spool %s", spool_id)
+        raise HTTPException(status_code=500, detail="Failed to remove local slot assignment")
+
+    logger.info("Unlinked Spoolman spool %s", spool_id)
+    return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}

+ 1541 - 0
backend/app/api/routes/spoolman_inventory.py

@@ -0,0 +1,1541 @@
+"""Spoolman inventory proxy endpoints.
+
+Translates between Spoolman's data model and Bambuddy's internal
+InventorySpool format so the frontend can use a single unified inventory UI
+regardless of whether data comes from the local database or Spoolman.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import re
+import time
+from contextlib import asynccontextmanager
+
+from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel, Field, field_validator, model_validator
+from sqlalchemy import delete, select, text
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.api.routes._spoolman_helpers import (
+    NormalizedFilament,
+    NormalizedVendorRef,
+    _map_spoolman_spool,
+    _safe_float,
+    _safe_int,
+    _safe_optional_float,
+    assert_safe_spoolman_url,
+)
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.ams_label import AmsLabel
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
+from backend.app.models.spoolman_k_profile import SpoolmanKProfile
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.models.user import User
+from backend.app.schemas.spool import SpoolKProfileBase
+from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
+from backend.app.services.printer_manager import printer_manager
+from backend.app.services.spoolman import (
+    SpoolmanClient,
+    SpoolmanClientError,
+    SpoolmanNotFoundError,
+    SpoolmanUnavailableError,
+    get_spoolman_client,
+    init_spoolman_client,
+)
+from backend.app.utils.filament_ids import (
+    GENERIC_FILAMENT_IDS,
+    MATERIAL_TEMPS,
+    normalize_slicer_filament,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/spoolman/inventory", tags=["spoolman-inventory"])
+
+
+# Cache the last successful health-check timestamp to avoid a round-trip on
+# every request.  A failed check clears the cache immediately.
+_health_check_cache: dict[str, float] = {}
+_HEALTH_CHECK_TTL = 30.0  # seconds
+
+
+def _tag_cleared(val: str | None) -> bool:
+    """Return True when a PATCH field explicitly removes a tag (null)."""
+    return val is None
+
+
+async def _get_client(db: AsyncSession) -> SpoolmanClient:
+    """Return a validated Spoolman client (URL checked, health-checked) or raise an HTTP error."""
+    result = await db.execute(select(Settings))
+    settings: dict[str, str] = {s.key: s.value for s in result.scalars().all()}
+
+    enabled = settings.get("spoolman_enabled", "false").lower() == "true"
+    url = settings.get("spoolman_url", "").strip()
+
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+    if not url:
+        raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    # SSRF guard: reject dangerous schemes, cloud-metadata IPs (169.254.169.254, 100.100.100.200,
+    # fd00:ec2::254), multicast and unspecified addresses — loopback and RFC-1918 ranges are
+    # intentionally permitted (Spoolman commonly runs on the same host or home LAN).
+    # Raises ValueError with a descriptive message on any violation.
+    try:
+        assert_safe_spoolman_url(url)
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+    # Re-use the cached client when URL is unchanged; reinitialise on URL change (cache invalidation).
+    client = await get_spoolman_client()
+    if not client or client.base_url != url.rstrip("/"):
+        try:
+            client = await init_spoolman_client(url)
+        except ValueError as exc:
+            raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+    # Only call health_check() when the cached result has expired.
+    # Evict stale entries when URL changes (only one Spoolman URL is active at a time).
+    if url not in _health_check_cache and _health_check_cache:
+        _health_check_cache.clear()
+    now = time.monotonic()
+    last_ok = _health_check_cache.get(url, 0.0)
+    if now - last_ok > _HEALTH_CHECK_TTL:
+        if not await client.health_check():
+            _health_check_cache.pop(url, None)
+            raise HTTPException(status_code=503, detail="Spoolman server is not reachable")
+        _health_check_cache[url] = now
+
+    return client
+
+
+@asynccontextmanager
+async def _translate_spoolman_errors():
+    """Translate Spoolman typed exceptions to HTTP errors for all inventory endpoints."""
+    try:
+        yield
+    except SpoolmanNotFoundError as exc:
+        raise HTTPException(status_code=404, detail="Spool not found in Spoolman") from exc
+    except SpoolmanClientError as exc:
+        raise HTTPException(
+            status_code=502,
+            detail={
+                "message": "Spoolman rejected the request",
+                "upstream_status": exc.status_code,
+                "upstream_body": getattr(exc, "response_text", ""),
+            },
+        ) from exc
+    except SpoolmanUnavailableError as exc:
+        raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
+
+
+def _raise_if_partial_failure(spools: list[dict], results: list, operation: str) -> None:
+    """Raise HTTP 502 if any gather result is an exception, logging each failure."""
+    failures = [(s["id"], r) for s, r in zip(spools, results, strict=True) if isinstance(r, BaseException)]
+    if failures:
+        logger.error(
+            "Partial %s failure: %d/%d spools failed: %s",
+            operation,
+            len(failures),
+            len(spools),
+            [(sid, type(exc).__name__) for sid, exc in failures],
+        )
+        raise HTTPException(
+            status_code=502,
+            detail=f"{operation} partially applied: {len(spools) - len(failures)}/{len(spools)} spools updated",
+        )
+
+
+async def _apply_price_if_set(client: SpoolmanClient, spool: dict, cost_per_kg: float | None) -> tuple[dict, list[str]]:
+    """Patch the spool price; return (updated_spool, warnings).
+
+    Returns the original spool and a non-empty warnings list when the price
+    update fails, so the caller can return HTTP 207 instead of silently
+    discarding the price.
+    """
+    if cost_per_kg is None:
+        return spool, []
+    try:
+        async with _translate_spoolman_errors():
+            updated = await client.update_spool_full(spool["id"], price=cost_per_kg)
+        return updated, []
+    except HTTPException as exc:
+        if exc.status_code >= 500:
+            raise  # Propagate network/server errors — don't swallow Spoolman outages
+        logger.warning(
+            "Price update failed for spool %d; spool created without price (cost_per_kg=%s, status=%d)",
+            spool["id"],
+            cost_per_kg,
+            exc.status_code,
+        )
+        return spool, [f"price_not_set: Spoolman rejected the price update (HTTP {exc.status_code})"]
+
+
+# ---------------------------------------------------------------------------
+# Request / response schemas
+# ---------------------------------------------------------------------------
+
+
+_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$")
+
+
+def _validate_rgba(v: str | None) -> str | None:
+    if v is None:
+        return v
+    clean = v.removeprefix("#")
+    if not _HEX_RE.match(clean):
+        raise ValueError("rgba must be a 6 or 8 character hex string (RRGGBB or RRGGBBAA)")
+    return clean.upper()
+
+
+def _validate_storage_location(v: str | None) -> str | None:
+    if v is not None and any(c in v for c in ("\r", "\n", "\x00")):
+        raise ValueError("storage_location must not contain control characters")
+    return v
+
+
+class SpoolmanInventoryCreate(BaseModel):
+    # When spoolman_filament_id is provided the caller has already chosen a filament from the
+    # Spoolman catalog, so material (and other metadata) are optional — the backend skips
+    # find_or_create_filament() and uses the supplied ID directly.
+    spoolman_filament_id: int | None = Field(None, gt=0)
+    material: str | None = Field(None, min_length=1, max_length=64)
+    subtype: str | None = Field(None, max_length=64)
+    brand: str | None = Field(None, max_length=128)
+    color_name: str | None = Field(None, max_length=64)
+    rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)")
+    label_weight: int = Field(1000, ge=1, le=100_000)
+    core_weight: int = Field(
+        250, ge=0, le=10_000
+    )  # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool)
+    weight_used: float = Field(0.0, ge=0.0, le=100_000.0)
+    note: str | None = Field(None, max_length=1000)
+    cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
+    storage_location: str | None = Field(None, max_length=255)
+    # BambuStudio slicer preset for this spool. Spoolman has no native field
+    # for this, so we persist it under the bambu_slicer_filament[_name] keys
+    # in the spool's extra dict and read it back in _map_spoolman_spool.
+    slicer_filament: str | None = Field(None, max_length=128)
+    slicer_filament_name: str | None = Field(None, max_length=255)
+
+    @field_validator("rgba")
+    @classmethod
+    def validate_rgba(cls, v: str | None) -> str | None:
+        return _validate_rgba(v)
+
+    @field_validator("storage_location")
+    @classmethod
+    def validate_storage_location(cls, v: str | None) -> str | None:
+        return _validate_storage_location(v)
+
+    @model_validator(mode="after")
+    def validate_weight_consistency(self) -> SpoolmanInventoryCreate:
+        # material is required only when the caller has not pre-selected a Spoolman filament
+        if self.spoolman_filament_id is None and not self.material:
+            raise ValueError("material is required when spoolman_filament_id is not provided")
+        if self.weight_used > self.label_weight:
+            raise ValueError("weight_used must not exceed label_weight")
+        return self
+
+
+class SpoolmanInventoryUpdate(BaseModel):
+    material: str | None = Field(None, min_length=1, max_length=64)
+    subtype: str | None = Field(None, max_length=64)
+    brand: str | None = Field(None, max_length=128)
+    color_name: str | None = Field(None, max_length=64)
+    rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)")
+    label_weight: int | None = Field(None, ge=1, le=100_000)
+    core_weight: int | None = Field(
+        None, ge=0, le=10_000
+    )  # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool)
+    weight_used: float | None = Field(None, ge=0.0, le=100_000.0)
+    note: str | None = Field(None, max_length=1000)
+    cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
+    tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
+    tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$")
+    storage_location: str | None = Field(None, max_length=255)
+    # BambuStudio slicer preset — persisted to Spoolman extra dict (see Create
+    # schema). Pass an empty string to clear; null/omitted leaves unchanged.
+    slicer_filament: str | None = Field(None, max_length=128)
+    slicer_filament_name: str | None = Field(None, max_length=255)
+
+    @field_validator("rgba")
+    @classmethod
+    def validate_rgba(cls, v: str | None) -> str | None:
+        return _validate_rgba(v)
+
+    @field_validator("storage_location")
+    @classmethod
+    def validate_storage_location(cls, v: str | None) -> str | None:
+        return _validate_storage_location(v)
+
+    @model_validator(mode="after")
+    def validate_tag_fields(self) -> SpoolmanInventoryUpdate:
+        # null = remove tag; non-null values rejected (use /tag endpoint to write tags)
+        if self.tag_uid is not None:
+            raise ValueError("tag_uid cannot be set via this endpoint; use PATCH /spools/{id}/tag to write tags")
+        if self.tray_uuid is not None:
+            raise ValueError("tray_uuid cannot be set via this endpoint; use PATCH /spools/{id}/tag to write tags")
+        return self
+
+    @model_validator(mode="after")
+    def validate_weight_consistency(self) -> SpoolmanInventoryUpdate:
+        if self.weight_used is not None and self.label_weight is not None:
+            if self.weight_used > self.label_weight:
+                raise ValueError("weight_used must not exceed label_weight")
+        return self
+
+
+class SpoolmanInventoryBulkCreate(BaseModel):
+    spool: SpoolmanInventoryCreate
+    quantity: int = Field(1, ge=1, le=50)
+
+
+class SpoolWeightUpdate(BaseModel):
+    weight_grams: float = Field(..., ge=0.0, le=100_000.0)
+
+
+class SpoolTagLinkRequest(BaseModel):
+    # Minimum 8 hex chars = 4-byte NFC UID (Bambu Lab hardware tags use 4-byte UIDs).
+    tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
+    tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$")
+
+    @field_validator("tag_uid")
+    @classmethod
+    def tag_uid_not_all_zeros(cls, v: str | None) -> str | None:
+        if v is not None and all(c in "0" for c in v):
+            raise ValueError("tag_uid must not be all-zero bytes")
+        return v
+
+    @model_validator(mode="after")
+    def at_least_one(self) -> SpoolTagLinkRequest:
+        if not self.tag_uid and not self.tray_uuid:
+            raise ValueError("tag_uid or tray_uuid is required")
+        return self
+
+
+class SpoolSlotAssignmentRequest(BaseModel):
+    spoolman_spool_id: int = Field(..., gt=0)
+    printer_id: int = Field(..., gt=0)
+    # ams_id 0–7 for physical AMS units; 255 = external/virtual spool extruder slot
+    ams_id: int = Field(..., ge=0, le=255)
+    tray_id: int = Field(..., ge=0, le=3)
+
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
+
+
+@router.get("/spools")
+async def list_spools(
+    include_archived: bool = Query(False),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> list[dict]:
+    """Return all Spoolman spools in the InventorySpool format."""
+    client = await _get_client(db)
+    async with _translate_spoolman_errors():
+        spools = await client.get_all_spools(allow_archived=include_archived)
+
+    mapped: list[dict] = []
+    spool_ids: list[int] = []
+    for s in spools:
+        try:
+            m = _map_spoolman_spool(s)
+            mapped.append(m)
+            spool_ids.append(m["id"])
+        except ValueError as exc:
+            logger.warning("Skipping malformed Spoolman spool (id=%r): %s", s.get("id"), exc)
+
+    if spool_ids:
+        kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id.in_(spool_ids)))
+        kp_by_spool: dict[int, list[dict]] = {}
+        for kp in kp_result.scalars().all():
+            kp_by_spool.setdefault(kp.spoolman_spool_id, []).append(_k_profile_to_dict(kp))
+        for m in mapped:
+            m["k_profiles"] = kp_by_spool.get(m["id"], [])
+
+    return mapped
+
+
+@router.get("/spools/{spool_id}")
+async def get_spool(
+    spool_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> dict:
+    """Return a single Spoolman spool in the InventorySpool format."""
+    client = await _get_client(db)
+    async with _translate_spoolman_errors():
+        spool = await client.get_spool(spool_id)
+    try:
+        mapped = _map_spoolman_spool(spool)
+    except ValueError as exc:
+        logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
+        raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
+
+    kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
+    mapped["k_profiles"] = [_k_profile_to_dict(kp) for kp in kp_result.scalars().all()]
+    return mapped
+
+
+async def _resolve_filament_id(data: SpoolmanInventoryCreate, client: SpoolmanClient) -> int:
+    """Return the Spoolman filament ID for this spool creation request.
+
+    If spoolman_filament_id is set the caller pre-selected a catalog entry,
+    so find_or_create_filament() is skipped and the ID is used directly.
+    """
+    if data.spoolman_filament_id is not None:
+        return data.spoolman_filament_id
+    # Validator guarantees material is non-None when spoolman_filament_id is None
+    assert data.material is not None  # noqa: S101
+    color_hex = (data.rgba or "808080FF")[:6]
+    async with _translate_spoolman_errors():
+        return await client.find_or_create_filament(
+            material=data.material,
+            subtype=data.subtype or "",
+            brand=data.brand,
+            color_hex=color_hex,
+            label_weight=data.label_weight,
+            color_name=data.color_name,
+        )
+
+
+@router.post("/spools")
+async def create_spool(
+    data: SpoolmanInventoryCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Create a new spool in Spoolman, auto-creating vendor and filament as needed."""
+    client = await _get_client(db)
+    filament_id = await _resolve_filament_id(data, client)
+
+    remaining = max(0.0, data.label_weight - data.weight_used)
+    try:
+        async with _translate_spoolman_errors():
+            spool = await client.create_spool(
+                filament_id=filament_id,
+                remaining_weight=remaining,
+                comment=data.note or None,
+                location=data.storage_location or None,
+            )
+    except HTTPException as exc:
+        if exc.status_code == 404 and data.spoolman_filament_id is not None:
+            raise HTTPException(
+                status_code=404,
+                detail=f"Filament {data.spoolman_filament_id} not found in Spoolman",
+            ) from exc
+        raise
+
+    spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
+
+    # Persist slicer_filament under the spool's extra dict (mirror update_spool).
+    if data.slicer_filament is not None or data.slicer_filament_name is not None:
+        # Ensure extra fields are registered before write.
+        if data.slicer_filament is not None:
+            await client.ensure_extra_field("bambu_slicer_filament")
+        if data.slicer_filament_name is not None:
+            await client.ensure_extra_field("bambu_slicer_filament_name")
+        new_extra: dict = {}
+        if data.slicer_filament is not None:
+            new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament)
+        if data.slicer_filament_name is not None:
+            new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name)
+        if new_extra:
+            try:
+                async with _translate_spoolman_errors():
+                    spool = await client.merge_spool_extra(spool["id"], new_extra)
+            except HTTPException:
+                # Best-effort — the spool already exists, log and continue.
+                logger.warning(
+                    "Failed to persist slicer_filament for spool %s",
+                    spool.get("id"),
+                )
+
+    result = _map_spoolman_spool(spool)
+    if price_warnings:
+        return JSONResponse(status_code=207, content={**result, "warnings": price_warnings})
+    return result
+
+
+@router.post("/spools/bulk")
+async def bulk_create_spools(
+    payload: SpoolmanInventoryBulkCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> Response:
+    """Create multiple identical spools in Spoolman."""
+    client = await _get_client(db)
+    data = payload.spool
+
+    try:
+        filament_id = await _resolve_filament_id(data, client)
+    except HTTPException as exc:
+        if exc.status_code == 404 and data.spoolman_filament_id is not None:
+            raise HTTPException(
+                status_code=404,
+                detail=f"Filament {data.spoolman_filament_id} not found in Spoolman",
+            ) from exc
+        raise
+
+    remaining = max(0.0, data.label_weight - data.weight_used)
+    created: list[dict] = []
+    failures: list[str] = []
+    for _ in range(payload.quantity):
+        try:
+            spool = await client.create_spool(
+                filament_id=filament_id,
+                remaining_weight=remaining,
+                comment=data.note or None,
+                location=data.storage_location or None,
+            )
+        except (SpoolmanUnavailableError, SpoolmanClientError, SpoolmanNotFoundError) as exc:
+            logger.warning("Bulk spool creation: one spool failed: %s", exc)
+            failures.append("spool creation failed")
+            continue
+        try:
+            spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
+        except HTTPException as exc:
+            logger.warning(
+                "Bulk spool %d: price update failed (HTTP %d); spool not added to created list",
+                spool.get("id", 0),
+                exc.status_code,
+            )
+            failures.append("spool created but price update failed")
+            continue
+        if price_warnings:
+            logger.warning("Bulk spool %s created without price: %s", spool.get("id"), price_warnings)
+        created.append(_map_spoolman_spool(spool))
+
+    if not created:
+        raise HTTPException(status_code=500, detail="Failed to create any spools in Spoolman")
+
+    if len(created) < payload.quantity:
+        # Some spool creations failed — return 207 Multi-Status so the caller
+        # can distinguish a full success from a partial one and show a useful message.
+        return JSONResponse(
+            status_code=207,
+            content={
+                "created": created,
+                "requested_count": payload.quantity,
+                "failed_count": payload.quantity - len(created),
+                "failures": failures,
+            },
+        )
+
+    return JSONResponse(status_code=200, content=created)
+
+
+@router.patch("/spools/{spool_id}")
+async def update_spool(
+    *,
+    spool_id: int = Path(..., gt=0),
+    data: SpoolmanInventoryUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Update an existing Spoolman spool, re-linking the filament if metadata changed."""
+    client = await _get_client(db)
+
+    async with _translate_spoolman_errors():
+        current = await client.get_spool(spool_id)
+
+    cur_filament: dict = current.get("filament") or {}
+    cur_vendor: dict = cur_filament.get("vendor") or {}
+    cur_mat: str = (cur_filament.get("material") or "").strip()
+    cur_name: str = (cur_filament.get("name") or "").strip()
+    if cur_mat and cur_name.upper().startswith(cur_mat.upper()):
+        cur_subtype: str = cur_name[len(cur_mat) :].strip()
+    else:
+        cur_subtype = cur_name
+
+    # Resolve final values: use request value if provided, else keep current
+    material = data.material if data.material is not None else cur_mat
+    subtype = data.subtype if data.subtype is not None else cur_subtype
+    brand = data.brand if data.brand is not None else (cur_vendor.get("name") or None)
+    color_name = data.color_name if data.color_name is not None else (cur_filament.get("color_name") or None)
+    cur_color = (cur_filament.get("color_hex") or "808080").upper().removeprefix("#")
+    rgba = data.rgba if data.rgba is not None else (cur_color + "FF")
+    label_weight = data.label_weight if data.label_weight is not None else int(cur_filament.get("weight") or 1000)
+    weight_used = data.weight_used if data.weight_used is not None else float(current.get("used_weight") or 0)
+    note = data.note if data.note is not None else current.get("comment")
+    storage_location_changed = "storage_location" in data.model_fields_set
+    storage_location = data.storage_location if storage_location_changed else None
+
+    color_hex = rgba[:6]
+    async with _translate_spoolman_errors():
+        filament_id = await client.find_or_create_filament(
+            material=material,
+            subtype=subtype or "",
+            brand=brand,
+            color_hex=color_hex,
+            label_weight=label_weight,
+            color_name=color_name,
+        )
+    if not filament_id:
+        raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman")
+
+    remaining = max(0.0, label_weight - weight_used)
+
+    # Tag removal: clear only the "tag" key so other custom Spoolman extra fields
+    # set outside Bambuddy are preserved.
+    tag_nulled = (
+        ("tag_uid" in data.model_fields_set or "tray_uuid" in data.model_fields_set)
+        and _tag_cleared(data.tag_uid)
+        and _tag_cleared(data.tray_uuid)
+    )
+
+    # Serialise tag-clear + PATCH under the per-spool extra lock to prevent a
+    # concurrent merge_spool_extra call (e.g. NFC write-back) from overwriting
+    # the tag key between our read and our write.
+    #
+    # Spoolman PATCHes extra dicts by MERGING — popping "tag" from a re-fetched
+    # dict and sending the rest doesn't clear the key (Spoolman keeps the old
+    # value because the key wasn't in the payload). Explicitly set the tag to
+    # a JSON-encoded empty string; read-side filters strip the quotes.
+    async with client.extra_lock(spool_id):
+        if tag_nulled:
+            # Re-fetch inside the lock so we work with fresh extra data.
+            async with _translate_spoolman_errors():
+                fresh = await client.get_spool(spool_id)
+            cur_extra = dict(fresh.get("extra") or {})
+            cur_extra["tag"] = json.dumps("")
+            extra: dict | None = cur_extra
+        else:
+            extra = None
+
+        async with _translate_spoolman_errors():
+            updated = await client.update_spool_full(
+                spool_id=spool_id,
+                filament_id=filament_id,
+                remaining_weight=remaining,
+                comment=note or "",
+                price=data.cost_per_kg,
+                extra=extra,
+                location=storage_location or None,
+                clear_location=storage_location_changed and not storage_location,
+            )
+
+    # Persist BambuStudio slicer preset under the spool's extra dict.
+    # Spoolman doesn't have a native field for this, so we round-trip via
+    # extra and unpack in _map_spoolman_spool. Only writes when the request
+    # explicitly set the field — passing null/omitting leaves the existing
+    # extra entry untouched (write empty string to clear).
+    sf_set = "slicer_filament" in data.model_fields_set
+    sfn_set = "slicer_filament_name" in data.model_fields_set
+    if sf_set or sfn_set:
+        # Ensure extra fields are registered (Spoolman rejects PATCHes with
+        # unknown keys with HTTP 400). Idempotent if startup already ran this.
+        if sf_set:
+            await client.ensure_extra_field("bambu_slicer_filament")
+        if sfn_set:
+            await client.ensure_extra_field("bambu_slicer_filament_name")
+        new_extra: dict = {}
+        if sf_set:
+            new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament or "")
+        if sfn_set:
+            new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name or "")
+        async with _translate_spoolman_errors():
+            updated = await client.merge_spool_extra(spool_id, new_extra)
+
+    return _map_spoolman_spool(updated)
+
+
+@router.delete("/spools/{spool_id}")
+async def delete_spool(
+    spool_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Permanently delete a spool from Spoolman."""
+    client = await _get_client(db)
+    async with _translate_spoolman_errors():
+        await client.delete_spool(spool_id)
+    return {"status": "deleted"}
+
+
+@router.post("/spools/{spool_id}/archive")
+async def archive_spool(
+    spool_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Archive a spool in Spoolman (soft-delete)."""
+    client = await _get_client(db)
+    async with _translate_spoolman_errors():
+        spool = await client.set_spool_archived(spool_id, archived=True)
+    try:
+        return _map_spoolman_spool(spool)
+    except ValueError as exc:
+        logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
+        raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
+
+
+@router.post("/spools/{spool_id}/restore")
+async def restore_spool(
+    spool_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Restore an archived spool in Spoolman."""
+    client = await _get_client(db)
+    async with _translate_spoolman_errors():
+        spool = await client.set_spool_archived(spool_id, archived=False)
+    try:
+        return _map_spoolman_spool(spool)
+    except ValueError as exc:
+        logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
+        raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
+
+
+@router.patch("/spools/{spool_id}/weight")
+async def sync_spool_weight(
+    *,
+    spool_id: int = Path(..., gt=0),
+    data: SpoolWeightUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Update a spool's remaining weight from a measured gross weight.
+
+    Computes remaining = gross_weight - tare, where tare = spool.spool_weight
+    if set, else filament.spool_weight; falls back to 250 g when both unset.
+    """
+    client = await _get_client(db)
+
+    async with _translate_spoolman_errors():
+        current = await client.get_spool(spool_id)
+
+    cur_filament = current.get("filament") or {}
+    spool_tare = current.get("spool_weight")
+    raw_tare = spool_tare if spool_tare is not None else cur_filament.get("spool_weight")
+    core_weight = _safe_float(raw_tare, 250.0)
+    remaining = max(0.0, data.weight_grams - core_weight)
+
+    async with _translate_spoolman_errors():
+        updated = await client.update_spool_full(spool_id=spool_id, remaining_weight=remaining)
+
+    upd_filament = updated.get("filament") or {}
+    label_weight = _safe_int(upd_filament.get("weight"), 1000)
+    weight_used = max(0.0, label_weight - remaining)
+    return {"status": "ok", "weight_used": weight_used}
+
+
+@router.patch("/spools/{spool_id}/tag")
+async def link_tag_to_spoolman_spool(
+    *,
+    spool_id: int = Path(..., gt=0),
+    data: SpoolTagLinkRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Write an NFC tag UID or Bambu tray UUID into Spoolman's extra.tag for a spool.
+
+    tray_uuid takes precedence over tag_uid when both are supplied.
+    Returns 409 if another spool already carries the same tag.
+    Uses extra_lock to serialise against concurrent extra-field writes.
+    """
+    client = await _get_client(db)
+    tag = (data.tray_uuid or data.tag_uid).upper()
+    tag_json = json.dumps(tag)
+
+    async with client.extra_lock(spool_id):
+        # Duplicate check: scan all spools for the same tag on a different spool.
+        async with _translate_spoolman_errors():
+            all_spools = await client.get_all_spools()
+        for s in all_spools:
+            s_tag = (s.get("extra") or {}).get("tag", "")
+            if s_tag.strip('"').upper() == tag and s.get("id") != spool_id:
+                raise HTTPException(
+                    status_code=409,
+                    detail=f"Tag is already assigned to spool {s['id']}",
+                )
+
+        # Re-fetch inside the lock so cur_extra reflects any concurrent update.
+        async with _translate_spoolman_errors():
+            current = await client.get_spool(spool_id)
+        cur_extra = dict(current.get("extra") or {})
+        cur_extra["tag"] = tag_json
+        async with _translate_spoolman_errors():
+            updated = await client.update_spool_full(spool_id=spool_id, extra=cur_extra)
+
+    logger.info("Linked tag %s to Spoolman spool %s", tag, spool_id)
+    return _map_spoolman_spool(updated)
+
+
+@router.get("/slot-assignments/all", response_model=list[SpoolmanSlotAssignmentEnriched])
+async def get_all_spoolman_slot_assignments(
+    printer_id: int | None = Query(None, gt=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> list[SpoolmanSlotAssignmentEnriched]:
+    """Return all Spoolman slot assignments enriched with printer_name and ams_label.
+
+    ``printer_name`` is null only when the printer relation is missing
+    (cascade-deleted edge case). ``ams_label`` is null when no AmsLabel row
+    matches the slot's MQTT serial (or the synthetic ``f"p{pid}a{ams_id}"``
+    fallback key).
+    """
+    query = select(SpoolmanSlotAssignment).options(selectinload(SpoolmanSlotAssignment.printer))
+    if printer_id is not None:
+        query = query.where(SpoolmanSlotAssignment.printer_id == printer_id)
+    result = await db.execute(query)
+    slots = list(result.scalars().all())
+
+    # Build (printer_id, ams_id) -> ams_serial map from live printer states.
+    # Same pattern as inventory.py:765-806 for the local /assignments endpoint.
+    printer_ids = {s.printer_id for s in slots}
+    serial_map: dict[tuple[int, int], str] = {}
+    all_statuses = printer_manager.get_all_statuses()
+    for pid in printer_ids:
+        state = all_statuses.get(pid)
+        if not (state and state.raw_data):
+            continue
+        # Some printer firmware variants wrap the AMS list in an outer dict
+        # (`{"ams": [...]}`). Mirror the defense used in sync_spoolman_ams_weights
+        # (line 842-844) so a wrapped payload still resolves to a list.
+        ams_raw = state.raw_data.get("ams", [])
+        if isinstance(ams_raw, dict):
+            ams_raw = ams_raw.get("ams", [])
+        if not isinstance(ams_raw, list):
+            continue
+        for ams_unit in ams_raw:
+            if not isinstance(ams_unit, dict):
+                continue
+            sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
+            if not sn:
+                continue
+            try:
+                serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
+            except (ValueError, TypeError):
+                continue
+
+    # Add synthetic fallback key (f"p{pid}a{ams_id}") for slots without a serial.
+    all_serials: set[str] = set(serial_map.values())
+    for s in slots:
+        if (s.printer_id, s.ams_id) not in serial_map:
+            all_serials.add(f"p{s.printer_id}a{s.ams_id}")
+
+    label_by_serial: dict[str, str] = {}
+    if all_serials:
+        lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
+        for lbl in lbl_result.scalars().all():
+            label_by_serial[lbl.ams_serial_number] = lbl.label
+
+    def _ams_label_for(pid: int, ams_id: int) -> str | None:
+        sn = serial_map.get((pid, ams_id))
+        if sn and sn in label_by_serial:
+            return label_by_serial[sn]
+        if not sn:
+            return label_by_serial.get(f"p{pid}a{ams_id}")
+        return None
+
+    enriched: list[SpoolmanSlotAssignmentEnriched] = []
+    for s in slots:
+        if s.printer is None:
+            # FK is ondelete=CASCADE so this should be unreachable in normal
+            # operation; surface it loudly if a stale row ever appears.
+            logger.warning(
+                "Orphaned Spoolman slot assignment: printer_id=%d (ams=%d, tray=%d, spoolman_spool_id=%d) has no Printer row",
+                s.printer_id,
+                s.ams_id,
+                s.tray_id,
+                s.spoolman_spool_id,
+            )
+        enriched.append(
+            SpoolmanSlotAssignmentEnriched(
+                printer_id=s.printer_id,
+                printer_name=s.printer.name if s.printer else None,
+                ams_id=s.ams_id,
+                tray_id=s.tray_id,
+                spoolman_spool_id=s.spoolman_spool_id,
+                ams_label=_ams_label_for(s.printer_id, s.ams_id),
+            )
+        )
+    return enriched
+
+
+@router.post("/sync-ams-weights")
+async def sync_spoolman_ams_weights(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Sync remaining weight back to Spoolman for all slot-assigned spools.
+
+    Reads live AMS remain% from connected printers, computes
+    remaining = label_weight * remain% / 100, and PATCHes Spoolman.
+    """
+    client = await _get_client(db)
+
+    # Fetch all non-archived Spoolman spools once for label_weight lookup
+    async with _translate_spoolman_errors():
+        raw_spools = await client.get_all_spools(allow_archived=False)
+    spool_lookup: dict[int, dict] = {s["id"]: s for s in raw_spools if s.get("id") is not None}
+
+    result = await db.execute(select(SpoolmanSlotAssignment))
+    assignments = list(result.scalars().all())
+
+    synced = 0
+    skipped = 0
+
+    def _find_tray(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
+        if not ams_data:
+            return None
+        for ams_unit in ams_data:
+            if _safe_int(ams_unit.get("id"), -1) != ams_id:
+                continue
+            for tray in ams_unit.get("tray", []):
+                if _safe_int(tray.get("id"), -1) == tray_id:
+                    return tray
+        return None
+
+    for assignment in assignments:
+        spool_dict = spool_lookup.get(assignment.spoolman_spool_id)
+        if not spool_dict:
+            logger.debug("Spoolman AMS sync: spool %d not found in Spoolman, skipping", assignment.spoolman_spool_id)
+            skipped += 1
+            continue
+
+        label_weight = _safe_int((spool_dict.get("filament") or {}).get("weight"), 1000)
+        if label_weight <= 0:
+            logger.debug("Spoolman AMS sync: spool %d has no label_weight, skipping", assignment.spoolman_spool_id)
+            skipped += 1
+            continue
+
+        state = printer_manager.get_status(assignment.printer_id)
+        if not state or not state.raw_data:
+            logger.info(
+                "Spoolman AMS sync: printer %d not connected, skipping spool %d",
+                assignment.printer_id,
+                assignment.spoolman_spool_id,
+            )
+            skipped += 1
+            continue
+
+        ams_raw = state.raw_data.get("ams", [])
+        if isinstance(ams_raw, dict):
+            ams_raw = ams_raw.get("ams", [])
+        tray = _find_tray(ams_raw, assignment.ams_id, assignment.tray_id)
+        if not tray:
+            logger.info(
+                "Spoolman AMS sync: no tray data for spool %d (printer %d AMS%d-T%d)",
+                assignment.spoolman_spool_id,
+                assignment.printer_id,
+                assignment.ams_id,
+                assignment.tray_id,
+            )
+            skipped += 1
+            continue
+
+        remain_raw = tray.get("remain")
+        if remain_raw is None:
+            logger.debug(
+                "Spoolman AMS sync: no remain value for spool %d (tray %d/%d), skipping",
+                assignment.spoolman_spool_id,
+                assignment.ams_id,
+                assignment.tray_id,
+            )
+            skipped += 1
+            continue
+
+        try:
+            remain_val = int(remain_raw)
+        except (TypeError, ValueError):
+            logger.debug(
+                "Spoolman AMS sync: non-numeric remain=%r for spool %d, skipping",
+                remain_raw,
+                assignment.spoolman_spool_id,
+            )
+            skipped += 1
+            continue
+
+        if remain_val < 0 or remain_val > 100:
+            logger.debug("Spoolman AMS sync: invalid remain=%s for spool %d", remain_raw, assignment.spoolman_spool_id)
+            skipped += 1
+            continue
+
+        remaining = round(label_weight * remain_val / 100.0, 1)
+        try:
+            async with _translate_spoolman_errors():
+                await client.update_spool_full(assignment.spoolman_spool_id, remaining_weight=remaining)
+            logger.info(
+                "Spoolman AMS sync: spool %d remaining set to %s g (remain=%d%%)",
+                assignment.spoolman_spool_id,
+                remaining,
+                remain_val,
+            )
+            synced += 1
+        except HTTPException as exc:
+            if exc.status_code == 404:
+                logger.warning(
+                    "Spoolman AMS sync: spool %d not found in Spoolman (404), skipping",
+                    assignment.spoolman_spool_id,
+                )
+            else:
+                logger.warning(
+                    "Spoolman AMS sync: failed to update spool %d (HTTP %d)",
+                    assignment.spoolman_spool_id,
+                    exc.status_code,
+                )
+            skipped += 1
+
+    return {"synced": synced, "skipped": skipped}
+
+
+@router.post("/slot-assignments")
+async def assign_spoolman_slot(
+    body: SpoolSlotAssignmentRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Assign a Spoolman spool to a printer AMS slot (stored in local DB only).
+
+    Raises 404 if the printer does not exist or the spool is not found in Spoolman.
+    Spoolman's own ``spool.location`` field is NOT touched — it is user-managed.
+    """
+
+    client = await _get_client(db)
+    result = await db.execute(select(Printer).where(Printer.id == body.printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(status_code=404, detail="Printer not found")
+
+    # Verify the Spoolman spool exists before committing to local DB.
+    # This prevents ghost rows pointing at non-existent spool IDs.
+    async with _translate_spoolman_errors():
+        spool = await client.get_spool(body.spoolman_spool_id)
+
+    # Spool confirmed in Spoolman — upsert into local slot-assignment table
+    # assigned_at is intentionally not refreshed on re-assign (original timestamp preserved)
+    try:
+        await db.execute(
+            text(
+                "INSERT INTO spoolman_slot_assignments"
+                " (printer_id, ams_id, tray_id, spoolman_spool_id)"
+                " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
+                " ON CONFLICT(printer_id, ams_id, tray_id)"
+                " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
+            ),
+            {
+                "printer_id": body.printer_id,
+                "ams_id": body.ams_id,
+                "tray_id": body.tray_id,
+                "spool_id": body.spoolman_spool_id,
+            },
+        )
+        await db.commit()
+    except Exception as exc:
+        await db.rollback()
+        logger.error("Failed to persist slot assignment: %s", exc)
+        raise HTTPException(status_code=500, detail="Failed to save slot assignment") from exc
+
+    mapped = _map_spoolman_spool(spool)
+
+    # Fetch K-profiles before the MQTT try block so we can use async DB access.
+    kp_rows_result = await db.execute(
+        select(SpoolmanKProfile).where(
+            SpoolmanKProfile.spoolman_spool_id == body.spoolman_spool_id,
+            SpoolmanKProfile.printer_id == body.printer_id,
+        )
+    )
+    kp_rows = kp_rows_result.scalars().all()
+
+    # Auto-configure AMS slot via MQTT (best-effort; slot assignment is already persisted)
+    try:
+        mqtt_client = printer_manager.get_client(body.printer_id)
+        if mqtt_client:
+            tray_type = mapped.get("material") or ""
+            brand = mapped.get("brand") or ""
+            subtype = mapped.get("subtype") or ""
+            if brand:
+                tray_sub_brands = f"{brand} {tray_type} {subtype}".strip()
+            elif subtype:
+                tray_sub_brands = f"{tray_type} {subtype}".strip()
+            else:
+                tray_sub_brands = tray_type
+
+            tray_color = (mapped.get("rgba") or "808080FF").upper()
+            if len(tray_color) == 6:
+                tray_color = tray_color + "FF"
+
+            material_upper = tray_type.upper().strip()
+            tray_info_idx = (
+                GENERIC_FILAMENT_IDS.get(material_upper)
+                or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
+                or ""
+            )
+            setting_id = ""
+
+            temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
+            temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
+            temp_max = temp_defaults[1]
+
+            # Pull printer state from printer_manager. The previous
+            # `mqtt_client.printer_state` access via hasattr always returned
+            # None (the attribute is `state`, not `printer_state`), so the
+            # K-profile cascade silently skipped state.kprofiles, defaulted
+            # nozzle_diameter to 0.4, and left slot_extruder unset.
+            state = printer_manager.get_status(body.printer_id)
+            nozzle_diameter = "0.4"
+            if state and state.nozzles:
+                nd = state.nozzles[0].nozzle_diameter
+                if nd:
+                    nozzle_diameter = nd
+
+            slot_extruder = None
+            if state and state.ams_extruder_map:
+                if body.ams_id == 255:
+                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
+                    # tray_id 0→1, 1→0
+                    slot_extruder = 1 - body.tray_id
+                else:
+                    slot_extruder = state.ams_extruder_map.get(str(body.ams_id))
+
+            # Prefer exact extruder match, fall back to extruder-agnostic kp
+            # for the same nozzle. Hard-skipping on mismatch silently dropped
+            # valid stored profiles when the AMS-extruder mapping had shifted.
+            exact_kp = None
+            fallback_kp = None
+            for kp in kp_rows:
+                if kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
+                    continue
+                if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
+                    exact_kp = kp
+                    break
+                if fallback_kp is None:
+                    fallback_kp = kp
+            matching_kp = exact_kp or fallback_kp
+
+            # Resolve the printer-side calibration entry by cali_idx so we
+            # know the authoritative filament_id (the printer indexes its
+            # calibration table by filament_id, not setting_id).
+            printer_kp = None
+            if matching_kp and state and state.kprofiles:
+                for pkp in state.kprofiles:
+                    if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
+                        printer_kp = pkp
+                        break
+                if printer_kp is None:
+                    logger.warning(
+                        "Spoolman assign: cali_idx=%d not present in printer's "
+                        "calibration table — stored kp may be stale.",
+                        matching_kp.cali_idx,
+                    )
+
+            # Realign the slot's filament context (tray_info_idx + setting_id)
+            # to the kp's calibration context. Without this, ams_filament_setting
+            # declares the slot under generic PLA while extrusion_cali_sel points
+            # the cali_idx at a different preset — the printer can't link them
+            # and falls back to the default profile. P-prefix local presets are
+            # valid for tray_info_idx; PFUS-prefix cloud-user presets are not
+            # (the slicer rejects them).
+            effective_tray_info_idx = tray_info_idx
+            effective_setting_id = setting_id
+            if printer_kp and printer_kp.filament_id:
+                if not printer_kp.filament_id.startswith("PFUS"):
+                    effective_tray_info_idx = printer_kp.filament_id
+                if printer_kp.setting_id:
+                    effective_setting_id = printer_kp.setting_id
+            elif matching_kp and matching_kp.setting_id:
+                derived = normalize_slicer_filament(matching_kp.setting_id)[0]
+                if derived and not derived.startswith("PFUS"):
+                    effective_tray_info_idx = derived
+                effective_setting_id = matching_kp.setting_id
+            if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
+                logger.info(
+                    "Spoolman assign: realigning tray_info_idx %r → %r, setting_id %r → %r (kp_id=%s, source=%s)",
+                    tray_info_idx,
+                    effective_tray_info_idx,
+                    setting_id,
+                    effective_setting_id,
+                    matching_kp.id if matching_kp else None,
+                    "printer" if printer_kp else "stored",
+                )
+
+            mqtt_client.ams_set_filament_setting(
+                ams_id=body.ams_id,
+                tray_id=body.tray_id,
+                tray_info_idx=effective_tray_info_idx,
+                tray_type=tray_type,
+                tray_sub_brands=tray_sub_brands,
+                tray_color=tray_color,
+                nozzle_temp_min=temp_min,
+                nozzle_temp_max=temp_max,
+                setting_id=effective_setting_id,
+            )
+
+            if matching_kp and matching_kp.cali_idx is not None:
+                # Use printer-reported filament_id when available, otherwise
+                # fall back to the realigned tray_info_idx so both commands
+                # reference the same filament context.
+                cali_filament_id = (
+                    printer_kp.filament_id if printer_kp and printer_kp.filament_id else None
+                ) or effective_tray_info_idx
+                mqtt_client.extrusion_cali_sel(
+                    ams_id=body.ams_id,
+                    tray_id=body.tray_id,
+                    cali_idx=matching_kp.cali_idx,
+                    filament_id=cali_filament_id,
+                    nozzle_diameter=nozzle_diameter,
+                )
+                logger.info(
+                    "Spoolman assign: applied K-profile cali_idx=%d "
+                    "(kp_id=%d, filament_id=%s) for spool %d on printer %d AMS%d-T%d",
+                    matching_kp.cali_idx,
+                    matching_kp.id,
+                    cali_filament_id,
+                    body.spoolman_spool_id,
+                    body.printer_id,
+                    body.ams_id,
+                    body.tray_id,
+                )
+            else:
+                # No stored K-profile: preserve the slot's current live cali_idx
+                from backend.app.api.routes.inventory import _find_tray_in_ams_data
+
+                live_tray = None
+                if state and state.raw_data:
+                    ams_raw = state.raw_data.get("ams", [])
+                    if isinstance(ams_raw, dict):
+                        ams_raw = ams_raw.get("ams", [])
+                    live_tray = _find_tray_in_ams_data(ams_raw, body.ams_id, body.tray_id)
+                live_cali_idx = (live_tray or {}).get("cali_idx")
+                if live_cali_idx is not None and live_cali_idx >= 0:
+                    mqtt_client.extrusion_cali_sel(
+                        ams_id=body.ams_id,
+                        tray_id=body.tray_id,
+                        cali_idx=live_cali_idx,
+                        filament_id=effective_tray_info_idx,
+                        nozzle_diameter=nozzle_diameter,
+                    )
+                    logger.info(
+                        "No stored K-profile for Spoolman spool %d — preserved live cali_idx=%d",
+                        body.spoolman_spool_id,
+                        live_cali_idx,
+                    )
+
+            logger.info(
+                "Auto-configured AMS slot ams=%d tray=%d for Spoolman spool %d on printer %d",
+                body.ams_id,
+                body.tray_id,
+                body.spoolman_spool_id,
+                body.printer_id,
+            )
+    except Exception:
+        logger.exception(
+            "Failed to auto-configure AMS slot for Spoolman spool %d (printer=%d, ams=%d, tray=%d)",
+            body.spoolman_spool_id,
+            body.printer_id,
+            body.ams_id,
+            body.tray_id,
+        )
+
+    return mapped
+
+
+@router.delete("/slot-assignments/{spoolman_spool_id}")
+async def unassign_spoolman_slot(
+    spoolman_spool_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Remove the local slot assignment for a Spoolman spool.
+
+    Spoolman's own ``spool.location`` field is NOT touched — it is user-managed.
+    """
+    client = await _get_client(db)
+
+    try:
+        await db.execute(
+            delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spoolman_spool_id)
+        )
+        await db.commit()
+    except Exception as exc:
+        await db.rollback()
+        logger.error("Failed to delete slot assignment: %s", exc)
+        raise HTTPException(status_code=500, detail="Failed to remove slot assignment") from exc
+
+    # Fetch the spool from Spoolman to return in InventorySpool format.
+    # If the spool no longer exists in Spoolman, the local unassignment still succeeded.
+    try:
+        async with _translate_spoolman_errors():
+            spool = await client.get_spool(spoolman_spool_id)
+        return _map_spoolman_spool(spool)
+    except HTTPException as exc:
+        if exc.status_code != 404:
+            raise
+        # Spool no longer exists in Spoolman; unassignment still succeeded.
+        return {"id": spoolman_spool_id}
+
+
+@router.get("/slot-assignments")
+async def get_spoolman_slot_assignment(
+    printer_id: int = Query(..., gt=0),
+    ams_id: int = Query(..., ge=0, le=7),
+    tray_id: int = Query(..., ge=0, le=3),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> dict | None:
+    """Return the Spoolman spool assigned to a specific printer slot, or null if unassigned."""
+    client = await _get_client(db)
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(status_code=404, detail="Printer not found")
+
+    slot_result = await db.execute(
+        select(SpoolmanSlotAssignment).where(
+            SpoolmanSlotAssignment.printer_id == printer_id,
+            SpoolmanSlotAssignment.ams_id == ams_id,
+            SpoolmanSlotAssignment.tray_id == tray_id,
+        )
+    )
+    slot = slot_result.scalar_one_or_none()
+    if not slot:
+        return None
+
+    try:
+        async with _translate_spoolman_errors():
+            spool = await client.get_spool(slot.spoolman_spool_id)
+        return _map_spoolman_spool(spool)
+    except HTTPException as exc:
+        if exc.status_code != 404:
+            raise
+        # Spool deleted in Spoolman — clean up stale assignment.
+        # Include spoolman_spool_id in WHERE to avoid a TOCTOU race where a
+        # concurrent re-assign changed the slot to a different spool between
+        # the GET and this DELETE.
+        try:
+            await db.execute(
+                delete(SpoolmanSlotAssignment).where(
+                    SpoolmanSlotAssignment.id == slot.id,
+                    SpoolmanSlotAssignment.spoolman_spool_id == slot.spoolman_spool_id,
+                )
+            )
+            await db.commit()
+        except Exception as cleanup_exc:
+            await db.rollback()
+            logger.warning(
+                "Failed to remove stale slot assignment for spool %s: %s",
+                slot.spoolman_spool_id,
+                cleanup_exc,
+            )
+        return None
+
+
+def _k_profile_to_dict(p: SpoolmanKProfile) -> dict:
+    """Manually map SpoolmanKProfile → SpoolKProfileResponse-compatible dict."""
+    return {
+        "id": p.id,
+        "spool_id": p.spoolman_spool_id,
+        "printer_id": p.printer_id,
+        "extruder": p.extruder,
+        "nozzle_diameter": p.nozzle_diameter,
+        "nozzle_type": p.nozzle_type,
+        "k_value": p.k_value,
+        "name": p.name,
+        "cali_idx": p.cali_idx,
+        "setting_id": p.setting_id,
+        "created_at": p.created_at,
+    }
+
+
+def _normalize_filament(raw: dict) -> NormalizedFilament | None:
+    """Normalise a raw Spoolman filament dict for the frontend catalog picker.
+
+    Returns None for entries with missing/zero IDs — those are malformed and
+    must be filtered out before returning to the client.
+    weight=0 is collapsed to None — 0g is not a valid filament weight.
+    """
+    filament_id = _safe_int(raw.get("id"), 0)
+    if filament_id <= 0:
+        logger.warning("Skipping Spoolman filament with missing or invalid id: %r", raw.get("name"))
+        return None
+    vendor = raw.get("vendor") or {}
+    vendor_ref: NormalizedVendorRef | None = None
+    if vendor:
+        vendor_id = _safe_int(vendor.get("id"), 0)
+        if vendor_id <= 0:
+            logger.warning("Spoolman filament %d has vendor without valid id — vendor omitted", filament_id)
+        else:
+            vendor_ref = {"id": vendor_id, "name": str(vendor.get("name") or "").strip() or "Unknown"}
+    return NormalizedFilament(
+        id=filament_id,
+        name=str(raw.get("name") or ""),
+        material=raw.get("material") or None,
+        color_hex=raw.get("color_hex") or None,
+        color_name=raw.get("color_name") or None,
+        weight=_safe_int(raw.get("weight"), 0) or None,  # 0g is not a valid weight
+        spool_weight=_safe_optional_float(raw.get("spool_weight")),
+        vendor=vendor_ref,
+    )
+
+
+@router.get("/filaments")
+async def list_spoolman_filaments(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> list[NormalizedFilament]:
+    """Return all filaments from Spoolman, normalised for the frontend catalog picker."""
+    client = await _get_client(db)
+    async with _translate_spoolman_errors():
+        raw_filaments = await client.get_filaments()
+    if not isinstance(raw_filaments, list):
+        logger.warning("Spoolman get_filaments() returned non-list type: %s", type(raw_filaments).__name__)
+        return []
+    return [f for raw in raw_filaments if (f := _normalize_filament(raw)) is not None]
+
+
+@router.patch("/filaments/{filament_id}")
+async def patch_spoolman_filament(
+    *,
+    filament_id: int = Path(..., gt=0),
+    body: SpoolmanFilamentPatch = Body(...),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> NormalizedFilament:
+    """Update a Spoolman filament's name and/or spool_weight.
+
+    When spool_weight changes, Option A (keep_existing_spools=True) stamps the old
+    weight onto spools currently inheriting it (spool.spool_weight is None) so their
+    tare calculations are unaffected by the filament change.
+    Option B (keep_existing_spools=False, the default): when spool_weight is a
+    concrete value, stamps it onto every affected spool explicitly; when spool_weight
+    is null, clears per-spool overrides so spools fall back to the filament value.
+    """
+    client = await _get_client(db)
+
+    async with _translate_spoolman_errors():
+        current = await client.get_filament(filament_id)
+
+    patch_data = {k: v for k, v in body.model_dump(exclude_unset=True).items() if k != "keep_existing_spools"}
+    if not patch_data:
+        normalized = _normalize_filament(current)
+        if normalized is None:
+            raise HTTPException(status_code=404, detail="Filament not found")
+        return normalized
+
+    async with _translate_spoolman_errors():
+        updated = await client.patch_filament(filament_id, patch_data)
+
+    if "spool_weight" in body.model_fields_set:
+        async with _translate_spoolman_errors():
+            all_spools = await client.get_all_spools()
+        affected_spools = [s for s in all_spools if (s.get("filament") or {}).get("id") == filament_id]
+
+        if affected_spools:
+            if body.keep_existing_spools:
+                old_weight = _safe_optional_float(current.get("spool_weight"))
+                if old_weight is not None:
+                    spools_to_fix = [s for s in affected_spools if s.get("spool_weight") is None]
+                    if spools_to_fix:
+                        async with _translate_spoolman_errors():
+                            results = await asyncio.gather(
+                                *(
+                                    client.update_spool_full(spool_id=s["id"], spool_weight=old_weight)
+                                    for s in spools_to_fix
+                                ),
+                                return_exceptions=True,
+                            )
+                        _raise_if_partial_failure(spools_to_fix, results, "spool_weight stamp (option A)")
+            else:
+                new_weight = body.spool_weight
+                if new_weight is not None:
+                    # Stamp the new weight onto every spool of this filament type so
+                    # each spool carries the value explicitly rather than inheriting.
+                    async with _translate_spoolman_errors():
+                        results = await asyncio.gather(
+                            *(
+                                client.update_spool_full(spool_id=s["id"], spool_weight=new_weight)
+                                for s in affected_spools
+                            ),
+                            return_exceptions=True,
+                        )
+                    _raise_if_partial_failure(affected_spools, results, "spool_weight stamp (option B)")
+                else:
+                    # Filament weight is being cleared — remove any per-spool override
+                    # so spools fall back to whatever the filament now provides.
+                    spools_to_clear = [s for s in affected_spools if s.get("spool_weight") is not None]
+                    if spools_to_clear:
+                        async with _translate_spoolman_errors():
+                            results = await asyncio.gather(
+                                *(
+                                    client.update_spool_full(spool_id=s["id"], clear_spool_weight=True)
+                                    for s in spools_to_clear
+                                ),
+                                return_exceptions=True,
+                            )
+                        _raise_if_partial_failure(spools_to_clear, results, "spool_weight clear (option B null)")
+
+    normalized = _normalize_filament(updated)
+    if normalized is None:
+        raise HTTPException(status_code=502, detail="Spoolman returned malformed filament data")
+    return normalized
+
+
+@router.get("/spools/{spool_id}/k-profiles")
+async def get_spoolman_k_profiles(
+    spool_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> list[dict]:
+    """Return all local K-value calibration profiles for a Spoolman spool."""
+    await _get_client(db)
+    result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
+    profiles = result.scalars().all()
+    return [_k_profile_to_dict(p) for p in profiles]
+
+
+@router.put("/spools/{spool_id}/k-profiles")
+async def save_spoolman_k_profiles(
+    spool_id: int = Path(..., gt=0),
+    profiles: list[SpoolKProfileBase] = Body(...),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> list[dict]:
+    """Replace all K-value calibration profiles for a Spoolman spool."""
+    client = await _get_client(db)
+    async with _translate_spoolman_errors():
+        await client.get_spool(spool_id)
+
+    saved: list[SpoolmanKProfile] = []
+    try:
+        await db.execute(delete(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
+        for profile in profiles:
+            obj = SpoolmanKProfile(
+                spoolman_spool_id=spool_id,
+                printer_id=profile.printer_id,
+                extruder=profile.extruder,
+                nozzle_diameter=profile.nozzle_diameter,
+                nozzle_type=profile.nozzle_type,
+                k_value=profile.k_value,
+                name=profile.name,
+                cali_idx=profile.cali_idx,
+                setting_id=profile.setting_id,
+            )
+            db.add(obj)
+            saved.append(obj)
+        await db.commit()
+    except IntegrityError as exc:
+        await db.rollback()
+        raise HTTPException(422, "Duplicate or invalid K-profile (check printer_id and nozzle uniqueness)") from exc
+    except Exception as exc:
+        await db.rollback()
+        logger.error("K-profile save for spool %d failed: %s", spool_id, exc)
+        raise HTTPException(500, "Failed to save K-profiles") from exc
+
+    for obj in saved:
+        await db.refresh(obj)
+
+    return [_k_profile_to_dict(p) for p in saved]

+ 224 - 25
backend/app/api/routes/updates.py

@@ -54,6 +54,16 @@ def _is_docker_environment() -> bool:
     return False
 
 
+def _is_ha_addon() -> bool:
+    """Detect if running as a Home Assistant Supervisor addon.
+
+    HA Supervisor injects ``SUPERVISOR_TOKEN`` into every addon container;
+    the variable is not set in any other environment, so a single env-var
+    check is sufficient with no false-positive surface.
+    """
+    return bool(os.environ.get("SUPERVISOR_TOKEN"))
+
+
 def _find_executable(name: str) -> str | None:
     """Find an executable in PATH or common locations."""
     # Try standard PATH first
@@ -78,6 +88,78 @@ def _find_executable(name: str) -> str | None:
     return None
 
 
+def _parse_github_remote(url: str) -> tuple[str, str] | None:
+    """Extract `(owner, repo)` from a GitHub remote URL, or None if it isn't a
+    GitHub URL we recognise.
+
+    Handles the four forms `git remote -v` typically prints:
+      - `git@github.com:owner/repo.git`         (SSH, the dev default)
+      - `git@github.com:owner/repo`             (SSH without .git suffix)
+      - `https://github.com/owner/repo.git`     (HTTPS, what _perform_update sets)
+      - `https://github.com/owner/repo`         (HTTPS without .git)
+
+    Anything else (a fork URL, a different host, a malformed value, the empty
+    string from a missing origin) returns None so the caller treats it as
+    "not pointing at our repo" and resets it.
+    """
+    s = url.strip()
+    if not s:
+        return None
+    # SSH form: git@github.com:owner/repo[.git]
+    ssh_prefix = "git@github.com:"
+    https_prefix_a = "https://github.com/"
+    https_prefix_b = "http://github.com/"  # tolerated for legacy
+    if s.startswith(ssh_prefix):
+        path = s[len(ssh_prefix) :]
+    elif s.startswith(https_prefix_a):
+        path = s[len(https_prefix_a) :]
+    elif s.startswith(https_prefix_b):
+        path = s[len(https_prefix_b) :]
+    else:
+        return None
+    if path.endswith(".git"):
+        path = path[:-4]
+    parts = path.strip("/").split("/")
+    if len(parts) != 2 or not parts[0] or not parts[1]:
+        return None
+    return (parts[0], parts[1])
+
+
+async def _origin_points_at_repo(git_path: str, git_config: list[str], base_dir, expected_repo: str) -> bool:
+    """Return True iff the working tree's `origin` already resolves to
+    `<owner>/<repo>` matching `expected_repo` (e.g. "maziggy/bambuddy"),
+    regardless of whether it's the SSH or HTTPS form. Used to skip the
+    `git remote set-url origin https://...` rewrite when the developer's
+    SSH origin is already correct — see `_perform_update` for context."""
+    try:
+        process = await asyncio.create_subprocess_exec(
+            git_path,
+            *git_config,
+            "remote",
+            "get-url",
+            "origin",
+            cwd=str(base_dir),
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, _ = await process.communicate()
+    except (OSError, asyncio.CancelledError):
+        # Fail closed: let the caller go through the rewrite branch if we
+        # can't even invoke git. The unconditional set-url is the safer
+        # fallback, only mildly destructive.
+        return False
+    if process.returncode != 0:
+        # Most likely cause: no `origin` defined yet (fresh clone-style
+        # checkout). Caller will set it.
+        return False
+    parsed = _parse_github_remote(stdout.decode().strip())
+    if parsed is None:
+        return False
+    owner, repo = parsed
+    expected_owner, expected_repo_name = expected_repo.split("/", 1)
+    return owner == expected_owner and repo == expected_repo_name
+
+
 def parse_version(version: str) -> tuple:
     """Parse version string into tuple for comparison.
 
@@ -283,6 +365,13 @@ async def check_for_updates(
             }
 
             is_docker = _is_docker_environment()
+            is_ha_addon = _is_ha_addon()
+            if is_ha_addon:
+                update_method = "ha_addon"
+            elif is_docker:
+                update_method = "docker"
+            else:
+                update_method = "git"
             return {
                 "update_available": update_available,
                 "current_version": APP_VERSION,
@@ -292,7 +381,8 @@ async def check_for_updates(
                 "release_url": release_url,
                 "published_at": published_at,
                 "is_docker": is_docker,
-                "update_method": "docker" if is_docker else "git",
+                "is_ha_addon": is_ha_addon,
+                "update_method": update_method,
             }
 
     except httpx.HTTPError as e:
@@ -311,8 +401,61 @@ async def check_for_updates(
         }
 
 
-async def _perform_update():
-    """Perform the actual update using git fetch and reset."""
+async def _discover_target_release(db: AsyncSession) -> str | None:
+    """Look up the tag we should install from GitHub releases.
+
+    Same selection logic the GUI's update-check uses: respect
+    `include_beta_updates`, skip prereleases when the user opted out, take
+    the first matching release. Returns the raw tag name (e.g. `v0.2.4b1`)
+    so the git ref is unambiguous, or None if there's no release to install.
+
+    The previous in-app updater path was hardcoded to `git fetch origin main
+    && git reset --hard origin/main`, which silently no-ops whenever main
+    isn't where the latest release lives — e.g. during a beta release cycle
+    where the next stable hasn't been merged to main yet. Anchoring to the
+    release tag instead lets the GUI install whatever GitHub says is latest.
+    """
+    result = await db.execute(select(Settings).where(Settings.key == "include_beta_updates"))
+    beta_setting = result.scalar_one_or_none()
+    include_beta = beta_setting and beta_setting.value.lower() == "true"
+
+    try:
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
+                headers={"Accept": "application/vnd.github.v3+json"},
+                timeout=10.0,
+            )
+            response.raise_for_status()
+            releases = response.json()
+    except (httpx.HTTPError, ValueError) as exc:
+        logger.error("Could not fetch GitHub releases for update target: %s", exc)
+        return None
+
+    for release in releases:
+        tag = release.get("tag_name", "")
+        if not tag:
+            continue
+        if include_beta:
+            return tag
+        # Skip prereleases (parsed from version, not GitHub flag — GitHub's
+        # is_prerelease flag isn't always set on dailies).
+        parsed = parse_version(tag)
+        if parsed[4] == 0:
+            return tag
+    return None
+
+
+async def _perform_update(target_ref: str):
+    """Perform the actual update using git fetch and reset.
+
+    `target_ref` is whatever git ref the caller wants to land on — typically
+    a release tag like `v0.2.4b1` resolved by `_discover_target_release`,
+    but accepts any ref `git reset --hard` understands (`origin/main`, a
+    branch, a sha). Tag-based refs are the production path because they pin
+    the install to a specific release artifact instead of whatever happens
+    to be on a moving branch.
+    """
     global _update_status
 
     try:
@@ -341,20 +484,32 @@ async def _perform_update():
             "error": None,
         }
 
-        # Ensure remote uses HTTPS (SSH may not be available)
+        # Ensure remote points at the expected repo. We previously rewrote
+        # origin to HTTPS unconditionally on the assumption that systemd
+        # service users wouldn't have SSH keys configured — which is fine
+        # for that case, but stomps on developer checkouts where origin is
+        # legitimately `git@github.com:maziggy/bambuddy.git` and the user
+        # auths via SSH keys. After the rewrite, `git push` prompts for
+        # HTTPS credentials and fails.
+        # New behaviour: read the current origin, parse out the
+        # `<owner>/<repo>` pair, and only rewrite if it doesn't already
+        # resolve to the right GitHub repo. SSH origins pointing at the
+        # correct repo are preserved; only missing / wrong / corrupted
+        # origins get reset to HTTPS.
         https_url = f"https://github.com/{GITHUB_REPO}.git"
-        process = await asyncio.create_subprocess_exec(
-            git_path,
-            *git_config,
-            "remote",
-            "set-url",
-            "origin",
-            https_url,
-            cwd=str(base_dir),
-            stdout=asyncio.subprocess.PIPE,
-            stderr=asyncio.subprocess.PIPE,
-        )
-        await process.communicate()
+        if not await _origin_points_at_repo(git_path, git_config, base_dir, GITHUB_REPO):
+            process = await asyncio.create_subprocess_exec(
+                git_path,
+                *git_config,
+                "remote",
+                "set-url",
+                "origin",
+                https_url,
+                cwd=str(base_dir),
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+            )
+            await process.communicate()
 
         _update_status = {
             "status": "downloading",
@@ -363,13 +518,18 @@ async def _perform_update():
             "error": None,
         }
 
-        # Fetch from origin
+        # Fetch branches AND tags from origin so any ref the caller passes
+        # (release tag like `v0.2.4b1`, a branch like `main`, or a sha) is
+        # locally resolvable for the reset below. `--tags` is required —
+        # plain `git fetch origin` doesn't bring tags by default, so a
+        # release tag would not be resolvable.
         process = await asyncio.create_subprocess_exec(
             git_path,
             *git_config,
             "fetch",
+            "--prune",
+            "--tags",
             "origin",
-            "main",
             cwd=str(base_dir),
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
@@ -394,13 +554,18 @@ async def _perform_update():
             "error": None,
         }
 
-        # Hard reset to origin/main (clean update, no merge conflicts)
+        # Hard reset to the target ref (clean update, no merge conflicts).
+        # `target_ref` is typically a release tag like `v0.2.4b1` resolved
+        # from the GitHub releases API by `_discover_target_release`. The
+        # local branch name doesn't change — only HEAD moves. Falling back
+        # to `origin/main` here was the source of the "in-app updater can't
+        # reach beta releases" bug.
         process = await asyncio.create_subprocess_exec(
             git_path,
             *git_config,
             "reset",
             "--hard",
-            "origin/main",
+            target_ref,
             cwd=str(base_dir),
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
@@ -425,7 +590,13 @@ async def _perform_update():
             "error": None,
         }
 
-        # Install Python dependencies
+        # Install Python dependencies — must run from the source-code directory
+        # (where requirements.txt lives), not the data dir. On native installs
+        # systemd sets DATA_DIR=INSTALL_PATH/data, so `base_dir` is the data dir,
+        # not the working tree. `git reset` above worked from base_dir because
+        # git walks up looking for .git, but `pip install -r requirements.txt`
+        # needs the file in cwd literally.
+        app_dir = settings.app_dir
         process = await asyncio.create_subprocess_exec(
             sys.executable,
             "-m",
@@ -434,7 +605,7 @@ async def _perform_update():
             "-r",
             "requirements.txt",
             "-q",
-            cwd=str(base_dir),
+            cwd=str(app_dir),
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
@@ -445,7 +616,7 @@ async def _perform_update():
 
         # Try to build frontend if npm is available (optional - static files are pre-built)
         npm_path = _find_executable("npm")
-        frontend_dir = base_dir / "frontend"
+        frontend_dir = app_dir / "frontend"
 
         if npm_path and frontend_dir.exists():
             _update_status = {
@@ -503,6 +674,7 @@ async def _perform_update():
 @router.post("/apply")
 async def apply_update(
     background_tasks: BackgroundTasks,
+    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Apply available update (git pull + rebuild)."""
@@ -515,7 +687,20 @@ async def apply_update(
             "status": _update_status,
         }
 
-    # Check if running in Docker
+    # Check for managed deployment shapes that own the update lifecycle.
+    # HA addons are also Docker, so check HA first to surface the more
+    # specific message.
+    if _is_ha_addon():
+        return {
+            "success": False,
+            "is_ha_addon": True,
+            "is_docker": True,
+            "message": (
+                "Bambuddy is running as a Home Assistant addon. "
+                "Updates are managed by the Home Assistant Supervisor "
+                "(Settings → Add-ons → Bambuddy → Update)."
+            ),
+        }
     if _is_docker_environment():
         return {
             "success": False,
@@ -527,8 +712,22 @@ async def apply_update(
             ),
         }
 
+    # Discover which release tag to install. Resolved here (where we have
+    # a DB session) and passed into the background task; the BG task can't
+    # reuse this request's session since FastAPI closes it on response.
+    target_ref = await _discover_target_release(db)
+    if target_ref is None:
+        return {
+            "success": False,
+            "message": (
+                "Could not determine a release to install. Either GitHub is "
+                "unreachable or no release matches your update channel "
+                "(check the include_beta_updates setting)."
+            ),
+        }
+
     # Start update in background
-    background_tasks.add_task(_perform_update)
+    background_tasks.add_task(_perform_update, target_ref)
 
     _update_status = {
         "status": "downloading",

+ 18 - 1
backend/app/api/routes/users.py

@@ -21,6 +21,7 @@ from backend.app.core.auth import (
 )
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.group import Group
 from backend.app.models.library import LibraryFile
@@ -325,7 +326,12 @@ async def get_user_items_count(
     queue_items_count = queue_result.scalar() or 0
 
     # Count library files
-    library_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.created_by_id == user_id))
+    library_result = await db.execute(
+        select(func.count(LibraryFile.id)).where(
+            LibraryFile.created_by_id == user_id,
+            LibraryFile.deleted_at.is_(None),
+        )
+    )
     library_files_count = library_result.scalar() or 0
 
     return {
@@ -400,6 +406,17 @@ async def delete_user(
         )
         await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))
 
+    # Drop API keys owned by this user. The model declares ON DELETE CASCADE
+    # so Postgres handles this automatically, but SQLite ships with FK
+    # enforcement off (the project's existing pattern — same reason the
+    # blocks above set created_by_id = NULL by hand). Without an explicit
+    # DELETE here, deleting a user on SQLite would leave their API keys
+    # with a dangling user_id and ``_user_from_api_key`` would return None,
+    # silently degrading the keys to anonymous (and locking them out of
+    # /cloud/* — but the rest of the API would still accept them, which is
+    # exactly the orphan-key state the CASCADE was meant to prevent).
+    await db.execute(delete(APIKey).where(APIKey.user_id == user_id))
+
     await db.delete(user)
     await db.commit()
 

+ 43 - 0
backend/app/api/routes/virtual_printers.py

@@ -11,11 +11,24 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 
+# Imported at module scope so tests can patch
+# backend.app.api.routes.virtual_printers.tailscale_service.
+from backend.app.services.virtual_printer.tailscale import tailscale_service
+
 logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/virtual-printers", tags=["virtual-printers"])
 
 
+class TailscaleStatusResponse(BaseModel):
+    available: bool
+    fqdn: str
+    hostname: str
+    tailnet_name: str
+    tailscale_ips: list[str]
+    error: str | None
+
+
 class VirtualPrinterCreate(BaseModel):
     name: str = "Bambuddy"
     enabled: bool = False
@@ -24,6 +37,7 @@ class VirtualPrinterCreate(BaseModel):
     access_code: str | None = None
     target_printer_id: int | None = None
     auto_dispatch: bool = True
+    queue_force_color_match: bool = False
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
 
@@ -36,8 +50,10 @@ class VirtualPrinterUpdate(BaseModel):
     access_code: str | None = None
     target_printer_id: int | None = None
     auto_dispatch: bool | None = None
+    queue_force_color_match: bool | None = None
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
+    tailscale_disabled: bool | None = None
 
 
 def _resolve_printer_model(printer_model: str | None) -> str | None:
@@ -76,8 +92,10 @@ def _vp_to_dict(vp, status: dict | None = None) -> dict:
         "serial": serial,
         "target_printer_id": vp.target_printer_id,
         "auto_dispatch": vp.auto_dispatch,
+        "queue_force_color_match": vp.queue_force_color_match,
         "bind_ip": vp.bind_ip,
         "remote_interface_ip": vp.remote_interface_ip,
+        "tailscale_disabled": vp.tailscale_disabled,
         "position": vp.position,
         "status": status or {"running": False, "pending_files": 0},
     }
@@ -194,6 +212,7 @@ async def create_virtual_printer(
         access_code=body.access_code,
         target_printer_id=body.target_printer_id,
         auto_dispatch=body.auto_dispatch,
+        queue_force_color_match=body.queue_force_color_match,
         bind_ip=body.bind_ip,
         remote_interface_ip=body.remote_interface_ip,
         serial_suffix=new_suffix,
@@ -215,6 +234,26 @@ async def create_virtual_printer(
     return _vp_to_dict(vp)
 
 
+@router.get("/tailscale-status", response_model=TailscaleStatusResponse)
+async def get_tailscale_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+) -> TailscaleStatusResponse:
+    """Return current Tailscale availability and machine identity.
+
+    Used by the frontend to indicate whether virtual printer TLS is backed
+    by a trusted Let's Encrypt certificate or a self-signed CA.
+    """
+    status = await tailscale_service.get_status()
+    return TailscaleStatusResponse(
+        available=status.available,
+        fqdn=status.fqdn,
+        hostname=status.hostname,
+        tailnet_name=status.tailnet_name,
+        tailscale_ips=status.tailscale_ips,
+        error=status.error,
+    )
+
+
 @router.get("/{vp_id}")
 async def get_virtual_printer(
     vp_id: int,
@@ -296,10 +335,14 @@ async def update_virtual_printer(
             vp.model = _resolve_printer_model(target_printer.model) or target_printer.model
     if body.auto_dispatch is not None:
         vp.auto_dispatch = body.auto_dispatch
+    if body.queue_force_color_match is not None:
+        vp.queue_force_color_match = body.queue_force_color_match
     if body.bind_ip is not None:
         vp.bind_ip = body.bind_ip
     if body.remote_interface_ip is not None:
         vp.remote_interface_ip = body.remote_interface_ip
+    if body.tailscale_disabled is not None:
+        vp.tailscale_disabled = body.tailscale_disabled
 
     # Auto-inherit model when switching to proxy mode with existing target printer
     if body.mode == "proxy" and body.model is None and body.target_printer_id is None and vp.target_printer_id:

+ 73 - 0
backend/app/core/asyncio_handlers.py

@@ -0,0 +1,73 @@
+"""Asyncio event-loop exception handlers used at app startup.
+
+Currently houses a single Windows-specific filter for the noisy
+``_ProactorBasePipeTransport._call_connection_lost`` ``WinError 10054``
+that fires every time a printer / MQTT broker / camera RSTs a TCP socket
+instead of closing it cleanly. See ``install_proactor_reset_filter`` for
+the why and the failure mode it suppresses.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import sys
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+def _is_proactor_connection_reset(context: dict[str, Any]) -> bool:
+    """True if `context` describes the Windows Proactor cleanup-RST noise.
+
+    asyncio's default exception handler is invoked in two distinct cases
+    we care about — generic uncaught task exceptions, and the specific
+    `_call_connection_lost` cleanup path — and we only want to suppress
+    the latter. Match on three signals together so a real
+    `ConnectionResetError` raised inside an application task still
+    surfaces normally:
+
+      1. The exception is `ConnectionResetError` (or a subclass).
+      2. asyncio's own message string mentions `_call_connection_lost`
+         (the Proactor-cleanup callback is the only place Python emits
+         this exact phrase).
+      3. We're actually on Windows, where the Proactor is in use.
+    """
+    if sys.platform != "win32":
+        return False
+    exc = context.get("exception")
+    if not isinstance(exc, ConnectionResetError):
+        return False
+    message = context.get("message", "")
+    return "_call_connection_lost" in message
+
+
+def _proactor_reset_filter(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
+    """Custom event-loop exception handler.
+
+    Handles the Proactor-cleanup `ConnectionResetError` by logging it at
+    DEBUG instead of ERROR, and delegates everything else to asyncio's
+    default handler so unrelated bugs are still visible.
+    """
+    if _is_proactor_connection_reset(context):
+        logger.debug(
+            "asyncio Proactor: peer reset socket during cleanup (WinError 10054); "
+            "ignored — application-layer reconnect handles the disconnect"
+        )
+        return
+    loop.default_exception_handler(context)
+
+
+def install_proactor_reset_filter(loop: asyncio.AbstractEventLoop | None = None) -> bool:
+    """Install the filter on `loop` (or the running loop if omitted).
+
+    Returns True when the filter was installed (Windows only), False on
+    every other platform — so callers can branch on the return value if
+    they want to log the install / skip.
+    """
+    if sys.platform != "win32":
+        return False
+    if loop is None:
+        loop = asyncio.get_running_loop()
+    loop.set_exception_handler(_proactor_reset_filter)
+    return True

+ 195 - 14
backend/app/core/auth.py

@@ -4,7 +4,6 @@ import logging
 import os
 import secrets
 from datetime import datetime, timedelta, timezone
-from pathlib import Path
 from typing import Annotated
 
 import jwt
@@ -25,6 +24,42 @@ from backend.app.models.user import User
 
 logger = logging.getLogger(__name__)
 
+# SETTINGS_READ is intentionally not denied — the SpoolBuddy kiosk reads settings
+# via API key (e.g. to sync the UI language).
+_APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
+    {
+        Permission.SETTINGS_UPDATE,
+        Permission.SETTINGS_BACKUP,
+        Permission.SETTINGS_RESTORE,
+        Permission.USERS_READ,
+        Permission.USERS_CREATE,
+        Permission.USERS_UPDATE,
+        Permission.USERS_DELETE,
+        Permission.GROUPS_READ,
+        Permission.GROUPS_CREATE,
+        Permission.GROUPS_UPDATE,
+        Permission.GROUPS_DELETE,
+        Permission.API_KEYS_CREATE,
+        Permission.API_KEYS_UPDATE,
+        Permission.API_KEYS_DELETE,
+        Permission.API_KEYS_READ,
+        Permission.GITHUB_BACKUP,
+        Permission.GITHUB_RESTORE,
+        Permission.FIRMWARE_UPDATE,
+    }
+)
+
+
+def _check_apikey_permissions(perm_strings: list[str]) -> None:
+    """Raise 403 if any required permission is admin-only (not accessible via API key)."""
+    denied = _APIKEY_DENIED_PERMISSIONS.intersection(perm_strings)
+    if denied:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="API keys cannot be used for administrative operations",
+        )
+
+
 # Password hashing
 # Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues
 # pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations
@@ -49,13 +84,9 @@ def _get_jwt_secret() -> str:
         return env_secret
 
     # 2. Check for secret file in data directory
-    # Use DATA_DIR env var (same as rest of app), fallback to data/ subdirectory
-    data_dir_env = os.environ.get("DATA_DIR")
-    if data_dir_env:
-        data_dir = Path(data_dir_env)
-    else:
-        # Fallback to data/ subdirectory under project root (not project root itself!)
-        data_dir = Path(__file__).parent.parent.parent.parent / "data"
+    from backend.app.core.paths import resolve_data_dir
+
+    data_dir = resolve_data_dir()
     secret_file = data_dir / ".jwt_secret"
 
     if secret_file.exists():
@@ -195,7 +226,12 @@ async def create_camera_stream_token() -> str:
 
 
 async def verify_camera_stream_token(token: str) -> bool:
-    """Verify a camera stream token is valid (reusable — does not consume it)."""
+    """Verify a camera stream token is valid (reusable — does not consume it).
+
+    Tries the ephemeral 60-minute token first (the common, browser-bound case)
+    and falls through to long-lived tokens (#1108) for HA / kiosk integrations
+    that paste a token once and expect it to keep working for days.
+    """
     now = datetime.now(timezone.utc)
     async with async_session() as db:
         result = await db.execute(
@@ -205,7 +241,15 @@ async def verify_camera_stream_token(token: str) -> bool:
                 AuthEphemeralToken.expires_at > now,
             )
         )
-        return result.scalar_one_or_none() is not None
+        if result.scalar_one_or_none() is not None:
+            return True
+
+        # Long-lived path. Imported lazily so the auth module stays importable
+        # at startup before the long_lived_tokens model is registered.
+        from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
+
+        record = await verify_long_lived(db, token, scope="camera_stream")
+        return record is not None
 
 
 def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -357,6 +401,28 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
         return False
 
 
+async def _user_from_api_key(db: AsyncSession, api_key: APIKey) -> User | None:
+    """Resolve the owner of a validated API key, or None for legacy ownerless keys.
+
+    Cloud routes (and any route that needs caller identity) read the returned
+    User to look up per-user state like ``cloud_token``. Legacy keys created
+    before #1182 have ``user_id IS NULL`` and stay anonymous — they keep working
+    against non-cloud routes for backward compatibility, but cloud routes will
+    surface a "recreate this key" error rather than 200 with empty results.
+    """
+    if api_key.user_id is None:
+        return None
+    result = await db.execute(select(User).where(User.id == api_key.user_id))
+    user = result.scalar_one_or_none()
+    if user is None or not user.is_active:
+        # CASCADE on user delete should prevent a dangling user_id, but if
+        # someone manually deactivates the owner the key shouldn't suddenly
+        # gain an "anonymous" identity — drop the request to None so cloud
+        # access fails closed.
+        return None
+    return user
+
+
 async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
     """Validate an API key and return the APIKey object if valid, None otherwise.
 
@@ -487,7 +553,13 @@ async def require_auth_if_enabled(
     """Require authentication if auth is enabled, otherwise return None.
 
     Accepts both JWT tokens (via Authorization: Bearer header) and API keys
-    (via X-API-Key header or Authorization: Bearer bb_xxx).
+    (via X-API-Key header or Authorization: Bearer bb_xxx). API keys return
+    None for backward compatibility — routes that need the API-key owner (i.e.
+    cloud routes for #1182) resolve it via their own router-level dependency
+    that stashes ``request.state.api_key_owner``. Returning the owner here
+    instead would silently grant API-keyed callers access to every route that
+    fences via ``if current_user is None``, which is a wider surface than
+    #1182 was designed to expose.
     """
     async with async_session() as db:
         auth_enabled = await is_auth_enabled(db)
@@ -631,8 +703,7 @@ async def get_api_key(
             detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer <key>'",
         )
 
-    # M-NEW-2: Pre-filter by key_prefix (first 8 chars) to avoid O(n) pbkdf2 over all
-    # enabled keys — same fix as in _validate_api_key (L-1 from previous review).
+    # Pre-filter by key_prefix to avoid O(n) pbkdf2 hashes across all enabled keys.
     key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value
     result = await db.execute(
         select(APIKey).where(
@@ -669,6 +740,16 @@ async def get_api_key(
     )
 
 
+async def caller_is_api_key(
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+) -> bool:
+    """Return True when the request is authenticated via API key (X-API-Key or Bearer bb_xxx)."""
+    if x_api_key:
+        return True
+    return credentials is not None and credentials.credentials.startswith("bb_")
+
+
 def check_permission(api_key: APIKey, permission: str) -> None:
     """Check if API key has the required permission.
 
@@ -756,6 +837,7 @@ def require_permission(*permissions: str | Permission):
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
+                    _check_apikey_permissions(perm_strings)
                     return None  # API key valid, allow access
 
             credentials_exception = HTTPException(
@@ -772,6 +854,7 @@ def require_permission(*permissions: str | Permission):
             if token.startswith("bb_"):
                 api_key = await _validate_api_key(db, token)
                 if api_key:
+                    _check_apikey_permissions(perm_strings)
                     return None  # API key valid, allow access
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
@@ -833,10 +916,18 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
             if not auth_enabled:
                 return None  # Auth disabled, allow access
 
-            # Check for API key first (X-API-Key header)
+            # Check for API key first (X-API-Key header). API-keyed requests
+            # bypass the JWT permission check entirely — their scopes live on
+            # the APIKey row (can_queue / can_control_printer / can_read_status
+            # / can_access_cloud / printer_ids), and the dep returns None so
+            # routes don't gain a synthetic User identity that would grant
+            # access to fenced surfaces like long-lived-token management.
+            # Cloud routes (#1182) resolve the API-key owner separately via
+            # their own router-level dependency; see ``cloud.py``.
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
+                    _check_apikey_permissions(perm_strings)
                     return None  # API key valid, allow access
 
             # Check for Bearer token (could be JWT or API key)
@@ -846,6 +937,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     if api_key:
+                        _check_apikey_permissions(perm_strings)
                         return None  # API key valid, allow access
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -919,6 +1011,95 @@ def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
     return Depends(require_permission_if_auth_enabled(*permissions))
 
 
+def require_any_permission_if_auth_enabled(*permissions: str | Permission):
+    """Dependency factory that requires AT LEAST ONE of the given permissions when auth is enabled."""
+    perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
+
+    async def checker(
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> User | None:
+        async with async_session() as db:
+            auth_enabled = await is_auth_enabled(db)
+            if not auth_enabled:
+                return None
+
+            if x_api_key:
+                api_key = await _validate_api_key(db, x_api_key)
+                if api_key:
+                    return None
+
+            if credentials is not None:
+                token = credentials.credentials
+                if token.startswith("bb_"):
+                    api_key = await _validate_api_key(db, token)
+                    if api_key:
+                        return None
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Invalid API key",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+
+                try:
+                    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+                    username: str = payload.get("sub")
+                    if username is None:
+                        raise HTTPException(
+                            status_code=status.HTTP_401_UNAUTHORIZED,
+                            detail="Could not validate credentials",
+                            headers={"WWW-Authenticate": "Bearer"},
+                        )
+                    jti: str | None = payload.get("jti")
+                    if not jti or await is_jti_revoked(jti):
+                        raise HTTPException(
+                            status_code=status.HTTP_401_UNAUTHORIZED,
+                            detail="Could not validate credentials",
+                            headers={"WWW-Authenticate": "Bearer"},
+                        )
+                    iat: int | float | None = payload.get("iat")
+                except JWTError:
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+
+                user = await get_user_by_username(db, username)
+                if user is None or not user.is_active:
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+                if not _is_token_fresh(iat, user):
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+
+                if not user.has_any_permission(*perm_strings):
+                    raise HTTPException(
+                        status_code=status.HTTP_403_FORBIDDEN,
+                        detail=f"Missing required permissions: {', '.join(perm_strings)}",
+                    )
+                return user
+
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Authentication required",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+
+    return checker
+
+
+def RequireAnyPermissionIfAuthEnabled(*permissions: str | Permission):
+    """Convenience dependency that requires AT LEAST ONE of the given permissions when auth is enabled."""
+    return Depends(require_any_permission_if_auth_enabled(*permissions))
+
+
 def require_camera_stream_token_if_auth_enabled():
     """Dependency that validates a camera stream token query param when auth is enabled.
 

+ 46 - 1
backend/app/core/config.py

@@ -1,11 +1,12 @@
 import logging
 import os
+import re as _re
 from pathlib import Path
 
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.2.3.2"
+APP_VERSION = "0.2.4"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 
@@ -60,6 +61,12 @@ class Settings(BaseSettings):
 
     # Paths
     base_dir: Path = _data_dir  # For backwards compatibility
+    # `app_dir` is where the source code is checked out — distinct from `base_dir`
+    # on native installs where DATA_DIR is set to a sibling like INSTALL_PATH/data.
+    # Use this when you need the working tree (requirements.txt, frontend/, etc.)
+    # rather than the data dir. On Docker / local dev where DATA_DIR is unset,
+    # app_dir == base_dir.
+    app_dir: Path = _app_dir
     archive_dir: Path = _data_dir / "archive"
     plate_calibration_dir: Path = _plate_cal_dir  # Plate detection references
     static_dir: Path = _app_dir / "static"  # Static files are part of app, not data
@@ -73,13 +80,51 @@ class Settings(BaseSettings):
     # API
     api_prefix: str = "/api/v1"
 
+    # Slicer API sidecars. Defaults match the docker-compose.yml ports in the
+    # orca-slicer-api fork (https://github.com/maziggy/orca-slicer-api):
+    #   OrcaSlicer  → port 3003 (default profile)
+    #   BambuStudio → port 3001 (built locally via Dockerfile.bambu-studio)
+    # The slice route picks which one based on the user's preferred_slicer
+    # setting.
+    slicer_api_url: str = "http://localhost:3003"
+    bambu_studio_api_url: str = "http://localhost:3001"
+
     class Config:
         env_file = ".env"
         env_file_encoding = "utf-8"
+        # Don't reject unknown env vars — MFA_ENCRYPTION_KEY (#1219) and other
+        # operational env vars are read directly by their owning modules and
+        # never declared as Settings fields.
+        extra = "ignore"
 
 
 settings = Settings()
 
+# S6: Warn on unknown MFA_*/BAMBUDDY_* env vars so typos like MFA_ENCYPTION_KEY
+# are not silently swallowed by ``extra = "ignore"``. The original Pydantic
+# behaviour rejected them outright and broke startup (#1219); we now accept
+# them but log every unrecognised one at INFO so operators can spot mistakes.
+_INTENTIONAL_UNSETTINGS = {
+    "MFA_ENCRYPTION_KEY",  # encryption.py reads this directly
+    "DATA_DIR",  # paths.py / config.py
+    "DATABASE_URL",  # config.py (above)
+    "LOG_DIR",  # config.py (above)
+    "LOG_LEVEL",  # main.py logging setup
+    "BUG_REPORT_RELAY_URL",  # config.py (above)
+}
+
+_known_settings_fields = {f.upper() for f in settings.model_fields}
+
+for _env_key in os.environ:
+    if _re.match(r"^(MFA_|BAMBUDDY_)", _env_key, _re.IGNORECASE):
+        _norm = _env_key.upper()
+        if _norm not in _known_settings_fields and _norm not in _INTENTIONAL_UNSETTINGS:
+            logging.info(
+                "Unknown env var %r — not a declared Settings field. Possible typo? Recognised operational vars: %s",
+                _env_key,
+                sorted(_INTENTIONAL_UNSETTINGS),
+            )
+
 # Ensure directories exist
 settings.archive_dir.mkdir(parents=True, exist_ok=True)
 settings.plate_calibration_dir.mkdir(parents=True, exist_ok=True)

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 852 - 52
backend/app/core/database.py


+ 179 - 29
backend/app/core/encryption.py

@@ -1,52 +1,201 @@
 """At-rest encryption for high-value secrets (TOTP keys, OIDC client_secret).
 
-Set the ``MFA_ENCRYPTION_KEY`` environment variable to a URL-safe base64-encoded
-32-byte key (generate with ``python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"``)
-to enable Fernet symmetric encryption.
-
-When the key is not set, values are stored as plaintext and a warning is emitted.
-Existing plaintext values are read back correctly even after the key is added
-(values without the ``fernet:`` prefix are treated as legacy plaintext).
+The encryption key is resolved on first use in this priority order:
+
+1. ``MFA_ENCRYPTION_KEY`` environment variable (must be a URL-safe base64
+   string that decodes to exactly 32 bytes — the Fernet key format).
+2. ``DATA_DIR/.mfa_encryption_key`` file (read if present and valid). A
+   corrupted or unreadable file falls back to plaintext (step 4) without
+   overwriting — to protect previously encrypted rows.
+3. Auto-generate a new Fernet key, write to ``DATA_DIR/.mfa_encryption_key``
+   with mode ``0o600`` (only when neither env var nor key file exists).
+   Falls back to plaintext (step 4) on OSError.
+4. ``None`` (legacy plaintext fallback) — unreadable or corrupted key file,
+   or read-only filesystem.
+
+Existing plaintext values are read back correctly even after a key is
+configured — values without the ``fernet:`` prefix are returned as-is. This
+keeps the auto-bootstrap non-breaking for installs that already wrote
+plaintext rows before the key existed.
 """
 
 from __future__ import annotations
 
+import base64
+import binascii
 import logging
 import os
+from typing import Literal
 
 logger = logging.getLogger(__name__)
 
 _FERNET_PREFIX = "fernet:"
 _fernet_instance = None
 _warn_shown = False
+# Public source values exposed via get_key_source(). Internal failure causes
+# (none_write_failed, none_corrupted) are mapped to "none" before exposure
+# so the public API stays stable for the EncryptionStatusResponse schema.
+_PublicSource = Literal["env", "file", "generated", "none"]
+# Internal source carries the specific failure cause for accurate logging.
+# "none" remains valid for legacy test stubs (lambda: (None, "none")).
+_InternalSource = Literal[
+    "env",
+    "file",
+    "generated",
+    "none",
+    "none_write_failed",
+    "none_corrupted",
+]
+_key_source: _PublicSource | None = None
+
+_KEY_FILE_NAME = ".mfa_encryption_key"
+
+
+def _validate_fernet_key(key: str) -> bool:
+    try:
+        decoded = base64.urlsafe_b64decode(key.encode())
+    except (binascii.Error, ValueError):
+        return False
+    return len(decoded) == 32
+
+
+def _load_or_generate_key() -> tuple[str | None, _InternalSource]:
+    # Lazy import: keeps cryptography out of import-time even when the helper
+    # is patched in tests that never invoke encryption.
+    from cryptography.fernet import Fernet
+
+    from backend.app.core.paths import resolve_data_dir
+
+    # 1. Environment variable
+    env_key = os.environ.get("MFA_ENCRYPTION_KEY")
+    if env_key:
+        if _validate_fernet_key(env_key):
+            return env_key, "env"
+        logger.error(
+            "MFA_ENCRYPTION_KEY is set but is not a valid Fernet key "
+            "(must decode to exactly 32 bytes). Falling back to file-based key."
+        )
 
+    data_dir = resolve_data_dir()
+    key_file = data_dir / _KEY_FILE_NAME
+
+    # 2. Existing file in DATA_DIR
+    if key_file.exists():
+        try:
+            file_key = key_file.read_text().strip()
+        except OSError as exc:
+            # Refusing to fall through to regeneration — overwriting the file
+            # would destroy access to every row already encrypted under the
+            # current key. Operator must fix permissions or pin the key
+            # explicitly via MFA_ENCRYPTION_KEY.
+            logger.error(
+                "Failed to read existing MFA key file %s (%s). "
+                "Refusing to regenerate — this would destroy all previously encrypted secrets. "
+                "Fix the file permissions or set MFA_ENCRYPTION_KEY explicitly.",
+                key_file,
+                exc,
+            )
+            return None, "none_corrupted"
+        if _validate_fernet_key(file_key):
+            return file_key, "file"
+        logger.error(
+            "%s is present but is not a valid Fernet key. "
+            "Refusing to overwrite — fix the file or set MFA_ENCRYPTION_KEY. "
+            "Falling back to plaintext storage.",
+            key_file,
+        )
+        return None, "none_corrupted"
 
-def _get_fernet():
-    global _fernet_instance, _warn_shown
+    # 3. Generate a new key and persist it.
+    # S1: Use os.open(O_WRONLY|O_CREAT|O_EXCL, 0o600) to avoid the TOCTOU
+    # window between write_text() (umask-respecting) and chmod() — the key
+    # is created with 0o600 from the start, never world-readable.
+    new_key = Fernet.generate_key().decode()
+    try:
+        data_dir.mkdir(parents=True, exist_ok=True)
+        fd = os.open(str(key_file), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+        try:
+            os.write(fd, new_key.encode())
+        finally:
+            os.close(fd)
+        # S9: Some filesystems (Windows, SMB, FUSE without uid mapping) silently
+        # ignore mode bits — verify and warn so operators know the key is not
+        # protected at the FS level.
+        actual_mode = key_file.stat().st_mode & 0o777
+        if actual_mode != 0o600:
+            logger.warning(
+                "MFA key file %s: filesystem did not enforce 0o600 (actual: 0o%o). "
+                "Key may be world-readable on Windows / SMB / FUSE mounts.",
+                key_file,
+                actual_mode,
+            )
+        logger.info("Generated new MFA encryption key and saved to %s", key_file)
+        return new_key, "generated"
+    except FileExistsError:
+        # Race between key_file.exists() check above and O_EXCL — another
+        # process created the file. Treat as corrupted (do NOT regenerate).
+        logger.error(
+            "Race detected creating %s (file appeared between check and create). "
+            "Refusing to overwrite — set MFA_ENCRYPTION_KEY explicitly to recover.",
+            key_file,
+        )
+        return None, "none_corrupted"
+    except OSError as exc:
+        logger.error(
+            "Could not save MFA encryption key to %s (%s). "
+            "Falling back to plaintext storage. Set MFA_ENCRYPTION_KEY in the "
+            "environment or fix the data-dir permissions to enable encryption.",
+            key_file,
+            exc,
+        )
+        return None, "none_write_failed"
 
-    if _fernet_instance is not None:
-        return _fernet_instance
 
-    key = os.environ.get("MFA_ENCRYPTION_KEY")
-    if key:
-        from cryptography.fernet import Fernet
+def get_key_source() -> _PublicSource | None:
+    return _key_source
+
+
+def is_encryption_active() -> bool:
+    return _get_fernet() is not None
+
 
-        _fernet_instance = Fernet(key.encode() if isinstance(key, str) else key)
+def _get_fernet():
+    global _fernet_instance, _warn_shown, _key_source
+
+    if _fernet_instance is not None:
         return _fernet_instance
 
-    if not _warn_shown:
-        logger.warning(
-            "MFA_ENCRYPTION_KEY is not set — TOTP secrets and OIDC client_secrets are "
-            "stored in plaintext. Generate a key with: "
-            'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
-        )
-        _warn_shown = True
-    return None
+    key, internal_source = _load_or_generate_key()
+    # S8: collapse internal failure causes to public "none" while keeping
+    # the differentiated source for the warning path below.
+    _key_source = "none" if internal_source.startswith("none") else internal_source
+
+    if key is None:
+        if not _warn_shown:
+            # S8: only emit the "DATA_DIR not writable" warning when that's
+            # actually the cause. The corrupted-file path already error-logged
+            # in _load_or_generate_key with a more specific message.
+            if internal_source == "none_write_failed":
+                logger.warning(
+                    "MFA_ENCRYPTION_KEY is not set and DATA_DIR is not writable — "
+                    "TOTP secrets and OIDC client_secrets are stored in plaintext. "
+                    "Generate a key with: "
+                    'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
+                )
+            # Suppresses repetitive warnings across calls; reset together
+            # with _fernet_instance when re-initializing (e.g. in tests).
+            _warn_shown = True
+        return None
+
+    from cryptography.fernet import Fernet
+
+    _fernet_instance = Fernet(key.encode())
+    return _fernet_instance
 
 
 def mfa_encrypt(plaintext: str) -> str:
     """Encrypt a secret value. Returns the ciphertext with a ``fernet:`` prefix,
-    or the original plaintext if ``MFA_ENCRYPTION_KEY`` is not configured."""
+    or the original plaintext if no encryption key is available."""
     f = _get_fernet()
     if f is None:
         return plaintext
@@ -60,12 +209,13 @@ def mfa_decrypt(value: str) -> str:
     Raises ``RuntimeError`` if the prefix is present but no key is configured.
     """
     if not value.startswith(_FERNET_PREFIX):
-        # Nit6: Warn when a key IS configured but the stored value is plaintext.
+        # S7: Warn when a key IS configured but the stored value is plaintext.
         # This surfaces rows that were written before encryption was enabled so
-        # operators know they need a migration / re-enroll cycle.
+        # operators know they need a migration / re-enroll cycle. WARNING level
+        # so it shows up in normal operator log review.
         if _get_fernet() is not None:
             logger.warning(
-                "mfa_decrypt: MFA_ENCRYPTION_KEY is set but the stored value has no "
+                "mfa_decrypt: encryption key is active but the stored value has no "
                 "'fernet:' prefix — returning legacy plaintext. Consider re-enrolling "
                 "this secret to store it encrypted."
             )
@@ -80,9 +230,9 @@ def mfa_decrypt(value: str) -> str:
 
     try:
         return f.decrypt(value[len(_FERNET_PREFIX) :].encode()).decode()
-    except InvalidToken:
+    except InvalidToken as exc:
         raise RuntimeError(
             "MFA secret was encrypted under a different MFA_ENCRYPTION_KEY. "
             "Key rotation is not currently supported — restore the previous key "
             "or have users re-enroll."
-        )
+        ) from exc

+ 109 - 0
backend/app/core/logging_filters.py

@@ -0,0 +1,109 @@
+"""Logging filters for the Bambuddy log pipeline.
+
+Holds two filters: ``WriteRequestsOnlyFilter`` keeps the file-side
+uvicorn access log focused on state-changing HTTP methods, and
+``CancelledPoolNoiseFilter`` drops SQLAlchemy connection-pool log noise
+caused by Starlette's ``BaseHTTPMiddleware`` cancellation propagation
+(see the filter's docstring for details). Both live here so tests can
+import them without pulling in ``backend.app.main``'s startup graph.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+
+
+class WriteRequestsOnlyFilter(logging.Filter):
+    """Keep uvicorn access log records for state-changing HTTP methods only.
+
+    Uvicorn's access logger emits one record per HTTP request, formatted as
+
+        ``<client_addr> - "<METHOD> <path> HTTP/<ver>" <status>``
+
+    On a typical Bambuddy install the bulk of that traffic is GETs — the
+    frontend status-polling loop, the camera stream, snapshots, websocket
+    upgrades. None of those can change server state on their own, so for
+    incident triage ("who hit ``/print/stop`` at 09:23?") they're noise that
+    just rotates the log file faster.
+
+    This filter accepts only POST / PUT / PATCH / DELETE — the verbs that
+    actually mutate state — and drops everything else. Match anchors on the
+    surrounding ``" `` and trailing space so an unrelated literal substring
+    in a URL (e.g. ``GET /api/posts/POST``) cannot false-match.
+
+    Attach to ``logging.getLogger("uvicorn.access")`` (and only there — the
+    pattern is uvicorn's specific format string and would silently drop
+    everything if applied to a generic logger).
+    """
+
+    _WRITE_VERB_TOKENS: tuple[str, ...] = (
+        ' "POST ',
+        ' "PUT ',
+        ' "PATCH ',
+        ' "DELETE ',
+    )
+
+    def filter(self, record: logging.LogRecord) -> bool:  # noqa: A003 — stdlib API name
+        message = record.getMessage()
+        return any(token in message for token in self._WRITE_VERB_TOKENS)
+
+
+class CancelledPoolNoiseFilter(logging.Filter):
+    """Drop SQLAlchemy connection-pool log records driven by request cancellation.
+
+    Starlette's ``BaseHTTPMiddleware`` (used under the hood by FastAPI's
+    ``@app.middleware("http")`` decorator) cancels the inner task scope when a
+    client disconnects mid-request. The cancellation propagates into
+    SQLAlchemy's connection-pool cleanup and surfaces as two distinct ERROR
+    records — both expected on disconnect, neither actionable for the user:
+
+    1. ``Exception terminating connection ... CancelledError`` — fires every
+       time ``do_terminate`` is interrupted by the same cancel scope that's
+       unwinding the request. The ``CancelledError`` traceback always
+       attributes the cancel to ``BaseHTTPMiddleware.call_next``.
+
+    2. ``The garbage collector is trying to clean up non-checked-in
+       connection`` — fires later when the GC reclaims the session that
+       couldn't return its connection to the pool because of (1). It's
+       symptomatic of the cancellation, not a separate bug.
+
+    These pile up under heavy upload load (long multipart uploads where the
+    client times out before the server's response). Real connection-pool
+    issues — pool exhaustion, broken connections from network hiccups, etc.
+    — surface through DIFFERENT messages and a non-cancellation
+    ``exc_info`` chain, so they keep flowing through this filter unchanged.
+
+    Attach to ``logging.getLogger("sqlalchemy.pool")`` (and only there).
+    """
+
+    _GC_CLEANUP_PREFIX = "The garbage collector is trying to clean up non-checked-in connection"
+    _TERMINATE_PREFIX = "Exception terminating connection"
+
+    @staticmethod
+    def _has_cancelled_in_chain(exc: BaseException | None) -> bool:
+        """True if `exc` is `CancelledError` or has one in its cause chain."""
+        seen: set[int] = set()
+        cur: BaseException | None = exc
+        while cur is not None and id(cur) not in seen:
+            seen.add(id(cur))
+            if isinstance(cur, asyncio.CancelledError):
+                return True
+            cur = cur.__cause__ or cur.__context__
+        return False
+
+    def filter(self, record: logging.LogRecord) -> bool:  # noqa: A003 — stdlib API name
+        message = record.getMessage()
+        # GC-cleanup records have no exc_info — match by prefix only. Always
+        # symptomatic of the cancellation cascade, never independently useful.
+        if message.startswith(self._GC_CLEANUP_PREFIX):
+            return False
+        # Terminate-connection records carry a traceback; only drop those
+        # that are cancellation-driven. A real terminate failure (broken
+        # connection, network hiccup) keeps a non-CancelledError exc_info
+        # chain and surfaces normally.
+        if message.startswith(self._TERMINATE_PREFIX) and record.exc_info:
+            exc = record.exc_info[1]
+            if self._has_cancelled_in_chain(exc):
+                return False
+        return True

+ 26 - 0
backend/app/core/paths.py

@@ -0,0 +1,26 @@
+"""Shared path resolution helpers.
+
+Centralises the DATA_DIR fallback used by ``auth.py`` (``.jwt_secret``) and
+``encryption.py`` (``.mfa_encryption_key``) so both modules read the
+environment variable fresh on every call. Reading fresh — instead of caching
+the value at module import — is required so test fixtures can override
+``DATA_DIR`` per-test via ``monkeypatch.setenv`` and have the override take
+effect immediately.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+
+def resolve_data_dir() -> Path:
+    """Return the data directory, reading ``DATA_DIR`` fresh from env on each call.
+
+    Falls back to ``<project_root>/data`` when ``DATA_DIR`` is not set, matching
+    the behaviour of ``backend/app/core/auth.py:_get_jwt_secret``.
+    """
+    data_dir_env = os.environ.get("DATA_DIR")
+    if data_dir_env:
+        return Path(data_dir_env)
+    return Path(__file__).parent.parent.parent.parent / "data"

+ 27 - 0
backend/app/core/permissions.py

@@ -33,6 +33,7 @@ class Permission(StrEnum):
     ARCHIVES_DELETE_ALL = "archives:delete_all"
     ARCHIVES_REPRINT_OWN = "archives:reprint_own"
     ARCHIVES_REPRINT_ALL = "archives:reprint_all"
+    ARCHIVES_PURGE = "archives:purge"
 
     # Queue
     QUEUE_READ = "queue:read"
@@ -50,6 +51,10 @@ class Permission(StrEnum):
     LIBRARY_UPDATE_ALL = "library:update_all"
     LIBRARY_DELETE_OWN = "library:delete_own"
     LIBRARY_DELETE_ALL = "library:delete_all"
+    # Admin-only: bulk purge of old files + trash retention settings (#1008).
+    # Routine per-user trash management (restore-own, hard-delete-own) is
+    # gated by the existing LIBRARY_DELETE_* permissions instead.
+    LIBRARY_PURGE = "library:purge"
 
     # Projects
     PROJECTS_READ = "projects:read"
@@ -69,6 +74,8 @@ class Permission(StrEnum):
     INVENTORY_UPDATE = "inventory:update"
     INVENTORY_DELETE = "inventory:delete"
     INVENTORY_VIEW_ASSIGNMENTS = "inventory:view_assignments"  # View spool-to-AMS assignments on printer cards
+    INVENTORY_FORECAST_READ = "inventory:forecast_read"  # View forecast/reorder intelligence panel
+    INVENTORY_FORECAST_WRITE = "inventory:forecast_write"  # Modify SKU settings, lead times, shopping list
 
     # Smart Plugs
     SMART_PLUGS_READ = "smart_plugs:read"
@@ -138,6 +145,10 @@ class Permission(StrEnum):
     # Cloud Auth (admin-level)
     CLOUD_AUTH = "cloud:auth"
 
+    # MakerWorld Integration
+    MAKERWORLD_VIEW = "makerworld:view"  # Resolve MakerWorld URLs and view model metadata
+    MAKERWORLD_IMPORT = "makerworld:import"  # Download 3MFs from MakerWorld into the library
+
     # API Keys (admin-level)
     API_KEYS_READ = "api_keys:read"
     API_KEYS_CREATE = "api_keys:create"
@@ -181,6 +192,7 @@ PERMISSION_CATEGORIES = {
         Permission.ARCHIVES_DELETE_ALL,
         Permission.ARCHIVES_REPRINT_OWN,
         Permission.ARCHIVES_REPRINT_ALL,
+        Permission.ARCHIVES_PURGE,
     ],
     "Queue": [
         Permission.QUEUE_READ,
@@ -198,6 +210,7 @@ PERMISSION_CATEGORIES = {
         Permission.LIBRARY_UPDATE_ALL,
         Permission.LIBRARY_DELETE_OWN,
         Permission.LIBRARY_DELETE_ALL,
+        Permission.LIBRARY_PURGE,
     ],
     "Projects": [
         Permission.PROJECTS_READ,
@@ -217,6 +230,8 @@ PERMISSION_CATEGORIES = {
         Permission.INVENTORY_UPDATE,
         Permission.INVENTORY_DELETE,
         Permission.INVENTORY_VIEW_ASSIGNMENTS,
+        Permission.INVENTORY_FORECAST_READ,
+        Permission.INVENTORY_FORECAST_WRITE,
     ],
     "Smart Plugs": [
         Permission.SMART_PLUGS_READ,
@@ -283,6 +298,10 @@ PERMISSION_CATEGORIES = {
     "Cloud": [
         Permission.CLOUD_AUTH,
     ],
+    "MakerWorld": [
+        Permission.MAKERWORLD_VIEW,
+        Permission.MAKERWORLD_IMPORT,
+    ],
     "API Keys": [
         Permission.API_KEYS_READ,
         Permission.API_KEYS_CREATE,
@@ -345,6 +364,9 @@ DEFAULT_GROUPS = {
             Permission.LIBRARY_UPLOAD.value,
             Permission.LIBRARY_UPDATE_OWN.value,
             Permission.LIBRARY_DELETE_OWN.value,
+            # MakerWorld integration
+            Permission.MAKERWORLD_VIEW.value,
+            Permission.MAKERWORLD_IMPORT.value,
             # Projects - full access
             Permission.PROJECTS_READ.value,
             Permission.PROJECTS_CREATE.value,
@@ -361,6 +383,8 @@ DEFAULT_GROUPS = {
             Permission.INVENTORY_UPDATE.value,
             Permission.INVENTORY_DELETE.value,
             Permission.INVENTORY_VIEW_ASSIGNMENTS.value,
+            Permission.INVENTORY_FORECAST_READ.value,
+            Permission.INVENTORY_FORECAST_WRITE.value,
             # Smart Plugs - full access
             Permission.SMART_PLUGS_READ.value,
             Permission.SMART_PLUGS_CREATE.value,
@@ -419,6 +443,7 @@ DEFAULT_GROUPS = {
             Permission.FILAMENTS_READ.value,
             Permission.INVENTORY_READ.value,
             Permission.INVENTORY_VIEW_ASSIGNMENTS.value,
+            Permission.INVENTORY_FORECAST_READ.value,
             Permission.SMART_PLUGS_READ.value,
             Permission.CAMERA_VIEW.value,
             Permission.MAINTENANCE_READ.value,
@@ -432,6 +457,8 @@ DEFAULT_GROUPS = {
             Permission.SYSTEM_READ.value,
             Permission.SETTINGS_READ.value,
             Permission.WEBSOCKET_CONNECT.value,
+            # MakerWorld browsing only (no import — that writes to library)
+            Permission.MAKERWORLD_VIEW.value,
         ],
         "is_system": True,
     },

+ 118 - 0
backend/app/core/trace.py

@@ -0,0 +1,118 @@
+"""Per-request trace ID plumbing.
+
+Each HTTP request gets a short hex ID set in a ``ContextVar``; downstream
+log records (application *and* uvicorn access) read the same context and
+include the ID in their output. The result is that one ``grep <trace_id>``
+on ``bambuddy.log`` returns the access line + every line emitted on the
+server side while that request was being handled — closing the loop
+opened by piping uvicorn access into the file: the access line tells you
+*who* called the endpoint, the trace ID tells you *what else happened*
+on the server because of it.
+
+Why a ContextVar instead of e.g. ``request.state``:
+
+* asyncio copies the current context into every ``asyncio.create_task``,
+  so background work spawned from within a request inherits the same
+  trace ID without having to be passed it explicitly. ``request.state``
+  doesn't survive that hop.
+* The logging filter has no access to the FastAPI request object — it
+  runs synchronously inside the stdlib logging machinery — and the
+  ContextVar is the only mechanism that bridges async request scope to
+  sync log emission.
+
+Why no fancy structured-logging schema: this is a small project. The
+existing log format is a single line per record; we add a single
+bracketed token for the trace ID and stop there. If structured logging
+is wanted later, it can layer on top — the ContextVar carries an opaque
+string regardless of what consumes it downstream.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+import secrets
+from contextvars import ContextVar
+
+# Default ``"-"`` (instead of None or empty string) so the format string
+# always produces a stable visual width; a bare empty bracket pair would
+# read as "no trace ID at all" which is hard to grep for. ``-`` reads as
+# "no value in this column" the way it does in HTTP access logs already.
+TRACE_ID_PLACEHOLDER = "-"
+
+trace_id_var: ContextVar[str] = ContextVar("trace_id", default=TRACE_ID_PLACEHOLDER)
+
+# Length of a freshly minted trace ID in hex chars. 8 chars = 32 bits of
+# entropy = ~4 billion possibilities; collisions are astronomically
+# unlikely within a single rotation window of bambuddy.log and grep stays
+# easy at this length. Increase later if it proves too short for a busy
+# install — the filter and format don't care about width.
+_GENERATED_LENGTH = 8
+
+# Bound on how long an *inbound* trace ID can be when echoed from the
+# X-Trace-Id request header. Without a cap a malicious / buggy client
+# could push 1 MB of garbage into every log line for a request. 64 chars
+# comfortably accommodates UUIDs (32 hex), Datadog-style 64-bit IDs,
+# OpenTelemetry's 32-hex spans — anything longer is almost certainly
+# wrong and we'd rather mint our own than honour it.
+_MAX_INBOUND_LENGTH = 64
+
+# Whitelist of characters allowed in an inbound trace ID. Restricted to
+# the alphanumerics + a small set of separators that real-world
+# correlation IDs use, so newlines / quotes / control chars cannot be
+# smuggled into log lines via the X-Trace-Id header. A request with an
+# unacceptable header just gets a freshly minted server-side ID — we
+# never reject the request for it.
+_VALID_INBOUND = re.compile(r"^[A-Za-z0-9_\-]+$")
+
+
+def get_trace_id() -> str:
+    """Return the current trace ID, or the placeholder if none is set."""
+    return trace_id_var.get()
+
+
+def generate_trace_id() -> str:
+    """Mint a fresh server-side trace ID."""
+    return secrets.token_hex(_GENERATED_LENGTH // 2)
+
+
+def normalise_inbound_trace_id(raw: str | None) -> str | None:
+    """Validate and return a caller-supplied trace ID, or ``None`` to mint fresh.
+
+    Accepts only short alphanumeric + ``_-`` strings so a hostile or buggy
+    client can't smuggle log-injection payloads through the X-Trace-Id
+    header. Returns ``None`` for any input that fails the gate, signalling
+    to the middleware that it should generate one instead.
+    """
+    if raw is None:
+        return None
+    if not raw or len(raw) > _MAX_INBOUND_LENGTH:
+        return None
+    if not _VALID_INBOUND.match(raw):
+        return None
+    return raw
+
+
+class TraceIDFilter(logging.Filter):
+    """Inject the current ``trace_id_var`` value into every LogRecord.
+
+    Attach to the file handler (or any handler whose format string
+    references ``%(trace_id)s``) so that every line written through that
+    handler carries the request scope it was generated under. The filter
+    always returns ``True`` — it never drops records, only annotates
+    them.
+
+    Records emitted outside any HTTP request (startup, MQTT callbacks,
+    scheduled tasks not chained from a request) get the placeholder
+    string, so the format column stays aligned and absent values are
+    obviously visible as ``[-]`` rather than blanks.
+    """
+
+    def filter(self, record: logging.LogRecord) -> bool:  # noqa: A003 — stdlib API name
+        # Use direct attribute set (not setdefault-style) so the value is
+        # always taken from the *current* context — a record formatted on
+        # a different task than where it was created (rare but possible
+        # via QueueHandler or async-throttled handlers) still picks up
+        # the right ID.
+        record.trace_id = trace_id_var.get()
+        return True

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 607 - 63
backend/app/main.py


+ 2 - 0
backend/app/models/__init__.py

@@ -10,6 +10,7 @@ from backend.app.models.group import Group, user_groups
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.local_preset import LocalPreset
+from backend.app.models.long_lived_token import LongLivedToken
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationLog
 from backend.app.models.notification_template import NotificationTemplate
@@ -75,4 +76,5 @@ __all__ = [
     "UserTOTP",
     "AuthEphemeralToken",
     "AuthRateLimitEvent",
+    "LongLivedToken",
 ]

+ 12 - 1
backend/app/models/api_key.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import JSON, Boolean, DateTime, String, func
+from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
@@ -16,10 +16,21 @@ class APIKey(Base):
     key_hash: Mapped[str] = mapped_column(String(255))  # bcrypt hash of the key
     key_prefix: Mapped[str] = mapped_column(String(20))  # First 8 chars + "..." for display
 
+    # Owner — required for new keys, NULL only on legacy rows that predate per-user
+    # ownership. Cloud routes reject calls from keys without an owner so callers are
+    # forced to recreate them. CASCADE so deleting a user removes their keys.
+    user_id: Mapped[int | None] = mapped_column(
+        Integer,
+        ForeignKey("users.id", ondelete="CASCADE"),
+        nullable=True,
+        index=True,
+    )
+
     # Permissions
     can_queue: Mapped[bool] = mapped_column(Boolean, default=True)  # Add to queue
     can_control_printer: Mapped[bool] = mapped_column(Boolean, default=False)  # Start/stop/cancel
     can_read_status: Mapped[bool] = mapped_column(Boolean, default=True)  # Query status
+    can_access_cloud: Mapped[bool] = mapped_column(Boolean, default=False)  # Read /cloud/* on the owner's behalf
 
     # Optional scope limits
     printer_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)  # null = all printers

+ 1 - 0
backend/app/models/archive.py

@@ -33,6 +33,7 @@ class PrintArchive(Base):
     total_layers: Mapped[int | None] = mapped_column(Integer)
     nozzle_diameter: Mapped[float | None] = mapped_column(Float)
     bed_temperature: Mapped[int | None] = mapped_column(Integer)
+    bed_type: Mapped[str | None] = mapped_column(String(64))  # e.g. "Cool Plate", "Textured PEI Plate"
     nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
 
     # Printer model this file was sliced for (extracted from 3MF metadata)

+ 4 - 1
backend/app/models/color_catalog.py

@@ -14,7 +14,10 @@ class ColorCatalogEntry(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     manufacturer: Mapped[str] = mapped_column(String(200))
     color_name: Mapped[str] = mapped_column(String(200))
-    hex_color: Mapped[str] = mapped_column(String(7))  # #RRGGBB
+    hex_color: Mapped[str] = mapped_column(String(9))  # #RRGGBB or #RRGGBBAA
     material: Mapped[str | None] = mapped_column(String(100))
     is_default: Mapped[bool] = mapped_column(Boolean, default=False)
+    # Optional multi-colour stops + visual effect (#1154), mirrors Spool fields.
+    extra_colors: Mapped[str | None] = mapped_column(String(255))
+    effect_type: Mapped[str | None] = mapped_column(String(20))
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 28 - 0
backend/app/models/filament_sku_settings.py

@@ -0,0 +1,28 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Integer, String, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class FilamentSkuSettings(Base):
+    """User-configured reorder settings for a filament SKU (material/subtype/brand group)."""
+
+    __tablename__ = "filament_sku_settings"
+    __table_args__ = (
+        # sqlite_where ensures NULL columns participate in uniqueness (NULLS NOT DISTINCT).
+        # On PostgreSQL the partial index is not needed — standard UNIQUE handles it.
+        UniqueConstraint("material", "subtype", "brand", name="uq_filament_sku"),
+    )
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    material: Mapped[str] = mapped_column(String(50))
+    subtype: Mapped[str | None] = mapped_column(String(50))
+    brand: Mapped[str | None] = mapped_column(String(100))
+    lead_time_days: Mapped[int] = mapped_column(Integer, default=0)
+    safety_margin_value: Mapped[int] = mapped_column(Integer, default=14)
+    safety_margin_unit: Mapped[str] = mapped_column(String(10), default="days")
+    alerts_snoozed: Mapped[bool] = mapped_column(Boolean, default=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 2 - 0
backend/app/models/github_backup.py

@@ -17,6 +17,8 @@ class GitHubBackupConfig(Base):
     repository_url: Mapped[str] = mapped_column(String(500))  # Full GitHub URL
     access_token: Mapped[str] = mapped_column(Text)  # Personal Access Token
     branch: Mapped[str] = mapped_column(String(100), default="main")
+    provider: Mapped[str] = mapped_column(String(30), default="github")
+    allow_insecure_http: Mapped[bool] = mapped_column(Boolean, default=False)
 
     # Schedule configuration
     schedule_enabled: Mapped[bool] = mapped_column(Boolean, default=False)

+ 25 - 1
backend/app/models/library.py

@@ -2,7 +2,7 @@
 
 from datetime import datetime
 
-from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, Select, String, Text, func, select
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -82,9 +82,22 @@ class LibraryFile(Base):
     # User notes
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)
 
+    # Provenance — when the file was imported from an external source (e.g.
+    # MakerWorld), ``source_type`` identifies the source and ``source_url`` is
+    # the canonical public URL. Used for "already imported" detection and
+    # "re-open on MakerWorld" affordances. Index on source_url so the
+    # dedupe lookup is O(log N).
+    source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
+    source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
+
     # User tracking (Issue #206)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
 
+    # Soft-delete / trash bin (Issue #1008). When non-null, the file is in the
+    # trash and should not appear in normal listings. A background sweeper
+    # hard-deletes rows whose deleted_at is older than the retention window.
+    deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
+
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
@@ -94,6 +107,17 @@ class LibraryFile(Base):
     project: Mapped["Project | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
 
+    @classmethod
+    def active(cls) -> "Select[tuple[LibraryFile]]":
+        """Select statement that excludes trashed (soft-deleted) files.
+
+        Use this in place of ``select(LibraryFile)`` for any user-facing listing
+        or lookup so trashed files don't leak into normal flows. Endpoints that
+        specifically operate on trashed rows (trash list, restore, sweeper)
+        must use ``select(LibraryFile)`` directly.
+        """
+        return select(cls).where(cls.deleted_at.is_(None))
+
 
 from backend.app.models.archive import PrintArchive  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811

+ 77 - 0
backend/app/models/long_lived_token.py

@@ -0,0 +1,77 @@
+"""Long-lived camera-stream tokens (#1108).
+
+Issue #1108: the existing 60-minute ``camera_stream`` ephemeral tokens are
+too short-lived for home-automation integrations (Home Assistant cards,
+Frigate, kiosks), which expect a token they can paste once and forget.
+
+Why a separate table from ``AuthEphemeralToken``:
+
+- These are user-owned, named, and revocable from the UI — different
+  lifecycle from ephemeral / single-use tokens.
+- Hashed at rest (bcrypt). Ephemeral tokens are stored as raw strings
+  because their short TTL caps the impact of a DB read; a long-lived
+  token must survive a DB dump unscathed.
+
+Why a separate table from ``api_keys``:
+
+- ``api_keys`` is for webhook integrations and has no ``user_id`` FK
+  (the keys are global). Long-lived camera tokens are explicitly per-user
+  so the UI can show "your tokens" and so a leak can be traced to one user.
+- Different permission shape (``api_keys`` carries can_queue / can_control
+  flags; long-lived tokens are pure read-only camera streaming).
+
+V1 hard rules:
+
+- ``expires_at`` is required (the issue's ``expire_in: 0 = never`` was
+  rejected — irrevocable infinite tokens are a footgun).
+- 365-day max — enforced in the create route, not the DB, so a future
+  policy change is just a config bump.
+- Scope column exists today ("camera_stream" is the only valid value)
+  to keep the door open for other long-lived scopes later without a
+  schema migration.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class LongLivedToken(Base):
+    """Per-user, hashed-at-rest, revocable token for long-running camera viewers."""
+
+    __tablename__ = "long_lived_tokens"
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+    user_id: Mapped[int] = mapped_column(
+        Integer,
+        ForeignKey("users.id", ondelete="CASCADE"),
+        nullable=False,
+        index=True,
+    )
+    # User-given label — "Home Assistant", "Kitchen kiosk", etc.
+    name: Mapped[str] = mapped_column(String(100), nullable=False)
+    # Public lookup prefix — first 8 chars of the secret part. Indexed so
+    # verify() can fetch one row instead of scanning + bcrypting all rows.
+    # Format: ``bblt_<8-char-prefix>_<32-char-secret>``.
+    lookup_prefix: Mapped[str] = mapped_column(String(8), nullable=False, index=True)
+    # bcrypt hash of the 32-char secret part. Never stored or returned in plaintext.
+    secret_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+    # V1: only "camera_stream" is accepted. Column exists so future scopes
+    # don't need a schema migration.
+    scope: Mapped[str] = mapped_column(String(32), nullable=False, default="camera_stream")
+    # Required — no infinite tokens. Capped at 365 days at create time.
+    expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+    # Updated on successful verify (rate-limited to once per minute per token
+    # to avoid thrashing the DB on every MJPEG keep-alive read).
+    last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    # Set when the user (or an admin) revokes; verify treats revoked == invalid.
+    revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
+
+    def __repr__(self) -> str:
+        return f"<LongLivedToken id={self.id} user_id={self.user_id} name={self.name!r} scope={self.scope}>"

+ 4 - 0
backend/app/models/notification.py

@@ -88,6 +88,10 @@ class NotificationProvider(Base):
     on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold after print
     on_first_layer_complete = Column(Boolean, default=False)  # First layer finished printing
 
+    # Event triggers - Inventory stock alerts
+    on_stock_reorder_alert = Column(Boolean, default=False)  # SKU hits reorder point
+    on_stock_break_alert = Column(Boolean, default=False)  # Stock will run out before replenishment
+
     # Event triggers - Print queue
     on_queue_job_added = Column(Boolean, default=False)  # Job added to queue
     on_queue_job_assigned = Column(Boolean, default=False)  # Model-based job assigned to printer

+ 13 - 0
backend/app/models/notification_template.py

@@ -176,6 +176,19 @@ DEFAULT_TEMPLATES = [
         "title_template": "{app_name} - Password Reset",
         "body_template": "Hello {username},\n\nYour password has been reset.\nNew Password: {password}\n\nLogin at: {login_url}",
     },
+    # Inventory stock alert templates
+    {
+        "event_type": "stock_reorder_alert",
+        "name": "Stock Reorder Alert",
+        "title_template": "Reorder Alert: {material}",
+        "body_template": "{material} ({brand}) has reached the reorder point.\nStock: {stock_g}g | Rate: {rate_g_day}g/day | Days left: {days_left}d\nReorder now to avoid a stock break.",
+    },
+    {
+        "event_type": "stock_break_alert",
+        "name": "Stock Break Alert",
+        "title_template": "Stock Break Risk: {material}",
+        "body_template": "{material} ({brand}) will run out before replenishment arrives.\nStock: {stock_g}g | Rate: {rate_g_day}g/day | Lead time: {lead_time_days}d\nOnly {days_left}d of stock remaining — order immediately.",
+    },
     # User email notification templates (sent to the print job owner)
     {
         "event_type": "user_print_start",

+ 30 - 1
backend/app/models/oidc_provider.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -21,6 +21,15 @@ class OIDCProvider(Base):
     """
 
     __tablename__ = "oidc_providers"
+    __table_args__ = (
+        # DB-level enforcement of SEC-1: blocks only Fall B (email_claim='email' + require_ev=False).
+        # Fall C (custom claim) is safe — no email_verified gate on that path.
+        # Enforced on new installations; existing tables updated via _migrate_update_auto_link_constraint.
+        CheckConstraint(
+            "auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE",
+            name="ck_auto_link_requires_verified_email_claim",
+        ),
+    )
 
     id: Mapped[int] = mapped_column(primary_key=True)
     # Human-readable name shown on the login button (e.g. "PocketID", "Google")
@@ -50,6 +59,26 @@ class OIDCProvider(Base):
     # operators must explicitly opt-in to prevent an attacker-controlled IdP from
     # silently hijacking local accounts via email matching (M-2 fix).
     auto_link_existing_accounts: Mapped[bool] = mapped_column(Boolean, default=False)
+    # JWT claim name used as the email identity (default "email").
+    # Set to "preferred_username" or "upn" for Azure Entra ID, which does not send
+    # email_verified — using a custom claim skips the email_verified check entirely
+    # and is the recommended Azure configuration.
+    # Has no interaction with require_email_verified when set to a non-"email" value:
+    # custom claims never perform an email_verified check regardless of that setting.
+    email_claim: Mapped[str] = mapped_column(String(64), default="email")
+    # When True (default), the "email" claim is only trusted when email_verified=True.
+    # Set to False to accept the email even when email_verified is absent — required
+    # for providers like Azure Entra ID that never send email_verified and where a
+    # custom claim (email_claim != "email") is not preferred.
+    # Has no effect when email_claim is not "email": the custom-claim path never
+    # performs an email_verified check regardless of this setting.
+    require_email_verified: Mapped[bool] = mapped_column(Boolean, default=True)
+    # Nullable FK — configurable default group for auto-created OIDC users.
+    # Falls back to "Viewers" when None. ON DELETE SET NULL fires on PostgreSQL;
+    # SQLite ignores it (no PRAGMA foreign_keys=ON), so runtime resolution handles dangling refs.
+    default_group_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, default=None
+    )
     # Optional icon URL (SVG/PNG) shown on the login button
     icon_url: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 6 - 0
backend/app/models/pending_upload.py

@@ -20,6 +20,12 @@ class PendingUpload(Base):
     file_path: Mapped[str] = mapped_column(String(500))  # Temp storage path
     file_size: Mapped[int] = mapped_column(Integer)
 
+    # Embedded 3MF Title metadata, captured at FTP-receive time so the review
+    # card and the eventual archive's print_name agree on which name to show
+    # (#1152 follow-up). NULL when the 3MF has no title or the metadata read
+    # failed — the response model falls back to the filename stem in that case.
+    metadata_print_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
     # Source info
     source_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
 

+ 5 - 0
backend/app/models/printer.py

@@ -28,6 +28,11 @@ class Printer(Base):
     external_camera_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
     external_camera_type: Mapped[str | None] = mapped_column(String(20), nullable=True)  # mjpeg, rtsp, snapshot
     external_camera_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+    # Optional single-frame snapshot URL — when set, used for snapshot / finish-photo
+    # / timelapse / plate-detect captures instead of opening the live stream and
+    # skipping a warm-up frame. Bypasses MJPEG warm-up issues on sources that
+    # expose a dedicated frame endpoint (e.g. go2rtc's /api/frame.jpeg). #1177.
+    external_camera_snapshot_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
     camera_rotation: Mapped[int] = mapped_column(default=0)  # 0, 90, 180, 270 degrees
     # Plate detection - check if build plate is empty before starting print
     plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False)

+ 8 - 0
backend/app/models/project.py

@@ -16,6 +16,14 @@ class Project(Base):
     description: Mapped[str | None] = mapped_column(Text, nullable=True)
     color: Mapped[str | None] = mapped_column(String(20), nullable=True)  # Hex color for UI
     status: Mapped[str] = mapped_column(String(20), default="active")  # active, completed, archived
+
+    # External link rendered as a clickable icon next to the project name (#1155).
+    url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
+    # Filename of the cover photo inside the project's attachments dir; serves as
+    # the card's hero image when set (#1155). The file lives alongside other
+    # attachments but is tracked here separately so users can manage one without
+    # the other.
+    cover_image_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
     target_count: Mapped[int | None] = mapped_column(
         Integer, nullable=True
     )  # Optional target number of prints (plates)

+ 22 - 0
backend/app/models/shopping_list.py

@@ -0,0 +1,22 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class ShoppingListItem(Base):
+    """A filament SKU queued for purchase."""
+
+    __tablename__ = "filament_shopping_list"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    material: Mapped[str] = mapped_column(String(50))
+    subtype: Mapped[str | None] = mapped_column(String(50))
+    brand: Mapped[str | None] = mapped_column(String(100))
+    quantity_spools: Mapped[int] = mapped_column(Integer, default=1)
+    note: Mapped[str | None] = mapped_column(String(500))
+    status: Mapped[str] = mapped_column(String(20), default="pending")  # pending | purchased | received
+    purchased_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    added_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 21 - 1
backend/app/models/spool.py

@@ -16,6 +16,14 @@ class Spool(Base):
     subtype: Mapped[str | None] = mapped_column(String(50))  # Basic, Matte, Silk, etc.
     color_name: Mapped[str | None] = mapped_column(String(100))  # "Jade White"
     rgba: Mapped[str | None] = mapped_column(String(8))  # RRGGBBAA hex
+    # Multi-colour gradient stops for filaments with more than one colour
+    # (e.g. tri-colour, multi-colour). Stored as comma-separated 6- or 8-char
+    # hex tokens without `#`. Empty/NULL means solid (uses `rgba`). Up to 8
+    # stops; combination mode is driven by `subtype` (Gradient, Multicolor).
+    extra_colors: Mapped[str | None] = mapped_column(String(255))
+    # Visual effect overlay independent of subtype: sparkle, wood, marble,
+    # glow, matte. Purely a rendering hint — does not affect MQTT/firmware.
+    effect_type: Mapped[str | None] = mapped_column(String(20))
     brand: Mapped[str | None] = mapped_column(String(100))  # "Polymaker"
     label_weight: Mapped[int] = mapped_column(Integer, default=1000)  # Advertised net weight (g)
     core_weight: Mapped[int] = mapped_column(Integer, default=250)  # Empty spool weight (g)
@@ -33,12 +41,24 @@ class Spool(Base):
     note: Mapped[str | None] = mapped_column(String(500))
     added_full: Mapped[bool | None] = mapped_column()  # Whether spool was added as full (unused)
 
+    # User-defined category (e.g. "Production", "Prototype", "Client A") for
+    # filtering and per-group low-stock thresholds (#729). Free text — the
+    # form autocompletes from categories already present on other spools.
+    category: Mapped[str | None] = mapped_column(String(50))
+    # Per-spool override of the global inventory low-stock threshold (%).
+    # NULL falls back to the `low_stock_threshold` setting. Lets users mark
+    # production spools with a higher threshold (alert earlier) and prototype
+    # spools with a lower one without changing the global default.
+    low_stock_threshold_pct: Mapped[int | None] = mapped_column(Integer)
+
     # Cost tracking
     cost_per_kg: Mapped[float | None] = mapped_column(Float)  # Cost per kilogram
 
+    storage_location: Mapped[str | None] = mapped_column(String(255))  # User-editable storage location
+
     last_used: Mapped[datetime | None] = mapped_column(DateTime)  # Last time this spool was used in a print
     encode_time: Mapped[datetime | None] = mapped_column(DateTime)  # When spool was encoded/written to tag
-    tag_uid: Mapped[str | None] = mapped_column(String(16))  # RFID tag UID (16 hex chars)
+    tag_uid: Mapped[str | None] = mapped_column(String(32))  # RFID tag UID (up to 32 hex chars)
     tray_uuid: Mapped[str | None] = mapped_column(String(32))  # Bambu Lab spool UUID (32 hex chars)
     data_origin: Mapped[str | None] = mapped_column(String(20))  # How data was populated: manual, rfid_auto, nfc_link
     tag_type: Mapped[str | None] = mapped_column(String(20))  # Tag vendor: bambulab, generic, etc.

+ 1 - 0
backend/app/models/spoolbuddy_device.py

@@ -37,5 +37,6 @@ class SpoolBuddyDevice(Base):
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)
     system_stats: Mapped[str | None] = mapped_column(Text, nullable=True)
+    ssh_host_key: Mapped[str | None] = mapped_column(Text, nullable=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 34 - 0
backend/app/models/spoolman_k_profile.py

@@ -0,0 +1,34 @@
+from datetime import datetime
+
+from sqlalchemy import CheckConstraint, DateTime, Float, ForeignKey, Integer, String, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class SpoolmanKProfile(Base):
+    """K-value calibration profile for a Spoolman spool on a specific printer/nozzle combo."""
+
+    __tablename__ = "spoolman_k_profile"
+
+    __table_args__ = (
+        UniqueConstraint("spoolman_spool_id", "printer_id", "extruder", "nozzle_diameter"),
+        CheckConstraint("extruder >= 0 AND extruder <= 1", name="ck_extruder_range"),
+    )
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    spoolman_spool_id: Mapped[int] = mapped_column(Integer, nullable=False)
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    extruder: Mapped[int] = mapped_column(Integer, default=0)
+    nozzle_diameter: Mapped[str] = mapped_column(String(10), default="0.4")
+    nozzle_type: Mapped[str | None] = mapped_column(String(50))
+    k_value: Mapped[float] = mapped_column(Float)
+    name: Mapped[str | None] = mapped_column(String(100))
+    cali_idx: Mapped[int | None] = mapped_column(Integer)
+    setting_id: Mapped[str | None] = mapped_column(String(50))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    printer: Mapped["Printer"] = relationship()
+
+
+from backend.app.models.printer import Printer  # noqa: E402, F401

+ 35 - 0
backend/app/models/spoolman_slot_assignment.py

@@ -0,0 +1,35 @@
+from datetime import datetime
+
+from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Integer, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class SpoolmanSlotAssignment(Base):
+    """Assignment of a Spoolman spool to a specific AMS slot on a printer.
+
+    Tracks which Spoolman spool ID occupies a given (printer, ams, tray) slot.
+    This is the source of truth for Spoolman slot assignments — Spoolman's own
+    ``spool.location`` field is NOT managed by Bambuddy and is left for the user.
+    """
+
+    __tablename__ = "spoolman_slot_assignments"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    ams_id: Mapped[int] = mapped_column(Integer)
+    tray_id: Mapped[int] = mapped_column(Integer)
+    spoolman_spool_id: Mapped[int] = mapped_column(Integer)
+    assigned_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    printer: Mapped["Printer"] = relationship()
+
+    __table_args__ = (
+        UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_assignment"),
+        CheckConstraint("(ams_id >= 0 AND ams_id <= 7) OR ams_id = 255", name="ck_ams_id_range"),
+        CheckConstraint("tray_id >= 0 AND tray_id <= 3", name="ck_tray_id_range"),
+    )
+
+
+from backend.app.models.printer import Printer  # noqa: E402, F401

+ 8 - 0
backend/app/models/virtual_printer.py

@@ -18,6 +18,11 @@ class VirtualPrinter(Base):
     auto_dispatch: Mapped[bool] = mapped_column(
         Boolean, server_default="true"
     )  # print_queue mode: auto-start or manual
+    queue_force_color_match: Mapped[bool] = mapped_column(
+        Boolean, server_default="false"
+    )  # print_queue mode: pin per-slot type+color from the 3MF onto the queue
+    # item so the scheduler refuses to dispatch onto a printer with the wrong
+    # filament loaded (#1188).
     model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)
     access_code: Mapped[str | None] = mapped_column(String(8), nullable=True)  # 8 chars (server mode)
     target_printer_id: Mapped[int | None] = mapped_column(
@@ -25,6 +30,9 @@ class VirtualPrinter(Base):
     )  # proxy mode
     bind_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # dedicated IP (proxy mode)
     remote_interface_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # SSDP advertise IP
+    tailscale_disabled: Mapped[bool] = mapped_column(
+        Boolean, server_default="true"
+    )  # opt-in: user must explicitly enable; auto-detect only runs then
     serial_suffix: Mapped[str] = mapped_column(String(9), default="391800001")  # unique per printer
     position: Mapped[int] = mapped_column(Integer, default=0)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 4 - 0
backend/app/schemas/api_key.py

@@ -10,6 +10,7 @@ class APIKeyCreate(BaseModel):
     can_queue: bool = True
     can_control_printer: bool = False
     can_read_status: bool = True
+    can_access_cloud: bool = False  # Read /cloud/* on the creator's behalf — default off (#1182)
     printer_ids: list[int] | None = None  # null = all printers
     expires_at: datetime | None = None
 
@@ -21,6 +22,7 @@ class APIKeyUpdate(BaseModel):
     can_queue: bool | None = None
     can_control_printer: bool | None = None
     can_read_status: bool | None = None
+    can_access_cloud: bool | None = None
     printer_ids: list[int] | None = None
     enabled: bool | None = None
     expires_at: datetime | None = None
@@ -32,9 +34,11 @@ class APIKeyResponse(BaseModel):
     id: int
     name: str
     key_prefix: str  # First 8 chars for identification
+    user_id: int | None  # Owner — NULL on legacy keys created before per-user ownership (#1182)
     can_queue: bool
     can_control_printer: bool
     can_read_status: bool
+    can_access_cloud: bool
     printer_ids: list[int] | None
     enabled: bool
     last_used: datetime | None

+ 1 - 0
backend/app/schemas/archive.py

@@ -66,6 +66,7 @@ class ArchiveResponse(BaseModel):
     total_layers: int | None = None
     nozzle_diameter: float | None
     bed_temperature: int | None
+    bed_type: str | None = None  # e.g. "Cool Plate", "Textured PEI Plate" (from 3MF curr_bed_type)
     nozzle_temperature: int | None
 
     sliced_for_model: str | None = None  # Printer model this file was sliced for

+ 25 - 0
backend/app/schemas/archive_purge.py

@@ -0,0 +1,25 @@
+"""Schemas for archive auto-purge (#1008 follow-up)."""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+
+class ArchivePurgePreviewResponse(BaseModel):
+    count: int
+    total_bytes: int
+    sample_filenames: list[str]
+    older_than_days: int
+
+
+class ArchivePurgeRequest(BaseModel):
+    older_than_days: int = Field(ge=1, le=3650)
+
+
+class ArchivePurgeResponse(BaseModel):
+    deleted: int
+
+
+class ArchivePurgeSettings(BaseModel):
+    enabled: bool = False
+    days: int = Field(default=365, ge=7, le=3650)

+ 86 - 9
backend/app/schemas/auth.py

@@ -1,7 +1,7 @@
 import re
 from typing import Literal
 
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, field_validator, model_validator
 
 
 def _validate_password_complexity(v: str) -> str:
@@ -108,12 +108,11 @@ class SetupRequest(BaseModel):
     admin_username: str | None = Field(default=None, max_length=150)
     admin_password: str | None = Field(default=None, max_length=256)
 
-    @field_validator("admin_password")
-    @classmethod
-    def validate_admin_password(cls, v: str | None) -> str | None:
-        if v is not None:
-            _validate_password_complexity(v)
-        return v
+    # Password complexity is NOT validated at the schema layer. When re-enabling auth
+    # with an existing admin user (or when LDAP is the auth backend), the frontend
+    # still sends whatever is in the password field but the route ignores it.
+    # Enforcing complexity here would reject those legitimate flows. The route body
+    # applies the check only when a brand-new local admin is actually being created.
 
 
 class SetupResponse(BaseModel):
@@ -297,6 +296,19 @@ class AdminDisable2FARequest(BaseModel):
 # ---------------------------------------------------------------------------
 
 
+AUTO_LINK_REQUIREMENTS_ERROR = (
+    "auto_link_existing_accounts requires require_email_verified=True when email_claim='email'"
+)
+
+
+def _validate_email_claim_name(v: str) -> str:
+    # Accepts only alphanumeric/underscore/hyphen claim names starting with a letter —
+    # prevents log injection and limits the attack surface of operator-supplied claim names.
+    if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9_\-]{0,63}", v):
+        raise ValueError("Invalid claim name")
+    return v
+
+
 def _validate_icon_url(v: str | None) -> str | None:
     """Reject non-HTTPS icon URLs to prevent SSRF / mixed-content issues."""
     if v is None:
@@ -356,27 +368,46 @@ class OIDCProviderCreate(BaseModel):
     is_enabled: bool = True
     auto_create_users: bool = False
     auto_link_existing_accounts: bool = False  # M-2: conservative default, opt-in only
+    email_claim: str = Field(default="email", max_length=64)
+    require_email_verified: bool = True
     icon_url: str | None = None
+    default_group_id: int | None = None
 
     @field_validator("issuer_url")
     @classmethod
     def validate_issuer_url(cls, v: str) -> str:
         result = _validate_issuer_url(v)
-        assert result is not None
+        if result is None:
+            raise ValueError("issuer_url is required")
         return result
 
     @field_validator("scopes")
     @classmethod
     def validate_scopes(cls, v: str) -> str:
         result = _validate_scopes(v)
-        assert result is not None
+        if result is None:
+            raise ValueError("scopes is required")
         return result
 
+    @field_validator("email_claim")
+    @classmethod
+    def validate_email_claim(cls, v: str) -> str:
+        return _validate_email_claim_name(v)
+
     @field_validator("icon_url")
     @classmethod
     def validate_icon_url(cls, v: str | None) -> str | None:
         return _validate_icon_url(v)
 
+    # SEC-1: auto_link with email_claim='email' requires require_email_verified=True.
+    # Fall B (require_email_verified=False + email_claim='email') accepts absent email_verified → account-takeover risk.
+    # Fall C (custom claim != 'email') is safe: no email_verified gate on that path regardless of require_email_verified.
+    @model_validator(mode="after")
+    def check_auto_link_requires_verified(self) -> "OIDCProviderCreate":
+        if self.auto_link_existing_accounts and self.email_claim == "email" and not self.require_email_verified:
+            raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
+        return self
+
 
 class OIDCProviderUpdate(BaseModel):
     name: str | None = Field(default=None, max_length=100)
@@ -393,18 +424,42 @@ class OIDCProviderUpdate(BaseModel):
     is_enabled: bool | None = None
     auto_create_users: bool | None = None
     auto_link_existing_accounts: bool | None = None
+    email_claim: str | None = Field(default=None, max_length=64)
+    require_email_verified: bool | None = None
     icon_url: str | None = None
+    default_group_id: int | None = None
 
     @field_validator("scopes")
     @classmethod
     def validate_scopes(cls, v: str | None) -> str | None:
         return _validate_scopes(v)
 
+    @field_validator("email_claim")
+    @classmethod
+    def validate_email_claim(cls, v: str | None) -> str | None:
+        if v is None:
+            return None
+        return _validate_email_claim_name(v)
+
     @field_validator("icon_url")
     @classmethod
     def validate_icon_url(cls, v: str | None) -> str | None:
         return _validate_icon_url(v)
 
+    # SEC-1 (schema-level): blocks only when auto_link=True + email_claim='email' + require_email_verified=False
+    # arrive in the same request. email_claim=None means the request leaves it unchanged (still 'email' by default),
+    # so that is also treated as 'email'. Partial updates spanning two requests are caught by the
+    # Combined-State-Guard in the route handler after the setattr loop.
+    @model_validator(mode="after")
+    def check_auto_link_requires_verified(self) -> "OIDCProviderUpdate":
+        if (
+            self.auto_link_existing_accounts is True
+            and self.require_email_verified is False
+            and (self.email_claim is None or self.email_claim == "email")
+        ):
+            raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
+        return self
+
 
 class OIDCProviderResponse(BaseModel):
     id: int
@@ -415,7 +470,10 @@ class OIDCProviderResponse(BaseModel):
     is_enabled: bool
     auto_create_users: bool
     auto_link_existing_accounts: bool = False
+    email_claim: str = "email"
+    require_email_verified: bool = True
     icon_url: str | None = None
+    default_group_id: int | None = None
 
     class Config:
         from_attributes = True
@@ -435,3 +493,22 @@ class OIDCLinkResponse(BaseModel):
     provider_name: str
     provider_email: str | None = None
     created_at: str
+
+
+class EncryptionRowCounts(BaseModel):
+    oidc_providers: int
+    user_totp: int
+
+
+class EncryptionStatusResponse(BaseModel):
+    key_configured: bool
+    key_source: Literal["env", "file", "generated", "none"]
+    legacy_plaintext_rows: EncryptionRowCounts
+    encrypted_rows: EncryptionRowCounts
+    # B4: filled by the endpoint after a sample-decrypt of one encrypted row,
+    # so a wrong-key state (where key_configured=True but rows decrypt to junk)
+    # is detected, not just the no-key case.
+    decryption_broken: bool = False
+    # B2: number of rows skipped during the last legacy re-encryption migration.
+    # Filled from backend.app.core.database.get_migration_error_count().
+    migration_error_count: int = 0

+ 53 - 26
backend/app/schemas/github_backup.py

@@ -3,7 +3,7 @@
 import re
 from datetime import datetime
 
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, model_validator
 
 from backend.app.core.compat import StrEnum
 
@@ -16,12 +16,22 @@ class ScheduleType(StrEnum):
     WEEKLY = "weekly"
 
 
+class ProviderType(StrEnum):
+    """Git hosting provider types."""
+
+    GITHUB = "github"
+    GITLAB = "gitlab"
+    GITEA = "gitea"
+    FORGEJO = "forgejo"
+
+
 class GitHubBackupConfigCreate(BaseModel):
     """Schema for creating/updating GitHub backup config."""
 
-    repository_url: str = Field(..., min_length=1, max_length=500, description="GitHub repository URL")
+    repository_url: str = Field(..., min_length=1, max_length=500, description="Git repository URL")
     access_token: str = Field(..., min_length=1, description="Personal Access Token")
     branch: str = Field(default="main", max_length=100, description="Branch to push to")
+    provider: ProviderType = Field(default=ProviderType.GITHUB, description="Git hosting provider")
 
     schedule_enabled: bool = Field(default=False, description="Enable scheduled backups")
     schedule_type: ScheduleType = Field(default=ScheduleType.DAILY, description="Schedule frequency")
@@ -32,21 +42,31 @@ class GitHubBackupConfigCreate(BaseModel):
     backup_spools: bool = Field(default=False, description="Backup spool inventory")
     backup_archives: bool = Field(default=False, description="Backup print archive history")
 
+    allow_insecure_http: bool = Field(default=False, description="Allow HTTP (non-TLS) repository URLs")
     enabled: bool = Field(default=True, description="Enable backup feature")
 
-    @field_validator("repository_url")
-    @classmethod
-    def validate_repo_url(cls, v: str) -> str:
-        """Validate GitHub repository URL format."""
-        # Accept various GitHub URL formats
-        patterns = [
-            r"^https://github\.com/[\w.-]+/[\w.-]+(?:\.git)?$",
-            r"^git@github\.com:[\w.-]+/[\w.-]+(?:\.git)?$",
+    @model_validator(mode="after")
+    def validate_repo_url(self) -> "GitHubBackupConfigCreate":
+        url = self.repository_url.strip().rstrip("/")
+        self.repository_url = url
+        https_or_ssh = [
+            r"^https://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$",
+            r"^git@[\w.-]+:[\w.-]+(\/[\w.-]+)+(?:\.git)?$",
         ]
-        v = v.strip().rstrip("/")
-        if not any(re.match(p, v) for p in patterns):
-            raise ValueError("Invalid GitHub repository URL. Expected format: https://github.com/owner/repo")
-        return v
+        http_pattern = r"^http://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$"
+        if any(re.match(p, url) for p in https_or_ssh):
+            return self
+        if re.match(http_pattern, url):
+            if not self.allow_insecure_http:
+                raise ValueError(
+                    "This URL uses HTTP instead of HTTPS. "
+                    "Enable 'Allow insecure HTTP' if your instance does not use TLS."
+                )
+            return self
+        raise ValueError(
+            "Invalid Git repository URL. Expected: https://host/owner/repo, "
+            "http://host/owner/repo (with 'Allow insecure HTTP' enabled), or git@host:owner/repo"
+        )
 
 
 class GitHubBackupConfigUpdate(BaseModel):
@@ -55,6 +75,7 @@ class GitHubBackupConfigUpdate(BaseModel):
     repository_url: str | None = Field(default=None, max_length=500)
     access_token: str | None = Field(default=None)
     branch: str | None = Field(default=None, max_length=100)
+    provider: ProviderType | None = None
 
     schedule_enabled: bool | None = None
     schedule_type: ScheduleType | None = None
@@ -65,21 +86,25 @@ class GitHubBackupConfigUpdate(BaseModel):
     backup_spools: bool | None = None
     backup_archives: bool | None = None
 
+    allow_insecure_http: bool | None = None
     enabled: bool | None = None
 
-    @field_validator("repository_url")
-    @classmethod
-    def validate_repo_url(cls, v: str | None) -> str | None:
-        if v is None:
-            return v
-        patterns = [
-            r"^https://github\.com/[\w.-]+/[\w.-]+(?:\.git)?$",
-            r"^git@github\.com:[\w.-]+/[\w.-]+(?:\.git)?$",
+    @model_validator(mode="after")
+    def validate_repo_url(self) -> "GitHubBackupConfigUpdate":
+        if self.repository_url is None:
+            return self
+        url = self.repository_url.strip().rstrip("/")
+        self.repository_url = url
+        valid_patterns = [
+            r"^https?://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$",
+            r"^git@[\w.-]+:[\w.-]+(\/[\w.-]+)+(?:\.git)?$",
         ]
-        v = v.strip().rstrip("/")
-        if not any(re.match(p, v) for p in patterns):
-            raise ValueError("Invalid GitHub repository URL")
-        return v
+        if not any(re.match(p, url) for p in valid_patterns):
+            raise ValueError(
+                "Invalid repository URL. Expected: https://host/owner/repo, "
+                "http://host/owner/repo, or git@host:owner/repo"
+            )
+        return self
 
 
 class GitHubBackupConfigResponse(BaseModel):
@@ -89,6 +114,8 @@ class GitHubBackupConfigResponse(BaseModel):
     repository_url: str
     has_token: bool = Field(description="Whether an access token is configured")
     branch: str
+    provider: str
+    allow_insecure_http: bool
 
     schedule_enabled: bool
     schedule_type: str

+ 59 - 0
backend/app/schemas/library_trash.py

@@ -0,0 +1,59 @@
+"""Schemas for the library trash bin + bulk purge (#1008)."""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+
+class PurgePreviewRequest(BaseModel):
+    older_than_days: int = Field(ge=1, le=3650, description="Age threshold in days.")
+    include_never_printed: bool = True
+
+
+class PurgePreviewResponse(BaseModel):
+    count: int
+    total_bytes: int
+    sample_filenames: list[str]
+    older_than_days: int
+    include_never_printed: bool
+
+
+class PurgeRequest(BaseModel):
+    older_than_days: int = Field(ge=1, le=3650)
+    include_never_printed: bool = True
+
+
+class PurgeResponse(BaseModel):
+    moved_to_trash: int
+
+
+class TrashFile(BaseModel):
+    id: int
+    filename: str
+    file_size: int
+    thumbnail_path: str | None = None
+    folder_id: int | None = None
+    folder_name: str | None = None
+    created_by_id: int | None = None
+    created_by_username: str | None = None
+    deleted_at: datetime
+    auto_purge_at: datetime
+
+
+class TrashListResponse(BaseModel):
+    items: list[TrashFile]
+    total: int
+    retention_days: int
+
+
+class TrashSettings(BaseModel):
+    retention_days: int = Field(ge=1, le=365)
+    auto_purge_enabled: bool = False
+    auto_purge_days: int = Field(default=90, ge=7, le=3650)
+    auto_purge_include_never_printed: bool = True
+
+
+class EmptyTrashResponse(BaseModel):
+    deleted: int

+ 111 - 0
backend/app/schemas/makerworld.py

@@ -0,0 +1,111 @@
+"""Pydantic schemas for the MakerWorld integration routes."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+
+class MakerWorldResolveRequest(BaseModel):
+    """Body for POST /makerworld/resolve."""
+
+    url: str = Field(..., description="Any MakerWorld model URL (scheme optional)")
+
+
+class MakerWorldResolvedModel(BaseModel):
+    """Structured result of URL resolution.
+
+    ``design`` and ``instances`` are passed through verbatim from MakerWorld's
+    API — we don't re-shape them because the frontend needs access to fields
+    MakerWorld may add over time (badges, license variants, etc.). Keeping
+    them as opaque dicts avoids brittle coupling.
+    """
+
+    model_id: int
+    profile_id: int | None = Field(
+        default=None,
+        description="Specific profile from the URL's #profileId- fragment, if any",
+    )
+    design: dict[str, Any]
+    instances: list[dict[str, Any]]
+    already_imported_library_ids: list[int] = Field(
+        default_factory=list,
+        description="LibraryFile IDs that were previously imported from this model URL",
+    )
+
+
+class MakerWorldImportRequest(BaseModel):
+    """Body for POST /makerworld/import."""
+
+    model_id: int = Field(
+        ...,
+        description="The MakerWorld design ID (the number in /models/{id}).",
+    )
+    profile_id: int | None = Field(
+        default=None,
+        description=(
+            "The profileId of the selected instance (plate configuration). Each "
+            "instance in `/design/{id}/instances` carries a `profileId` field — "
+            "the frontend forwards the picked one here. If omitted, the backend "
+            "falls back to the first available instance of the model."
+        ),
+    )
+    instance_id: int | None = Field(
+        default=None,
+        description="Retained for backwards compatibility; no longer used by the download flow.",
+    )
+    folder_id: int | None = Field(default=None, description="Target library folder; null = root")
+
+
+class MakerWorldRecentImport(BaseModel):
+    """One row in the 'recent MakerWorld imports' list."""
+
+    library_file_id: int
+    filename: str
+    folder_id: int | None
+    thumbnail_path: str | None = Field(
+        default=None,
+        description="Relative path under /api/v1/library/files/{id}/thumbnail — "
+        "the frontend wraps it with a stream token to render.",
+    )
+    source_url: str | None = Field(
+        default=None,
+        description="Canonical MakerWorld URL (``https://makerworld.com/models/{id}"
+        "#profileId-{pid}``). The frontend uses it to build an 'Open on MakerWorld' "
+        "link and to extract model/profile ids without a second API round-trip.",
+    )
+    created_at: str
+
+
+class MakerWorldImportResponse(BaseModel):
+    """Result of a MakerWorld import."""
+
+    library_file_id: int
+    filename: str
+    folder_id: int | None = Field(
+        default=None,
+        description=(
+            "Folder the file was saved to — the auto-created 'MakerWorld' folder "
+            "by default, or whichever folder the caller specified. Surfaced so the "
+            "frontend can deep-link to File Manager → that folder after import."
+        ),
+    )
+    profile_id: int | None = Field(
+        default=None,
+        description=(
+            "The MakerWorld profile (plate) id that was imported. Surfaced so the "
+            "frontend can match the response back to the plate row in the UI and "
+            "render inline 'view in library' / 'open in slicer' controls there."
+        ),
+    )
+    was_existing: bool = Field(
+        description="True if a prior import from the same source URL was reused (no re-download)"
+    )
+
+
+class MakerWorldStatus(BaseModel):
+    """Integration health + auth status surfaced to the frontend."""
+
+    has_cloud_token: bool = Field(description="Whether the caller's account has a stored Bambu Cloud token")
+    can_download: bool = Field(description="Shortcut: has_cloud_token AND it looks valid. Downloads require it.")

+ 8 - 0
backend/app/schemas/notification.py

@@ -212,6 +212,14 @@ class NtfyConfig(BaseModel):
     server: str = Field(default="https://ntfy.sh", description="ntfy server URL")
     topic: str = Field(..., description="Topic name to publish to")
     auth_token: str | None = Field(default=None, description="Optional authentication token")
+    event_priorities: dict[str, int] | None = Field(
+        default=None,
+        description=(
+            "Per-event priority override. Keys are event names (e.g. 'on_print_failed'); "
+            "values are ntfy priorities 1-5 (1=min, 2=low, 3=default, 4=high, 5=urgent). "
+            "Events without an entry use ntfy's server-side default."
+        ),
+    )
 
 
 class PushoverConfig(BaseModel):

+ 25 - 0
backend/app/schemas/printer.py

@@ -18,6 +18,7 @@ class PrinterBase(BaseModel):
     external_camera_url: str | None = None
     external_camera_type: str | None = None  # "mjpeg", "rtsp", "snapshot", "usb"
     external_camera_enabled: bool = False
+    external_camera_snapshot_url: str | None = None  # Optional single-frame override; #1177
     camera_rotation: int = 0  # 0, 90, 180, 270 degrees
 
 
@@ -50,6 +51,7 @@ class PrinterUpdate(BaseModel):
     external_camera_url: str | None = None
     external_camera_type: str | None = None
     external_camera_enabled: bool | None = None
+    external_camera_snapshot_url: str | None = None  # #1177
     camera_rotation: int | None = None  # 0, 90, 180, 270 degrees
     plate_detection_enabled: bool | None = None
     plate_detection_roi: PlateDetectionROI | None = None
@@ -63,6 +65,7 @@ class PrinterResponse(PrinterBase):
     external_camera_url: str | None = None
     external_camera_type: str | None = None
     external_camera_enabled: bool = False
+    external_camera_snapshot_url: str | None = None  # #1177
     camera_rotation: int = 0  # 0, 90, 180, 270 degrees
     plate_detection_enabled: bool = False
     plate_detection_roi: PlateDetectionROI | None = None
@@ -87,6 +90,7 @@ class PrinterResponse(PrinterBase):
             "external_camera_url": printer.external_camera_url,
             "external_camera_type": printer.external_camera_type,
             "external_camera_enabled": printer.external_camera_enabled,
+            "external_camera_snapshot_url": printer.external_camera_snapshot_url,
             "camera_rotation": printer.camera_rotation,
             "is_active": printer.is_active,
             "nozzle_count": printer.nozzle_count,
@@ -179,6 +183,24 @@ class AmsLabelBody(BaseModel):
     ams_serial: str = Field(default="", max_length=50)
 
 
+class FilaSwitchResponse(BaseModel):
+    """Filament Track Switch (FTS) state — accessory that mediates AMS-to-extruder routing.
+
+    When installed, the AMS info field reports bits 8-11 = 0xE (uninitialized)
+    because slots are dynamically routed via the FTS rather than tied to a
+    specific extruder. Frontend uses `installed` to suppress the per-extruder
+    slot filter in the print modal. See #1162.
+    """
+
+    installed: bool = False
+    # in[track] = currently loaded slot for that track (-1 = empty)
+    in_slots: list[int] = []
+    # out[track] = extruder this track terminates at (0 = right, 1 = left)
+    out_extruders: list[int] = []
+    stat: int = 0
+    info: int = 0
+
+
 class PrintOptionsResponse(BaseModel):
     """AI detection and print options from xcam data."""
 
@@ -245,6 +267,9 @@ class PrinterStatus(BaseModel):
     ams_mapping: list[int] = []
     # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
     ams_extruder_map: dict[str, int] = {}
+    # Filament Track Switch (FTS) accessory — when installed, AMS reports
+    # bits 8-11 = 0xE (uninitialized) and routing is dynamic via the FTS. See #1162.
+    fila_switch: FilaSwitchResponse | None = None
     # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
     tray_now: int = 255
     # AMS status for filament change tracking

+ 33 - 1
backend/app/schemas/project.py

@@ -1,6 +1,21 @@
 from datetime import datetime
 
-from pydantic import BaseModel
+from pydantic import BaseModel, field_validator
+
+
+def _validate_project_url(value: str | None) -> str | None:
+    """Reject anything that isn't an http(s) URL — the URL is rendered as a
+    clickable `<a href>` so a `javascript:` / `data:` / `file:` value would be
+    an XSS vector even with React's default escaping (#1155)."""
+    if value is None:
+        return value
+    trimmed = value.strip()
+    if not trimmed:
+        return None
+    lowered = trimmed.lower()
+    if not (lowered.startswith("http://") or lowered.startswith("https://")):
+        raise ValueError("url must start with http:// or https://")
+    return trimmed
 
 
 class ProjectCreate(BaseModel):
@@ -17,6 +32,12 @@ class ProjectCreate(BaseModel):
     priority: str = "normal"
     budget: float | None = None
     parent_id: int | None = None  # For sub-projects
+    url: str | None = None
+
+    @field_validator("url")
+    @classmethod
+    def _check_url(cls, v: str | None) -> str | None:
+        return _validate_project_url(v)
 
 
 class ProjectUpdate(BaseModel):
@@ -34,6 +55,12 @@ class ProjectUpdate(BaseModel):
     priority: str | None = None
     budget: float | None = None
     parent_id: int | None = None
+    url: str | None = None
+
+    @field_validator("url")
+    @classmethod
+    def _check_url(cls, v: str | None) -> str | None:
+        return _validate_project_url(v)
 
 
 class ProjectStats(BaseModel):
@@ -95,6 +122,8 @@ class ProjectResponse(BaseModel):
     created_at: datetime
     updated_at: datetime
     stats: ProjectStats | None = None
+    url: str | None = None
+    cover_image_filename: str | None = None
 
     class Config:
         from_attributes = True
@@ -132,6 +161,9 @@ class ProjectListResponse(BaseModel):
     progress_percent: float | None = None
     # Preview of archives (up to 5)
     archives: list[ArchivePreview] = []
+    # #1155: card-level metadata
+    url: str | None = None
+    cover_image_filename: str | None = None
 
     class Config:
         from_attributes = True

+ 40 - 2
backend/app/schemas/settings.py

@@ -116,11 +116,15 @@ class AppSettings(BaseModel):
         default="immediate",
         description="Mode: 'immediate' (archive now), 'review' (pending review), or 'print_queue' (add to print queue)",
     )
+    virtual_printer_archive_name_source: str = Field(
+        default="metadata",
+        description="Source for the archive's display name on virtual-printer uploads: 'metadata' uses the 3MF's embedded print_name (default, matches Bambu's behavior), 'filename' uses the filename Bambu Studio sent over FTP (lets users rename via the slicer's 'send to printer' dialog).",
+    )
 
     # Dark mode theme settings
-    dark_style: str = Field(default="classic", description="Dark mode style: classic, glow, vibrant")
+    dark_style: str = Field(default="vibrant", description="Dark mode style: classic, glow, vibrant")
     dark_background: str = Field(
-        default="neutral", description="Dark mode background: neutral, warm, cool, oled, slate, forest"
+        default="cool", description="Dark mode background: neutral, warm, cool, oled, slate, forest"
     )
     dark_accent: str = Field(default="green", description="Dark mode accent: green, teal, blue, orange, purple, red")
 
@@ -183,6 +187,28 @@ class AppSettings(BaseModel):
         description="Preferred slicer: 'bambu_studio' or 'orcaslicer'",
     )
 
+    # Slicer dispatch mode: when True, "Slice" actions open the in-app
+    # SliceModal and call the slicer-API sidecar. When False (default), they
+    # hand off to the user's local desktop slicer via URI scheme — preserving
+    # the original Bambuddy behavior for users who don't run a sidecar.
+    use_slicer_api: bool = Field(
+        default=False,
+        description="Use the slicer-API sidecar for slicing instead of the desktop slicer URI scheme",
+    )
+
+    # Slicer-API sidecar base URLs. Per-installation, configured via the
+    # Settings UI (the "Slicer" card). Empty string means "fall back to the
+    # SLICER_API_URL / BAMBU_STUDIO_API_URL env vars" — which themselves
+    # default to the docker-compose ports in core/config.py.
+    orcaslicer_api_url: str = Field(
+        default="",
+        description="OrcaSlicer sidecar URL (e.g. http://localhost:3003). Empty falls back to the SLICER_API_URL env var.",
+    )
+    bambu_studio_api_url: str = Field(
+        default="",
+        description="BambuStudio sidecar URL (e.g. http://localhost:3001). Empty falls back to the BAMBU_STUDIO_API_URL env var.",
+    )
+
     # Prometheus metrics endpoint
     prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
     prometheus_token: str = Field(
@@ -281,6 +307,13 @@ class AppSettings(BaseModel):
         description="JSON array of printer IDs to monitor (empty = all connected printers)",
     )
 
+    # Inventory forecasting
+    forecast_global_lead_time_days: int = Field(
+        default=0,
+        ge=0,
+        description="Global lead time floor (days) used in reorder point calculation for all SKUs",
+    )
+
     # Default sidebar order (admin-set for all users)
     default_sidebar_order: str = Field(
         default="",
@@ -327,6 +360,7 @@ class AppSettingsUpdate(BaseModel):
     virtual_printer_enabled: bool | None = None
     virtual_printer_access_code: str | None = None
     virtual_printer_mode: str | None = None
+    virtual_printer_archive_name_source: str | None = None
     dark_style: str | None = None
     dark_background: str | None = None
     dark_accent: str | None = None
@@ -352,6 +386,9 @@ class AppSettingsUpdate(BaseModel):
     library_disk_warning_gb: float | None = None
     camera_view_mode: str | None = None
     preferred_slicer: str | None = None
+    use_slicer_api: bool | None = None
+    orcaslicer_api_url: str | None = None
+    bambu_studio_api_url: str | None = None
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
@@ -388,6 +425,7 @@ class AppSettingsUpdate(BaseModel):
     obico_poll_interval: int | None = Field(default=None, ge=5, le=120)
     obico_enabled_printers: str | None = None
     default_sidebar_order: str | None = None
+    forecast_global_lead_time_days: int | None = Field(default=None, ge=0)
 
     @field_validator("gcode_snippets")
     @classmethod

+ 188 - 0
backend/app/schemas/slicer.py

@@ -0,0 +1,188 @@
+"""Pydantic schemas for slice requests."""
+
+from typing import Literal
+
+from pydantic import BaseModel, Field, model_validator
+
+
+class PresetRef(BaseModel):
+    """A source-aware reference to a printer / process / filament preset.
+
+    The SliceModal pulls dropdown options from three tiers (cloud / local /
+    standard). At submit time the client sends one of these per slot so the
+    backend knows where to fetch the preset content from at slice time.
+    """
+
+    source: Literal["cloud", "local", "standard"]
+    id: str = Field(..., description=("Cloud setting_id, local DB row id (stringified), or standard preset name."))
+
+
+class SliceBundleSpec(BaseModel):
+    """Per-request reference to a Printer Preset Bundle stored on the slicer
+    sidecar. When SliceRequest.bundle is set, the dispatch skips PresetRef
+    resolution entirely and asks the sidecar to pick its inner JSON triplet
+    by name from the bundle's extracted directory — much faster than
+    re-uploading three profile JSONs every slice and matches the preset
+    triplet the user actually slices with in BambuStudio.
+    """
+
+    bundle_id: str = Field(
+        ...,
+        min_length=1,
+        description="Sidecar-side bundle id from POST /api/v1/slicer/bundles.",
+    )
+    printer_name: str = Field(
+        ...,
+        min_length=1,
+        description="Preset name within the bundle's printer/ directory (with or without the BambuStudio '# ' prefix).",
+    )
+    process_name: str = Field(
+        ...,
+        min_length=1,
+        description="Preset name within the bundle's process/ directory.",
+    )
+    filament_names: list[str] = Field(
+        ...,
+        min_length=1,
+        description="Per-slot filament preset names within the bundle's filament/ directory. Index 0 = slot 1.",
+    )
+
+
+class SliceRequest(BaseModel):
+    """Body for `POST /library/files/{file_id}/slice`.
+
+    Two preset shapes are accepted per slot for backwards-compatibility:
+
+    - **Legacy** — bare integer ``*_preset_id`` fields point into the
+      ``local_presets`` table. Existing clients (and stale browser tabs after
+      a Bambuddy upgrade) keep working unchanged.
+    - **Source-aware** — ``*_preset`` carries an explicit
+      ``{source, id}``. Required for cloud / standard tiers; also accepted
+      (and equivalent) for local presets when the client is on the new modal.
+
+    Exactly one of each pair must be set; the validator normalises legacy
+    integer ids into a ``PresetRef(source='local', id=str(id))`` so the
+    downstream resolver only deals with one shape.
+    """
+
+    # Legacy fields — kept optional so older clients continue to work.
+    printer_preset_id: int | None = Field(
+        default=None,
+        description="DEPRECATED: prefer printer_preset. LocalPreset id with preset_type='printer'.",
+    )
+    process_preset_id: int | None = Field(
+        default=None,
+        description="DEPRECATED: prefer process_preset. LocalPreset id with preset_type='process'.",
+    )
+    filament_preset_id: int | None = Field(
+        default=None,
+        description="DEPRECATED: prefer filament_preset. LocalPreset id with preset_type='filament'.",
+    )
+
+    # Source-aware fields — set by the new SliceModal.
+    printer_preset: PresetRef | None = None
+    process_preset: PresetRef | None = None
+    filament_preset: PresetRef | None = None
+
+    # Multi-color: one PresetRef per AMS slot the source plate uses. Order is
+    # significant — the slicer matches index-by-index against the plate's
+    # filament slots. Always preferred over the legacy singular field; the
+    # validator promotes a singular field into ``[singular]`` when the list
+    # is empty so older clients keep working.
+    filament_presets: list[PresetRef] = Field(default_factory=list)
+
+    # Bundle dispatch alternative — when set, presets above are ignored and
+    # the slicer dispatch picks per-category JSONs from a previously-imported
+    # .bbscfg on the sidecar. Validator below short-circuits the
+    # presets-required check when this is non-None.
+    bundle: SliceBundleSpec | None = Field(
+        default=None,
+        description="When set, slice via a sidecar-side bundle instead of resolved preset refs.",
+    )
+
+    plate: int | None = Field(
+        default=None,
+        ge=1,
+        description="Plate number to slice (1-indexed). Defaults to plate 1 on the sidecar.",
+    )
+    export_3mf: bool = Field(
+        default=False,
+        description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
+    )
+
+    @model_validator(mode="after")
+    def normalise_preset_refs(self) -> "SliceRequest":
+        """Each slot must end up with a `PresetRef` set. Legacy integer ids
+        become `(source='local', id=str(int))` so the route handler only
+        deals with the canonical shape. For filament: a non-empty
+        ``filament_presets`` list satisfies the requirement on its own; an
+        empty list falls back to the singular fields, which then promote
+        into a one-element list.
+
+        When ``bundle`` is set, the dispatch picks the JSON triplet from
+        the sidecar bundle directly so PresetRef resolution is skipped —
+        return early before the presets-required checks below.
+        """
+        if self.bundle is not None:
+            return self
+        for slot, ref_attr, legacy_attr in (
+            ("printer", "printer_preset", "printer_preset_id"),
+            ("process", "process_preset", "process_preset_id"),
+        ):
+            ref = getattr(self, ref_attr)
+            legacy_id = getattr(self, legacy_attr)
+            if ref is None and legacy_id is None:
+                raise ValueError(
+                    f"{slot} preset is required: provide '{ref_attr}' (preferred) or legacy '{legacy_attr}'"
+                )
+            if ref is None:
+                setattr(self, ref_attr, PresetRef(source="local", id=str(legacy_id)))
+
+        # Filament accepts THREE shapes, in priority order:
+        #   1. filament_presets    — multi-color array (new clients)
+        #   2. filament_preset     — source-aware singular (single-color new clients)
+        #   3. filament_preset_id  — legacy bare integer (old clients)
+        # The first non-empty shape wins; missing all three raises.
+        if not self.filament_presets:
+            if self.filament_preset is not None:
+                self.filament_presets = [self.filament_preset]
+            elif self.filament_preset_id is not None:
+                fallback = PresetRef(source="local", id=str(self.filament_preset_id))
+                self.filament_preset = fallback
+                self.filament_presets = [fallback]
+            else:
+                raise ValueError(
+                    "filament preset is required: provide 'filament_presets' (preferred), "
+                    "'filament_preset', or legacy 'filament_preset_id'"
+                )
+        elif self.filament_preset is None:
+            # Multi-color caller: backfill the singular from the first slot
+            # so callers that still read the legacy field see a stable value.
+            self.filament_preset = self.filament_presets[0]
+        return self
+
+
+class SliceResponse(BaseModel):
+    """Response from `POST /library/files/{file_id}/slice`. The result lands
+    in the user's library as a new ``LibraryFile`` (in the same folder as
+    the source)."""
+
+    library_file_id: int
+    name: str
+    print_time_seconds: int
+    filament_used_g: float
+    filament_used_mm: float
+    used_embedded_settings: bool = False
+
+
+class SliceArchiveResponse(BaseModel):
+    """Response from `POST /archives/{archive_id}/slice`. The result lands
+    in the user's archives as a new ``PrintArchive`` row, inheriting
+    printer / project metadata from the source archive."""
+
+    archive_id: int
+    name: str
+    print_time_seconds: int
+    filament_used_g: float
+    filament_used_mm: float
+    used_embedded_settings: bool = False

+ 67 - 0
backend/app/schemas/slicer_presets.py

@@ -0,0 +1,67 @@
+"""Pydantic schemas for the unified slicer-presets endpoint.
+
+The SliceModal pulls printer/process/filament options from three sources, in
+priority order: cloud (the user's Bambu Cloud account), local (DB-backed
+imported profiles), and standard (slicer-bundled stock profiles). The endpoint
+returns all three lists with name-based dedup applied so each preset appears
+exactly once across the response.
+"""
+
+from typing import Literal
+
+from pydantic import BaseModel
+
+CloudStatus = Literal["ok", "not_authenticated", "expired", "unreachable"]
+
+
+class UnifiedPreset(BaseModel):
+    """A single printer/process/filament preset with its source.
+
+    The ``id`` shape varies by source:
+      - cloud  → Bambu Cloud setting_id (e.g. ``"PFUS9ac902733670a9"``)
+      - local  → stringified DB row id from ``local_presets``
+      - standard → preset name as written in the bundled JSON (the slicer
+                   resolves bundled profiles by name during inheritance walk)
+
+    The frontend treats ``id`` as opaque; the slice dispatch path uses
+    ``(source, id)`` to fetch / pass the preset content to the sidecar.
+
+    ``filament_type`` and ``filament_colour`` are populated for the filament
+    slot only — they let the SliceModal pre-pick a preset per plate slot in
+    the multi-color flow by matching against the source 3MF's per-slot type
+    and color. Populated when the underlying preset JSON exposes them; left
+    as ``None`` on bundled profiles where colour is a runtime spool attribute.
+    """
+
+    id: str
+    name: str
+    source: Literal["cloud", "local", "standard"]
+    filament_type: str | None = None
+    filament_colour: str | None = None
+
+
+class UnifiedPresetsBySlot(BaseModel):
+    """Three slots in the order Bambu Studio / OrcaSlicer use."""
+
+    printer: list[UnifiedPreset] = []
+    process: list[UnifiedPreset] = []
+    filament: list[UnifiedPreset] = []
+
+
+class UnifiedPresetsResponse(BaseModel):
+    """Each tier carries only the names that didn't appear in a higher tier.
+
+    Cloud is the highest priority (user's personal customisations win), then
+    the local imports the user explicitly curated, then the slicer's stock
+    fallback. A name that appears in cloud is filtered out of local and
+    standard; a name that appears in local is filtered out of standard.
+
+    ``cloud_status`` lets the frontend show a banner explaining why the cloud
+    tier is empty when the user expected to see it (signed out / token
+    expired / network down).
+    """
+
+    cloud: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
+    local: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
+    standard: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
+    cloud_status: CloudStatus = "ok"

+ 108 - 1
backend/app/schemas/spool.py

@@ -1,6 +1,80 @@
 from datetime import datetime
 
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
+
+# Visual variant applied to a spool's swatch — purely cosmetic, does not
+# affect MQTT/firmware. Kept independent of `subtype` so users can override
+# the rendering hint without touching Bambu's categorical filament label.
+# Mirrors the visual variants the spool form's `KNOWN_VARIANTS` exposes so
+# the catalog and spool form share one vocabulary; structural variants like
+# gradient/dual-color/tri-color/multicolor combine with `extra_colors` for
+# rendering, surface effects (sparkle/wood/marble/glow/matte) layer overlays.
+ALLOWED_EFFECT_TYPES = frozenset(
+    {
+        # Surface effects
+        "sparkle",
+        "wood",
+        "marble",
+        "glow",
+        "matte",
+        # Sheen / finish variants
+        "silk",
+        "galaxy",
+        "rainbow",
+        "metal",
+        "translucent",
+        # Multi-colour structures (drive gradient rendering when paired with extra_colors)
+        "gradient",
+        "dual-color",
+        "tri-color",
+        "multicolor",
+    }
+)
+
+# Cap how many gradient stops we accept on input so a paste of arbitrary text
+# can't blow up the stored value or downstream rendering.
+MAX_EXTRA_COLOR_STOPS = 8
+
+
+def normalize_extra_colors(value: str | None) -> str | None:
+    """Parse comma-separated hex tokens into canonical lowercase form.
+
+    Accepts 6- or 8-char hex per token, with or without leading `#`. Returns
+    None for blank input, raises ValueError for malformed tokens or too many
+    stops. Output is the comma-joined canonical form (no `#`, lowercase).
+    """
+    if value is None:
+        return None
+    raw = value.strip()
+    if not raw:
+        return None
+    tokens = [tok.strip().lstrip("#").lower() for tok in raw.split(",") if tok.strip()]
+    if not tokens:
+        return None
+    if len(tokens) > MAX_EXTRA_COLOR_STOPS:
+        raise ValueError(f"extra_colors accepts at most {MAX_EXTRA_COLOR_STOPS} stops")
+    for tok in tokens:
+        if len(tok) not in (6, 8):
+            raise ValueError(f"extra_colors token '{tok}' must be 6 or 8 hex chars")
+        try:
+            int(tok, 16)
+        except ValueError as exc:
+            raise ValueError(f"extra_colors token '{tok}' is not valid hex") from exc
+    return ",".join(tokens)
+
+
+def normalize_effect_type(value: str | None) -> str | None:
+    if value is None:
+        return None
+    trimmed = value.strip().lower()
+    if not trimmed:
+        return None
+    # Tolerate "Dual Color" / "dual_color" / "dual color" → "dual-color" so
+    # users pasting from spool-subtype labels don't hit a validation wall.
+    canonical = trimmed.replace("_", "-").replace(" ", "-")
+    if canonical not in ALLOWED_EFFECT_TYPES:
+        raise ValueError(f"effect_type must be one of: {sorted(ALLOWED_EFFECT_TYPES)}")
+    return canonical
 
 
 class SpoolBase(BaseModel):
@@ -8,7 +82,20 @@ class SpoolBase(BaseModel):
     subtype: str | None = None
     color_name: str | None = None
     rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
+    extra_colors: str | None = None
+    effect_type: str | None = None
     brand: str | None = None
+
+    @field_validator("extra_colors")
+    @classmethod
+    def _validate_extra_colors(cls, v: str | None) -> str | None:
+        return normalize_extra_colors(v)
+
+    @field_validator("effect_type")
+    @classmethod
+    def _validate_effect_type(cls, v: str | None) -> str | None:
+        return normalize_effect_type(v)
+
     label_weight: int = 1000
     core_weight: int = 250
     core_weight_catalog_id: int | None = None
@@ -26,6 +113,9 @@ class SpoolBase(BaseModel):
     weight_locked: bool = False
     last_scale_weight: int | None = None
     last_weighed_at: datetime | None = None
+    # User-defined category + per-spool low-stock threshold override (#729).
+    category: str | None = Field(default=None, max_length=50)
+    low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
 
 
 class SpoolCreate(SpoolBase):
@@ -42,7 +132,20 @@ class SpoolUpdate(BaseModel):
     subtype: str | None = None
     color_name: str | None = None
     rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
+    extra_colors: str | None = None
+    effect_type: str | None = None
     brand: str | None = None
+
+    @field_validator("extra_colors")
+    @classmethod
+    def _validate_extra_colors(cls, v: str | None) -> str | None:
+        return normalize_extra_colors(v)
+
+    @field_validator("effect_type")
+    @classmethod
+    def _validate_effect_type(cls, v: str | None) -> str | None:
+        return normalize_effect_type(v)
+
     label_weight: int | None = None
     core_weight: int | None = None
     core_weight_catalog_id: int | None = None
@@ -58,6 +161,9 @@ class SpoolUpdate(BaseModel):
     tag_type: str | None = None
     cost_per_kg: float | None = Field(default=None, ge=0)
     weight_locked: bool | None = None
+    # User-defined category + per-spool low-stock threshold override (#729).
+    category: str | None = Field(default=None, max_length=50)
+    low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
 
 
 class SpoolKProfileBase(BaseModel):
@@ -122,6 +228,7 @@ class SpoolAssignmentResponse(BaseModel):
     created_at: datetime
     spool: SpoolResponse | None = None
     configured: bool = False
+    pending_config: bool = False  # True when slot was empty at assign time; will configure on insert
     ams_label: str | None = None  # User-defined friendly name for the AMS unit
 
     class Config:

+ 49 - 32
backend/app/schemas/spoolbuddy.py

@@ -1,6 +1,8 @@
+import json
 from datetime import datetime
+from typing import Literal
 
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
 
 # --- Device schemas ---
 
@@ -9,14 +11,14 @@ class DeviceRegisterRequest(BaseModel):
     device_id: str = Field(..., min_length=1, max_length=50)
     hostname: str = Field(..., min_length=1, max_length=100)
     ip_address: str = Field(..., min_length=1, max_length=45)
-    firmware_version: str | None = None
+    firmware_version: str | None = Field(None, max_length=20)
     has_nfc: bool = True
     has_scale: bool = True
     tare_offset: int = 0
     calibration_factor: float = 1.0
-    nfc_reader_type: str | None = None
-    nfc_connection: str | None = None
-    backend_url: str | None = None
+    nfc_reader_type: str | None = Field(None, max_length=20)
+    nfc_connection: str | None = Field(None, max_length=20)
+    backend_url: str | None = Field(None, max_length=255)
     has_backlight: bool = False
 
 
@@ -58,13 +60,20 @@ class HeartbeatRequest(BaseModel):
     nfc_ok: bool = False
     scale_ok: bool = False
     uptime_s: int = 0
-    firmware_version: str | None = None
-    ip_address: str | None = None
-    nfc_reader_type: str | None = None
-    nfc_connection: str | None = None
-    backend_url: str | None = None
+    firmware_version: str | None = Field(None, max_length=20)
+    ip_address: str | None = Field(None, max_length=45)
+    nfc_reader_type: str | None = Field(None, max_length=20)
+    nfc_connection: str | None = Field(None, max_length=20)
+    backend_url: str | None = Field(None, max_length=255)
     system_stats: dict | None = None
 
+    @field_validator("system_stats")
+    @classmethod
+    def _limit_system_stats_size(cls, v: dict | None) -> dict | None:
+        if v is not None and len(json.dumps(v)) > 4096:
+            raise ValueError("system_stats must not exceed 4096 bytes when JSON-encoded")
+        return v
+
 
 class HeartbeatResponse(BaseModel):
     pending_command: str | None = None
@@ -74,38 +83,39 @@ class HeartbeatResponse(BaseModel):
     calibration_factor: float
     display_brightness: int = 100
     display_blank_timeout: int = 0
+    ssh_public_key: str | None = None
 
 
 # --- NFC schemas ---
 
 
 class TagScannedRequest(BaseModel):
-    device_id: str
-    tag_uid: str
-    tray_uuid: str | None = None
+    device_id: str = Field(..., max_length=50)
+    tag_uid: str = Field(..., max_length=32)
+    tray_uuid: str | None = Field(None, max_length=32, pattern=r"^[0-9A-Fa-f]*$")
     sak: int | None = None
-    tag_type: str | None = None
+    tag_type: str | None = Field(None, max_length=50)
     raw_blocks: dict | None = None
 
 
 class TagRemovedRequest(BaseModel):
-    device_id: str
-    tag_uid: str
+    device_id: str = Field(..., max_length=50)
+    tag_uid: str = Field(..., max_length=32)
 
 
 # --- Scale schemas ---
 
 
 class ScaleReadingRequest(BaseModel):
-    device_id: str
-    weight_grams: float
+    device_id: str = Field(..., max_length=50)
+    weight_grams: float = Field(..., allow_inf_nan=False)
     stable: bool = False
     raw_adc: int | None = None
 
 
 class UpdateSpoolWeightRequest(BaseModel):
-    spool_id: int
-    weight_grams: float
+    spool_id: int = Field(..., gt=0)
+    weight_grams: float = Field(..., allow_inf_nan=False, ge=0.0, le=100_000.0)
 
 
 # --- Calibration schemas ---
@@ -130,16 +140,16 @@ class CalibrationResponse(BaseModel):
 
 
 class WriteTagRequest(BaseModel):
-    device_id: str
-    spool_id: int
+    device_id: str = Field(..., max_length=50)
+    spool_id: int = Field(..., gt=0)
 
 
 class WriteTagResultRequest(BaseModel):
-    device_id: str
-    spool_id: int
-    tag_uid: str
+    device_id: str = Field(..., max_length=50)
+    spool_id: int = Field(..., gt=0)
+    tag_uid: str = Field(..., min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
     success: bool
-    message: str | None = None
+    message: str | None = Field(None, max_length=500)
 
 
 class DisplaySettingsRequest(BaseModel):
@@ -153,20 +163,27 @@ class SystemConfigRequest(BaseModel):
 
 
 class SystemCommandRequest(BaseModel):
-    command: str = Field(..., description="System command: reboot, shutdown, restart_daemon, restart_browser")
+    command: str = Field(
+        ..., max_length=50, description="System command: reboot, shutdown, restart_daemon, restart_browser"
+    )
 
 
 class SystemCommandResultRequest(BaseModel):
-    command: str
+    command: str = Field(..., max_length=50)
     success: bool
-    message: str | None = None
+    message: str | None = Field(None, max_length=500)
+
+
+class UpdateStatusRequest(BaseModel):
+    status: Literal["updating", "complete", "error"]
+    message: str | None = Field(None, max_length=255)
 
 
 # --- Diagnostics schemas ---
 
 
 class DiagnosticResultRequest(BaseModel):
-    diagnostic: str  # 'nfc', 'scale', or 'read_tag'
+    diagnostic: str = Field(..., max_length=50, description="Diagnostic type: 'nfc', 'scale', or 'read_tag'")
     success: bool
-    output: str
-    exit_code: int
+    output: str = Field(..., max_length=10_000)
+    exit_code: int = Field(..., ge=-255, le=255)

+ 30 - 0
backend/app/schemas/spoolman.py

@@ -0,0 +1,30 @@
+from pydantic import BaseModel, Field, model_validator
+
+
+class SpoolmanFilamentPatch(BaseModel):
+    name: str | None = Field(None, min_length=1, max_length=200)
+    spool_weight: float | None = Field(None, ge=0.0, le=10_000.0)
+    keep_existing_spools: bool = False
+
+    @model_validator(mode="after")
+    def keep_existing_requires_weight(self) -> "SpoolmanFilamentPatch":
+        if self.keep_existing_spools and self.spool_weight is None:
+            raise ValueError("keep_existing_spools=True requires spool_weight to be provided")
+        return self
+
+
+class SpoolmanSlotAssignmentEnriched(BaseModel):
+    """Slot assignment row enriched with printer name and AMS label.
+
+    ``printer_name`` is null only in the cascade-deleted edge case where the
+    Printer relation has been removed. ``ams_label`` is null when no
+    ``ams_labels`` row matches the slot's MQTT serial (or the synthetic
+    ``f"p{printer_id}a{ams_id}"`` fallback key).
+    """
+
+    printer_id: int
+    printer_name: str | None
+    ams_id: int
+    tray_id: int
+    spoolman_spool_id: int
+    ams_label: str | None

+ 95 - 2
backend/app/services/archive.py

@@ -41,6 +41,83 @@ def _copy_and_fsync(src: Path, dst: Path, chunk_size: int = 1024 * 1024) -> None
     shutil.copystat(src, dst)
 
 
+def resolve_display_stem(filename: str) -> str:
+    """Return a clean human-readable stem from a 3MF/gcode filename.
+
+    Bambu Studio's "Send to printer" dialog typically writes files like
+    ``Plate_1.gcode.3mf`` (a sliced gcode payload wrapped in a 3MF container).
+    The naive ``Path(filename).stem`` only drops the last suffix, leaving
+    ``Plate_1.gcode`` — which then surfaces in the archive UI as a confusing
+    ``Plate_1.gcode`` rather than ``Plate_1`` (#1152 follow-up).
+
+    Strip the recognised print-format suffixes in order:
+
+    - ``.gcode.3mf`` → bare stem (Bambu Studio FTP send)
+    - ``.3mf``       → bare stem
+    - ``.gcode``     → bare stem (rare standalone gcode upload)
+
+    Anything else passes through unchanged.
+    """
+    name = Path(filename).name  # drop any path components
+    lower = name.lower()
+    for suffix in (".gcode.3mf", ".3mf", ".gcode"):
+        if lower.endswith(suffix):
+            return name[: -len(suffix)]
+    return Path(name).stem
+
+
+def peek_plate_index_in_3mf(file_path: Path) -> int | None:
+    """Return the plate index recorded inside a Bambu 3MF, or None.
+
+    Reads only ``Metadata/slice_info.config`` to keep this cheap — used by
+    the print-start callback to verify that the 3MF we just downloaded over
+    FTP actually matches the plate the printer is running (#1204). The full
+    ThreeMFParser does much more work and runs later inside ArchiveService.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return None
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+            plate = root.find(".//plate")
+            if plate is None:
+                return None
+            for meta in plate.findall("metadata"):
+                if meta.get("key") == "index":
+                    value = meta.get("value")
+                    if value:
+                        try:
+                            return int(value)
+                        except ValueError:
+                            return None
+    except Exception:
+        return None
+    return None
+
+
+_PLATE_SUFFIX_RE = re.compile(r"^(.*?)(\s*-\s*Plate\s+|_plate_)(\d+)$", re.IGNORECASE)
+
+
+def swap_plate_suffix(name: str | None, target_plate: int) -> str | None:
+    """Return ``name`` with its trailing plate number replaced, or None.
+
+    Bambu Studio names multi-plate uploads ``"<Project> - Plate <N>"`` (and
+    a lowercase ``"_plate_<N>"`` variant exists too — see
+    test_print_start_expected_promotion). When MQTT subtask_name lags
+    across consecutive plates of the same model (#1204) the suffix points
+    at the previous plate; swapping it gives us the correct upload to
+    re-fetch from FTP. Returns None if no recognised suffix is present.
+    """
+    if not name:
+        return None
+    m = _PLATE_SUFFIX_RE.match(name)
+    if not m:
+        return None
+    base, separator, _ = m.groups()
+    return f"{base}{separator}{target_plate}"
+
+
 class ThreeMFParser:
     """Parser for Bambu Lab 3MF files."""
 
@@ -133,6 +210,8 @@ class ThreeMFParser:
                             self.metadata["print_time_seconds"] = int(value)
                         elif key == "weight" and value:
                             self.metadata["filament_used_grams"] = float(value)
+                        elif key == "curr_bed_type" and value:
+                            self.metadata["bed_type"] = value
 
                     # Extract printable objects for skip object functionality
                     # Objects are stored as <object identify_id="123" name="Part1" skipped="false" />
@@ -334,6 +413,13 @@ class ThreeMFParser:
                 from backend.app.utils.printer_models import normalize_printer_model
 
                 self.metadata["sliced_for_model"] = normalize_printer_model(data["printer_model"])
+
+            # Build plate type — only set from project_settings if slice_info didn't already
+            # provide it (slice_info is more authoritative as it reflects the exported plate).
+            if "bed_type" not in self.metadata and "curr_bed_type" in data:
+                val = data["curr_bed_type"]
+                if isinstance(val, str) and val.strip():
+                    self.metadata["bed_type"] = val.strip()
         except Exception:
             pass  # Print settings are optional; missing values are left unset
 
@@ -886,6 +972,7 @@ class ArchiveService:
         original_filename: str | None = None,
         project_id: int | None = None,
         subtask_id: str | None = None,
+        prefer_filename_for_name: bool = False,
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
 
@@ -901,6 +988,11 @@ class ArchiveService:
             subtask_id: MQTT-provided task identifier (optional). Used to match an
                 existing archive across a backend restart mid-print so the
                 original row can be resumed instead of cancelled (#972).
+            prefer_filename_for_name: When True, use the uploaded filename stem as the
+                archive's display name even if the 3MF embeds a `print_name` in its
+                metadata. Used by virtual-printer flows so users who rename a job in
+                BambuStudio's "send to printer" dialog see that name instead of the
+                creator-baked title (#1152).
         """
         # Verify printer exists if specified
         if printer_id is not None:
@@ -911,7 +1003,7 @@ class ArchiveService:
 
         # Create archive directory structure
         timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-        display_stem = Path(original_filename).stem if original_filename else source_file.stem
+        display_stem = resolve_display_stem(original_filename if original_filename else source_file.name)
         archive_name = f"{timestamp}_{display_stem}"
         # Use "unassigned" folder for archives without a printer
         printer_folder = str(printer_id) if printer_id is not None else "unassigned"
@@ -1028,7 +1120,7 @@ class ArchiveService:
             file_size=dest_file.stat().st_size,
             content_hash=content_hash,
             thumbnail_path=thumbnail_path,
-            print_name=metadata.get("print_name") or display_stem,
+            print_name=display_stem if prefer_filename_for_name else (metadata.get("print_name") or display_stem),
             print_time_seconds=metadata.get("print_time_seconds"),
             filament_used_grams=metadata.get("filament_used_grams"),
             filament_type=metadata.get("filament_type"),
@@ -1037,6 +1129,7 @@ class ArchiveService:
             total_layers=metadata.get("total_layers"),
             nozzle_diameter=metadata.get("nozzle_diameter"),
             bed_temperature=metadata.get("bed_temperature"),
+            bed_type=metadata.get("bed_type"),
             nozzle_temperature=metadata.get("nozzle_temperature"),
             sliced_for_model=metadata.get("sliced_for_model"),
             makerworld_url=metadata.get("makerworld_url"),

+ 233 - 0
backend/app/services/archive_purge.py

@@ -0,0 +1,233 @@
+"""Archive auto-purge service (#1008 follow-up).
+
+Age-based hard-delete of print archives. Unlike the library trash flow there is
+no soft-delete intermediate — archives are historical print records, so the
+"undo" window the library bin provides doesn't apply here. A user who wants to
+keep an archive should download or favourite it before the purge window elapses.
+
+The sweeper runs on the same 15-minute cadence as the library trash sweeper but
+throttles actual purge runs to once per 24h. Admins can also trigger a manual
+purge from the Settings UI.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core import database as _database
+from backend.app.models.archive import PrintArchive
+from backend.app.models.settings import Settings
+from backend.app.services.archive import ArchiveService
+
+logger = logging.getLogger(__name__)
+
+AUTO_PURGE_ENABLED_KEY = "archive_auto_purge_enabled"
+AUTO_PURGE_DAYS_KEY = "archive_auto_purge_days"
+AUTO_PURGE_LAST_RUN_KEY = "archive_auto_purge_last_run"
+
+DEFAULT_AUTO_PURGE_DAYS = 365
+# 7-day floor mirrors the library auto-purge; anything shorter treats archives
+# as ephemeral which is rarely what anyone wants.
+MIN_AUTO_PURGE_DAYS = 7
+MAX_AUTO_PURGE_DAYS = 3650
+
+
+def _age_cutoff(now: datetime, older_than_days: int) -> datetime:
+    return now - timedelta(days=older_than_days)
+
+
+def _last_activity_expr():
+    """Most-recent timestamp on an archive row.
+
+    Reprints reuse the archive row and update ``completed_at``/``started_at`` but
+    leave ``created_at`` pinned to the first print, so purging on ``created_at``
+    would evict recently-reprinted archives. Use the latest of the three instead.
+    """
+    return func.coalesce(
+        PrintArchive.completed_at,
+        PrintArchive.started_at,
+        PrintArchive.created_at,
+    )
+
+
+class ArchivePurgeService:
+    """Manages archive auto-purge sweeper + admin-triggered manual purges."""
+
+    def __init__(self):
+        self._scheduler_task: asyncio.Task | None = None
+        # Match library trash cadence — the 24h throttle keeps actual work rare.
+        self._check_interval = 900
+
+    async def start_scheduler(self):
+        if self._scheduler_task is not None:
+            return
+        logger.info("Starting archive auto-purge sweeper")
+        self._scheduler_task = asyncio.create_task(self._scheduler_loop())
+
+    def stop_scheduler(self):
+        if self._scheduler_task:
+            self._scheduler_task.cancel()
+            self._scheduler_task = None
+            logger.info("Stopped archive auto-purge sweeper")
+
+    async def _scheduler_loop(self):
+        while True:
+            try:
+                await asyncio.sleep(self._check_interval)
+                async with _database.async_session() as db:
+                    await self._maybe_run_auto_purge(db)
+            except asyncio.CancelledError:
+                break
+            except Exception as e:  # pragma: no cover - defensive
+                logger.error("Error in archive auto-purge sweeper: %s", e)
+                await asyncio.sleep(60)
+
+    # ---- Settings -----------------------------------------------------
+
+    @staticmethod
+    async def _read_setting(db: AsyncSession, key: str) -> str | None:
+        result = await db.execute(select(Settings.value).where(Settings.key == key))
+        return result.scalar_one_or_none()
+
+    @staticmethod
+    async def _write_setting(db: AsyncSession, key: str, value: str) -> None:
+        result = await db.execute(select(Settings).where(Settings.key == key))
+        row = result.scalar_one_or_none()
+        if row is None:
+            db.add(Settings(key=key, value=value))
+        else:
+            row.value = value
+
+    async def get_settings(self, db: AsyncSession) -> dict:
+        """Return ``{enabled, days}``. Missing keys default to disabled / 365d."""
+        enabled_raw = await self._read_setting(db, AUTO_PURGE_ENABLED_KEY)
+        days_raw = await self._read_setting(db, AUTO_PURGE_DAYS_KEY)
+
+        enabled = (enabled_raw or "false").lower() == "true"
+        try:
+            days = int(days_raw) if days_raw is not None else DEFAULT_AUTO_PURGE_DAYS
+        except (TypeError, ValueError):
+            days = DEFAULT_AUTO_PURGE_DAYS
+        days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, days))
+        return {"enabled": enabled, "days": days}
+
+    async def set_settings(self, db: AsyncSession, *, enabled: bool, days: int) -> dict:
+        clamped_days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, int(days)))
+        await self._write_setting(db, AUTO_PURGE_ENABLED_KEY, "true" if enabled else "false")
+        await self._write_setting(db, AUTO_PURGE_DAYS_KEY, str(clamped_days))
+        await db.commit()
+        return {"enabled": enabled, "days": clamped_days}
+
+    async def _get_last_run(self, db: AsyncSession) -> datetime | None:
+        raw = await self._read_setting(db, AUTO_PURGE_LAST_RUN_KEY)
+        if not raw:
+            return None
+        try:
+            return datetime.fromisoformat(raw.replace("Z", "+00:00"))
+        except ValueError:
+            return None
+
+    async def _stamp_last_run(self, db: AsyncSession, when: datetime) -> None:
+        await self._write_setting(db, AUTO_PURGE_LAST_RUN_KEY, when.isoformat())
+        await db.commit()
+
+    async def _maybe_run_auto_purge(self, db: AsyncSession) -> int:
+        """Run the auto-purge if enabled and >=24h has elapsed since last run."""
+        cfg = await self.get_settings(db)
+        if not cfg["enabled"]:
+            return 0
+
+        now = datetime.now(timezone.utc)
+        last = await self._get_last_run(db)
+        if last is not None and (now - last) < timedelta(hours=24):
+            return 0
+
+        deleted = await self.purge_older_than(db, older_than_days=cfg["days"])
+        await self._stamp_last_run(db, now)
+        if deleted:
+            logger.info(
+                "Archive auto-purge: hard-deleted %d archive(s) (threshold=%d days)",
+                deleted,
+                cfg["days"],
+            )
+        return deleted
+
+    # ---- Preview / purge ---------------------------------------------
+
+    async def preview_purge(
+        self,
+        db: AsyncSession,
+        older_than_days: int,
+        sample_limit: int = 5,
+    ) -> dict:
+        """Count + size of archives eligible for purge. Read-only."""
+        if older_than_days < 1:
+            return {
+                "count": 0,
+                "total_bytes": 0,
+                "sample_filenames": [],
+                "older_than_days": older_than_days,
+            }
+        now = datetime.now(timezone.utc)
+        cutoff = _age_cutoff(now, older_than_days)
+        last_activity = _last_activity_expr()
+        clause = last_activity < cutoff
+
+        count_result = await db.execute(select(func.count(PrintArchive.id)).where(clause))
+        count = int(count_result.scalar() or 0)
+
+        size_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.file_size), 0)).where(clause))
+        total_bytes = int(size_result.scalar() or 0)
+
+        sample_result = await db.execute(
+            select(PrintArchive.filename).where(clause).order_by(last_activity).limit(sample_limit)
+        )
+        samples = [row[0] for row in sample_result.all()]
+
+        return {
+            "count": count,
+            "total_bytes": total_bytes,
+            "sample_filenames": samples,
+            "older_than_days": older_than_days,
+        }
+
+    async def purge_older_than(self, db: AsyncSession, older_than_days: int) -> int:
+        """Hard-delete archives older than ``older_than_days``. Returns count.
+
+        Delegates to :meth:`ArchiveService.delete_archive` for every row so the
+        on-disk cleanup (3MF, thumbnail, timelapse, photos) goes through the
+        same safety-checked path as manual deletion. Each delete runs in its
+        own session so a commit-per-row doesn't churn the caller's session
+        (and matches how the sweeper uses :func:`_database.async_session` in production).
+        """
+        if older_than_days < 1:
+            return 0
+        now = datetime.now(timezone.utc)
+        cutoff = _age_cutoff(now, older_than_days)
+
+        id_result = await db.execute(select(PrintArchive.id).where(_last_activity_expr() < cutoff))
+        ids = [row[0] for row in id_result.all()]
+        if not ids:
+            return 0
+
+        deleted = 0
+        for archive_id in ids:
+            async with _database.async_session() as delete_db:
+                service = ArchiveService(delete_db)
+                if await service.delete_archive(archive_id):
+                    deleted += 1
+        if deleted:
+            logger.info(
+                "Archive purge: hard-deleted %d archive(s) (older_than_days=%d)",
+                deleted,
+                older_than_days,
+            )
+        return deleted
+
+
+archive_purge_service = ArchivePurgeService()

+ 122 - 20
backend/app/services/background_dispatch.py

@@ -25,6 +25,7 @@ from backend.app.models.library import LibraryFile
 from backend.app.models.printer import Printer
 from backend.app.services.archive import ArchiveService
 from backend.app.services.bambu_ftp import (
+    cache_3mf_download,
     delete_file_async,
     get_ftp_retry_settings,
     upload_file_async,
@@ -684,9 +685,42 @@ class BackgroundDispatchService:
                     )
                     raise RuntimeError("Failed to start print")
 
-                pre_state = getattr(printer_manager.get_status(job.printer_id), "state", None)
+                # Register the archive's local 3MF in the cover-cache so the
+                # /cover endpoint can skip FTP — we already have the file on
+                # disk, no need to refetch 36 MB from a printer whose FTP is
+                # busy serving the active print (#1166 follow-up).
+                cache_3mf_download(job.printer_id, remote_filename, file_path)
+
+                # Wait for the printer to actually pick up the command before
+                # marking the dispatch job complete (#1042). MQTT-publish success
+                # only proves the command queued locally; the printer can still
+                # reject it (HMS error pending, half-broken session, SD card
+                # missing) and never transition. Until #1042 this watchdog was
+                # fire-and-forget — the job was reported successful and the
+                # user had no signal that the print never started. The uploaded
+                # file is intentionally left on the printer's SD card on
+                # timeout: the next dispatch will overwrite it via the existing
+                # delete-then-upload step, and the printer may still be in the
+                # middle of reading it if it picked up just past the timeout.
+                pre_status = printer_manager.get_status(job.printer_id)
+                pre_state = getattr(pre_status, "state", None) if pre_status else None
+                pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
+                pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
                 if pre_state:
-                    asyncio.create_task(self._verify_print_response(job.printer_id, printer_name, pre_state))
+                    await self._set_active_message(job, f"Waiting for {printer_name} to acknowledge print...")
+                    transitioned = await self._verify_print_response(
+                        job.printer_id,
+                        printer_name,
+                        pre_state,
+                        pre_subtask_id=pre_subtask_id,
+                        pre_gcode_file=pre_gcode_file,
+                    )
+                    if not transitioned:
+                        raise RuntimeError(
+                            f"Printer did not acknowledge print command — state still {pre_state}. "
+                            f"Check the printer for a pending error (HMS code, plate-clear prompt, "
+                            f"SD card) and try again."
+                        )
 
                 if job.requested_by_user_id and job.requested_by_username:
                     printer_manager.set_current_print_user(
@@ -702,7 +736,7 @@ class BackgroundDispatchService:
         from backend.app.main import register_expected_print
 
         async with async_session() as db:
-            lib_file = await db.scalar(select(LibraryFile).where(LibraryFile.id == job.source_id))
+            lib_file = await db.scalar(LibraryFile.active().where(LibraryFile.id == job.source_id))
             if not lib_file:
                 raise RuntimeError("File not found")
 
@@ -857,9 +891,34 @@ class BackgroundDispatchService:
                     await db.rollback()
                     raise RuntimeError("Failed to start print")
 
-                pre_state = getattr(printer_manager.get_status(job.printer_id), "state", None)
+                # Same as the archive path: register the library file's local
+                # 3MF in the cover-cache so /cover skips FTP (#1166 follow-up).
+                cache_3mf_download(job.printer_id, remote_filename, file_path)
+
+                # See _run_reprint_archive for rationale (#1042). On timeout
+                # also rolls back the freshly-created archive so the library
+                # flow doesn't leave behind a phantom row for a print that
+                # never started.
+                pre_status = printer_manager.get_status(job.printer_id)
+                pre_state = getattr(pre_status, "state", None) if pre_status else None
+                pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
+                pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
                 if pre_state:
-                    asyncio.create_task(self._verify_print_response(job.printer_id, printer_name, pre_state))
+                    await self._set_active_message(job, f"Waiting for {printer_name} to acknowledge print...")
+                    transitioned = await self._verify_print_response(
+                        job.printer_id,
+                        printer_name,
+                        pre_state,
+                        pre_subtask_id=pre_subtask_id,
+                        pre_gcode_file=pre_gcode_file,
+                    )
+                    if not transitioned:
+                        await db.rollback()
+                        raise RuntimeError(
+                            f"Printer did not acknowledge print command — state still {pre_state}. "
+                            f"Check the printer for a pending error (HMS code, plate-clear prompt, "
+                            f"SD card) and try again."
+                        )
 
                 if job.requested_by_user_id and job.requested_by_username:
                     printer_manager.set_current_print_user(
@@ -899,38 +958,81 @@ class BackgroundDispatchService:
         printer_id: int,
         printer_name: str,
         pre_state: str,
-        timeout: float = 15.0,
+        pre_subtask_id: str | None = None,
+        pre_gcode_file: str | None = None,
+        timeout: float = 90.0,
         poll_interval: float = 3.0,
-    ):
-        """Check if the printer responded to a print command.
-
-        Runs as a fire-and-forget background task after start_print() succeeds.
-        If the printer's gcode_state hasn't changed within the timeout, logs a
-        warning for diagnostics (visible in support packages).
+    ) -> bool:
+        """Wait for the printer to acknowledge a print command.
+
+        Returns True if the printer transitioned (state advanced past pre_state
+        or subtask_id advanced past pre_subtask_id). Returns False on timeout —
+        in that case logs a warning and forces an MQTT reconnect, mirroring the
+        queue-side watchdog (`_watchdog_print_start`). Caller is responsible
+        for surfacing the False result to the user (typically by raising so the
+        dispatch job is marked failed).
+
+        Both transition signals are checked because H2D can sit at FINISH for
+        ~50 s after accepting `project_file` before flipping to PREPARE; the
+        printer echoes our per-dispatch identity back as `subtask_id` on
+        `push_status` first, so a subtask_id change is a definitive "command
+        landed" signal even while state is still FINISH (#1078).
         """
         deadline = time.monotonic() + timeout
+        last_status = None
         while time.monotonic() < deadline:
             await asyncio.sleep(poll_interval)
             state = printer_manager.get_status(printer_id)
             if not state:
-                return  # Printer disconnected
+                # Printer momentarily not reporting — could be a brief MQTT
+                # disconnect mid-window. Keep polling rather than declaring
+                # failure on the first missed tick; the printer may reconnect
+                # within the remaining timeout and still surface a transition.
+                continue
+            last_status = state
             if state.state != pre_state:
-                return  # Printer responded
+                return True
+            if pre_subtask_id is not None and state.subtask_id is not None and state.subtask_id != pre_subtask_id:
+                return True
         logger.warning(
-            "Printer %s (%d) did not respond to print command within %.0fs (state still %s) — printer may need restart",
+            "Printer %s (%d) did not respond to print command within %.0fs "
+            "(state still %s, subtask_id still %s) — printer may need restart",
             printer_name,
             printer_id,
             timeout,
             pre_state,
+            pre_subtask_id,
         )
-        # Strong signal the MQTT session is half-broken (#887, #936): telemetry
-        # still arrives but our publishes don't reach the printer. Force a fresh
-        # session so the next dispatch can land without a power cycle.
+        # Distinguish #1150 (slow parse) from #887/#936 (half-broken session)
+        # via gcode_file: if the printer is now showing a different file than
+        # before dispatch, the project_file command landed and the printer is
+        # parsing — a forced reconnect mid-parse causes 0500_4003. If
+        # gcode_file is unchanged, the publish was silently swallowed and the
+        # original #936 recovery (force_reconnect → fresh client_id) is what
+        # we want. Caveat: in the rare retry-same-file-after-timeout case the
+        # printer's gcode_file looks identical before and after the publish
+        # lands, so a slow parse on retry-same-file still falls through to the
+        # reconnect (and the original 0500_4003) — accepted to avoid breaking
+        # the half-broken-session recovery path.
         client = printer_manager.get_client(printer_id)
-        if client:
+        current_gcode_file = getattr(last_status, "gcode_file", None) if last_status else None
+        publish_landed = current_gcode_file is not None and current_gcode_file != pre_gcode_file
+        if publish_landed:
+            logger.warning(
+                "Printer %s (%d) gcode_file changed to %r (was %r) — printer "
+                "received the command and is parsing slowly. Skipping forced "
+                "MQTT reconnect to avoid 0500_4003 mid-parse (#1150).",
+                printer_name,
+                printer_id,
+                current_gcode_file,
+                pre_gcode_file,
+            )
+        elif client and hasattr(client, "force_reconnect_stale_session"):
             client.force_reconnect_stale_session(
-                f"print command unacknowledged after {timeout:.0f}s (state still {pre_state})"
+                f"print command unacknowledged after {timeout:.0f}s "
+                f"(state still {pre_state}, gcode_file {current_gcode_file!r})"
             )
+        return False
 
     @staticmethod
     async def _cleanup_sd_card_file(

+ 23 - 5
backend/app/services/bambu_ftp.py

@@ -670,14 +670,32 @@ def clear_3mf_cache(printer_id: int | None = None, delete_files: bool = True) ->
     When ``delete_files`` is True (default) the on-disk 3MF is removed as well
     — called from on_print_complete so temp files don't accumulate across
     prints. Tests that want to inspect the cache contents disable this.
+
+    Only paths inside ``archive_dir/temp`` are unlinked. The dispatch sites
+    added in #1166 also cache the live archive copy and library file bytes
+    so /cover can skip FTP — those are *user data*, never the cache's to
+    delete. Pre-fix this branch silently removed archive 3mfs on every print
+    completion (#1212 + private reports of "file disappeared overnight").
     """
+    from backend.app.core.config import settings as _config_settings
+
+    temp_root = _config_settings.archive_dir / "temp"
+
+    def _is_temp_path(path: Path) -> bool:
+        try:
+            return path.is_relative_to(temp_root)
+        except (OSError, ValueError):
+            return False
 
     def _maybe_unlink(path: Path) -> None:
-        if delete_files and path.exists():
-            try:
-                path.unlink()
-            except OSError as exc:
-                logger.debug("3MF cache cleanup skipped %s: %s", path, exc)
+        if not delete_files or not path.exists():
+            return
+        if not _is_temp_path(path):
+            return
+        try:
+            path.unlink()
+        except OSError as exc:
+            logger.debug("3MF cache cleanup skipped %s: %s", path, exc)
 
     if printer_id is None:
         for path in list(_threemf_path_cache.values()):

+ 315 - 58
backend/app/services/bambu_mqtt.py

@@ -52,6 +52,19 @@ class HMSError:
     message: str = ""
 
 
+# HMS short codes the firmware emits during normal user-cancel sequences.
+# These aren't faults — they're status echoes that confirm the cancel happened.
+# Filtering them at parse-time keeps them out of state.hms_errors entirely,
+# so they don't drive the printer card's "X problem" badge, the red pip, or
+# any other consumer that treats hms_errors as the active-fault list.
+_HMS_USER_ACTION_CODES: frozenset[str] = frozenset(
+    {
+        "0300_400C",  # "The task was canceled."
+        "0500_400E",  # "Printing was cancelled."
+    }
+)
+
+
 @dataclass
 class KProfile:
     """Pressure advance (K) calibration profile from printer."""
@@ -77,6 +90,26 @@ class NozzleInfo:
     nozzle_diameter: str = ""  # e.g., "0.4"
 
 
+@dataclass
+class FilaSwitchState:
+    """Filament Track Switch (FTS) accessory state.
+
+    The FTS is an external accessory that mediates filament routing between an
+    AMS and the printer's extruders. When installed, the AMS no longer has a
+    fixed extruder assignment — any slot can be routed to any extruder via the
+    track switch. Detected from print.device.fila_switch in MQTT.
+    """
+
+    installed: bool = False
+    # in[track] = currently loaded slot for that track (-1 = empty). The slot
+    # value is reported as observed in MQTT (treated as a global tray ID).
+    in_slots: list[int] = field(default_factory=list)
+    # out[track] = extruder this track terminates at (0 = right/main, 1 = left)
+    out_extruders: list[int] = field(default_factory=list)
+    stat: int = 0  # status flags (0 = idle)
+    info: int = 0  # info flags
+
+
 @dataclass
 class PrintOptions:
     """AI detection and print options from xcam data."""
@@ -157,6 +190,20 @@ class PrinterState:
     ams_mapping: list = field(default_factory=list)
     # Per-AMS extruder map: {ams_id: extruder_id} where 0=right/main, 1=left/deputy
     ams_extruder_map: dict = field(default_factory=dict)
+    # Filament Track Switch (FTS) accessory — when installed, AMS info reports
+    # bits 8-11 = 0xE (uninitialized) because routing is dynamic. See #1162.
+    fila_switch: "FilaSwitchState" = field(default_factory=lambda: FilaSwitchState())
+    # Plate dispatched by Bambuddy for the current print. Some firmware versions
+    # (P1S 01.10.00.00) only put the .3mf filename in print.gcode_file, so the
+    # regex used to derive the plate number from the path always falls back to
+    # plate 1 — and the printer card shows the wrong thumbnail (#1166). When
+    # Bambuddy dispatches the print itself we know the plate authoritatively;
+    # we record it here and prefer it over the gcode_file regex. The subtask
+    # field guards against staleness: if the printer is currently running a
+    # different subtask (e.g. a Studio-direct dispatch), these values are
+    # ignored. Cleared on disconnect.
+    dispatched_plate_id: int | None = None
+    dispatched_subtask: str | None = None
     # H2D per-extruder tray_now from snow field: {extruder_id: normalized_global_tray_id}
     # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
     h2d_extruder_snow: dict = field(default_factory=dict)
@@ -311,6 +358,10 @@ class BambuMQTTClient:
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._logging_enabled: bool = False
         self._last_message_time: float = 0.0  # Track when we last received a message
+        # Raw-message fan-out for VP MQTT bridge (non-proxy modes republish the
+        # printer's pushes verbatim to slicers connected to a virtual printer).
+        # Handlers receive (topic, payload_bytes) before JSON parsing.
+        self._raw_message_handlers: list[Callable[[str, bytes], None]] = []
         self._disconnection_event: threading.Event | None = None
         self._previous_ams_hash: str | None = None  # Track AMS changes
 
@@ -412,31 +463,96 @@ class BambuMQTTClient:
             self.state.connected = False
             if self.on_state_change:
                 self.on_state_change(self.state)
-            # Force-close the underlying socket so paho's loop thread detects
-            # the broken connection and triggers auto-reconnect.  We don't call
-            # client.disconnect() because that's a clean disconnect and paho
-            # would NOT auto-reconnect afterwards.
-            # Set flag so _on_disconnect knows this was intentional and skips
-            # redundant state broadcast (we already set connected=False above).
+            # Route based on caller thread — see force_reconnect_stale_session.
+            # check_staleness is normally called from FastAPI handlers (async,
+            # gets the hard-reset path) but the dispatcher exists for safety.
             self._stale_reconnecting = True
-            if self._client:
-                try:
-                    sock = self._client.socket()
-                    if sock:
-                        sock.close()
-                except Exception:
-                    pass  # Best-effort; paho loop will reconnect on next iteration
+            self._reset_client_for_reconnect()
         return self.state.connected
 
     def force_reconnect_stale_session(self, reason: str) -> None:
-        # Heals the #887 half-broken session: telemetry keeps arriving but our
-        # publishes no longer reach the printer. Closing the socket makes paho
-        # drop and re-establish with a fresh session.
+        # Heals the #887/#936/#1136 half-broken session: telemetry keeps
+        # arriving but our publishes don't reach the printer.
+        #
+        # Two routing paths:
+        #
+        # Async-context callers (background_dispatch.py:993 — dispatch deadline)
+        #   → full client teardown + fresh client_id. Wipes paho's client-side
+        #     QoS 1 queue, which is exactly the #1136 reproducer: an unacked
+        #     `project_file` from the broken session would otherwise replay on
+        #     reconnect, mixing stale commands into the next dispatch and
+        #     triggering 0500_4003 SD R/W on the printer.
+        #
+        # Paho-network-thread callers (line ~2604/~2623 — dev-mode probe and
+        # ams_filament_setting zombie detection inside `_update_state`)
+        #   → socket-close fallback. Calling `loop_stop()` from inside the
+        #     network thread would self-join and deadlock; the safe pattern is
+        #     to close the socket and let paho's own loop detect the broken
+        #     connection and auto-reconnect (same instance, same client_id —
+        #     queue replay is theoretically possible here but those paths have
+        #     always done socket-close and #1136 was specifically triggered
+        #     from the dispatch path).
         logger.warning("[%s] Forcing MQTT reconnect: %s", self.serial_number, reason)
         self._stale_reconnecting = True
         self.state.connected = False
         if self.on_state_change:
             self.on_state_change(self.state)
+        self._reset_client_for_reconnect()
+
+    def _reset_client_for_reconnect(self) -> None:
+        """Route between hard-reset and socket-close based on caller thread.
+
+        Hard-reset (preferred) requires we're not running on paho's network
+        thread, since `loop_stop()` on the same thread deadlocks. Detect via
+        ``asyncio.get_running_loop()`` — paho's callback thread has no loop;
+        every legitimate hard-reset caller (FastAPI handlers, background
+        async tasks) does."""
+        try:
+            loop = asyncio.get_running_loop()
+        except RuntimeError:
+            loop = None
+
+        if loop is not None:
+            self._loop = loop
+            self._hard_reset_client()
+        else:
+            self._socket_close_for_reconnect()
+
+    def _hard_reset_client(self) -> None:
+        """Tear down the paho client entirely and rebuild it with a fresh
+        client_id, so the broker drops the old session and paho's local
+        QoS 1 queue is gone. Must NOT be called from paho's network thread.
+        Caller is responsible for setting ``_stale_reconnecting`` and
+        broadcasting the disconnected state."""
+        old_client = self._client
+        self._client = None
+        if old_client is not None:
+            try:
+                old_client.disconnect()  # MQTT DISCONNECT — broker drops session
+            except Exception:
+                pass
+            try:
+                old_client.loop_stop()  # blocks briefly until the network thread exits
+            except Exception:
+                pass
+        # Skip reconnect if no asyncio loop is available (test environment or
+        # pre-init). The next initial connect() call from PrinterManager will
+        # set up the client fresh.
+        if self._loop is None:
+            return
+        try:
+            self.connect(loop=self._loop)
+        except Exception as e:
+            logger.error("[%s] Hard reset reconnect failed: %s", self.serial_number, e)
+
+    def _socket_close_for_reconnect(self) -> None:
+        """Close the underlying socket so paho's loop thread detects the
+        broken connection and triggers auto-reconnect on the SAME client
+        instance. Safe to call from paho's own network thread (the loop
+        polls the socket on every iteration and handles a closed socket
+        gracefully). Used as a fallback when hard-reset isn't safe; queue
+        replay remains theoretically possible here but #1136 specifically
+        traced through the dispatch-deadline path which now hard-resets."""
         if self._client:
             try:
                 sock = self._client.socket()
@@ -573,6 +689,15 @@ class BambuMQTTClient:
             self.on_state_change(self.state)
 
     def _on_message(self, client, userdata, msg):
+        for handler in self._raw_message_handlers:
+            try:
+                handler(msg.topic, msg.payload)
+            except Exception:
+                logger.exception(
+                    "[%s] raw-message handler crashed for topic=%s",
+                    self.serial_number,
+                    msg.topic,
+                )
         try:
             try:
                 raw = msg.payload.decode()
@@ -616,13 +741,26 @@ class BambuMQTTClient:
         if not isinstance(print_data, dict):
             return
         command = print_data.get("command", "")
-        if command == "project_file" and "ams_mapping" in print_data:
-            self._captured_ams_mapping = print_data["ams_mapping"]
-            logger.info(
-                "[%s] Captured ams_mapping from print command: %s",
-                self.serial_number,
-                self._captured_ams_mapping,
-            )
+        if command == "project_file":
+            if "ams_mapping" in print_data:
+                self._captured_ams_mapping = print_data["ams_mapping"]
+                logger.info(
+                    "[%s] Captured ams_mapping from print command: %s",
+                    self.serial_number,
+                    self._captured_ams_mapping,
+                )
+            # Diagnostic for #1162 follow-up (X2D + FTS routing): when a
+            # slicer-launched project_file passes through the request topic,
+            # log the full payload so we can diff Studio's field set against
+            # ours. We pin our own sequence_id to "20000" (line ~3195), so
+            # any other value means the command came from Studio/Orca, not
+            # from us.
+            if print_data.get("sequence_id") != "20000":
+                logger.info(
+                    "[%s] External project_file payload: %s",
+                    self.serial_number,
+                    json.dumps(print_data),
+                )
 
     def _process_message(self, payload: dict):
         """Process incoming MQTT message from printer."""
@@ -779,8 +917,20 @@ class BambuMQTTClient:
                     and print_data.get("sequence_id") == self._dev_mode_probe_seq
                 ):
                     self._handle_dev_mode_probe_response(print_data)
-                # Track user-initiated ams_filament_setting responses (#887 zombie detection)
-                elif cmd == "ams_filament_setting" and self._last_ams_cmd_time > 0:
+                # Track user-initiated ams_filament_setting responses (#887
+                # zombie detection). Reset both the timer AND the unanswered
+                # counter on ANY response — the response proves the channel is
+                # alive, so the counter must not stay armed even when the
+                # watchdog already zeroed `_last_ams_cmd_time` on a previous
+                # tick. The original `and self._last_ams_cmd_time > 0` guard
+                # caused #1164: one sluggish response (>10s) would set the
+                # counter to 1 and zero the timer; the late response arrived
+                # but was ignored by this branch (timer is 0); the counter
+                # stayed at 1 indefinitely; the very next slow response —
+                # possibly hours later, on a totally unrelated command — would
+                # take it to 2 and force-reconnect, surfacing as "filament
+                # config doesn't reach the printer ~6 changes in".
+                elif cmd == "ams_filament_setting":
                     self._last_ams_cmd_time = 0.0
                     self._ams_cmd_unanswered = 0
             if "command" in print_data and print_data.get("command") == "extrusion_cali_get":
@@ -1419,8 +1569,14 @@ class BambuMQTTClient:
                 # Valid physical trays: 0-15 (regular AMS), 128-135 (AMS-HT), 254 (external spool)
                 tn = self.state.tray_now
                 if (0 <= tn <= 15) or (128 <= tn <= 135) or tn == 254:
-                    # Log tray change for mid-print usage splitting
-                    if tn != self.state.last_loaded_tray and self.state.state in ("RUNNING", "PAUSE"):
+                    # Log tray change for mid-print usage splitting. Gate on the
+                    # print-lifecycle flags (`_was_running` set on first RUNNING /
+                    # new print, `_completion_triggered` set when on_print_complete
+                    # fires) instead of `state in ("RUNNING", "PAUSE")` — P2S
+                    # firmware briefly transitions out of RUNNING during AMS
+                    # auto-fallback (#957), so a literal-string gate misses the
+                    # switch and the usage tracker double-credits at completion.
+                    if tn != self.state.last_loaded_tray and self._was_running and not self._completion_triggered:
                         self.state.tray_change_log.append((tn, self.state.layer_num))
                         logger.info(
                             "[%s] Tray change during print: tray=%d at layer=%d",
@@ -1832,6 +1988,22 @@ class BambuMQTTClient:
                 # Log 'cur' field if present (might indicate current/active extruder)
                 if "cur" in ext_data:
                     logger.debug("[%s] device.extruder.cur: %s", self.serial_number, ext_data["cur"])
+
+        # Filament Track Switch (FTS) detection — #1162. Presence of
+        # device.fila_switch in MQTT means the FTS accessory is installed.
+        if "device" in data and isinstance(data.get("device"), dict):
+            fs_data = data["device"].get("fila_switch")
+            if isinstance(fs_data, dict):
+                in_raw = fs_data.get("in")
+                out_raw = fs_data.get("out")
+                self.state.fila_switch = FilaSwitchState(
+                    installed=True,
+                    in_slots=list(in_raw) if isinstance(in_raw, list) else [],
+                    out_extruders=list(out_raw) if isinstance(out_raw, list) else [],
+                    stat=int(fs_data.get("stat", 0) or 0),
+                    info=int(fs_data.get("info", 0) or 0),
+                )
+
         if "bed_temper" in data:
             temps["bed"] = float(data["bed_temper"])
         if "bed_target_temper" in data:
@@ -2212,6 +2384,14 @@ class BambuMQTTClient:
                         # indicators that some firmware sends during normal printing.
                         if code < 0x4000:
                             continue
+                        # Skip user-action echoes — the printer firmware emits these
+                        # as part of normal user-cancel sequences. They're not faults
+                        # and shouldn't count toward "X problem" badges or surface as
+                        # red pips on the printer card. Backend's notification path
+                        # already suppresses 0500_400E for the same reason.
+                        short_code = f"{(attr >> 16) & 0xFFFF:04X}_{code & 0xFFFF:04X}"
+                        if short_code in _HMS_USER_ACTION_CODES:
+                            continue
                         self.state.hms_errors.append(
                             HMSError(
                                 code=f"0x{code:x}" if code else "0x0",
@@ -2248,23 +2428,29 @@ class BambuMQTTClient:
                         f"[{self.serial_number}] print_error: {print_error} (0x{print_error:08x}) -> short_code={short_code}"
                     )
 
-                    # Only add if not already in HMS errors (avoid duplicates)
-                    existing_short_codes = set()
-                    for e in self.state.hms_errors:
-                        # Extract short code from existing errors
-                        e_module = (e.attr >> 16) & 0xFFFF
-                        e_error = int(e.code.replace("0x", ""), 16) if e.code else 0
-                        existing_short_codes.add(f"{e_module:04X}_{e_error:04X}")
-
-                    if short_code not in existing_short_codes:
-                        self.state.hms_errors.append(
-                            HMSError(
-                                code=f"0x{error:x}",
-                                attr=print_error,  # Store full value for display
-                                module=module >> 8,  # High byte of module (e.g., 0x05)
-                                severity=3,  # Warning level for print_error
+                    # Same user-action filter as the hms[] branch above — print_error
+                    # carries the same cancel echoes (e.g. 0500_400E) and they must
+                    # not surface as faults on the printer card.
+                    if short_code in _HMS_USER_ACTION_CODES:
+                        pass  # cancel echo — silently drop
+                    else:
+                        # Only add if not already in HMS errors (avoid duplicates)
+                        existing_short_codes = set()
+                        for e in self.state.hms_errors:
+                            # Extract short code from existing errors
+                            e_module = (e.attr >> 16) & 0xFFFF
+                            e_error = int(e.code.replace("0x", ""), 16) if e.code else 0
+                            existing_short_codes.add(f"{e_module:04X}_{e_error:04X}")
+
+                        if short_code not in existing_short_codes:
+                            self.state.hms_errors.append(
+                                HMSError(
+                                    code=f"0x{error:x}",
+                                    attr=print_error,  # Store full value for display
+                                    module=module >> 8,  # High byte of module (e.g., 0x05)
+                                    severity=3,  # Warning level for print_error
+                                )
                             )
-                        )
 
         # Parse home_flag first so SD-card detection below can prefer it.
         # Bit 8 = HAS_SDCARD_NORMAL, bit 9 = HAS_SDCARD_ABNORMAL, bit 11 = store-to-SD,
@@ -2669,6 +2855,13 @@ class BambuMQTTClient:
             and (
                 self._previous_gcode_state == "RUNNING"  # Normal transition
                 or (self._was_running and self._previous_gcode_state != self.state.state)  # After server restart
+                # Pre-print failure (#1111): printer rejected the job during setup
+                # — wrong nozzle size, AMS error, etc. The print never reaches
+                # RUNNING, so without this branch neither the RUNNING check nor
+                # _was_running match and the queue item stays stuck at "printing".
+                # Restricted to FAILED from pre-print states so a stale FAILED on
+                # first connection (prev=None) still can't accidentally fire.
+                or (self.state.state == "FAILED" and self._previous_gcode_state in ("PREPARE", "SLICING"))
             )
         )
         # For IDLE, only trigger if we just came from RUNNING (explicit abort/cancel)
@@ -2898,6 +3091,13 @@ class BambuMQTTClient:
             protocol=mqtt.MQTTv311,
         )
 
+        # Bambu's broker has racy PUBACK matching with paho's QoS=1 inflight
+        # tracking (#1164). The default ceiling of 20 wedges sessions after
+        # ~16-20 cumulative commands; lifting it well above any realistic
+        # session count keeps QoS=1 working without changing wire-protocol
+        # behaviour across printer models.
+        self._client.max_inflight_messages_set(1000)
+
         self._client.username_pw_set("bblp", self.access_code)
         self._client.on_connect = self._on_connect
         self._client.on_disconnect = self._on_disconnect
@@ -3076,6 +3276,13 @@ class BambuMQTTClient:
 
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+            # Record what we dispatched so /cover can pick the right plate
+            # thumbnail even when the printer's gcode_file echo is just the
+            # 3MF filename without a plate path (#1166). Match the same
+            # subtask_name shape we send so the comparison in the cover route
+            # works against state.subtask_name reflected back via MQTT.
+            self.state.dispatched_plate_id = plate_id
+            self.state.dispatched_subtask = command["print"]["subtask_name"]
             return True
         else:
             # Log why we couldn't send the command
@@ -3348,6 +3555,39 @@ class BambuMQTTClient:
         """Check if logging is enabled."""
         return self._logging_enabled
 
+    def register_raw_message_handler(self, handler: Callable[[str, bytes], None]) -> None:
+        """Register a handler invoked for every incoming MQTT message.
+
+        Used by the VP MQTT bridge to republish the printer's report pushes to
+        slicers connected to a virtual printer in non-proxy mode. Handlers run
+        on paho's network thread and must not block; exceptions are caught.
+        """
+        if handler not in self._raw_message_handlers:
+            self._raw_message_handlers.append(handler)
+
+    def unregister_raw_message_handler(self, handler: Callable[[str, bytes], None]) -> None:
+        """Unregister a previously-registered raw-message handler."""
+        try:
+            self._raw_message_handlers.remove(handler)
+        except ValueError:
+            pass
+
+    def publish_raw(self, topic: str, payload: bytes | str, qos: int = 1) -> bool:
+        """Publish a pre-formed payload directly to the printer's MQTT broker.
+
+        Used by the VP MQTT bridge to forward slicer-originated commands without
+        going through send_command's sequence-id mangling. Returns False if the
+        underlying paho client isn't ready.
+        """
+        if self._client is None:
+            return False
+        try:
+            info = self._client.publish(topic, payload, qos=qos)
+            return info.rc == mqtt.MQTT_ERR_SUCCESS
+        except Exception:
+            logger.exception("[%s] publish_raw failed for topic=%s", self.serial_number, topic)
+            return False
+
     def send_drying_command(
         self, ams_id: int, temp: int, duration: int, mode: int = 1, filament: str = "", rotate_tray: bool = False
     ):
@@ -3733,9 +3973,9 @@ class BambuMQTTClient:
 
         # Detect printer type by serial number prefix
         # Dual-nozzle families:
-        #   H2D series: serial starts with "094"
-        #   X2D series: serial starts with "20P9"
-        is_dual_nozzle = self.serial_number.startswith(("094", "20P9"))
+        #   H2 series: legacy "094"; post-2026 H2C batches ship with "31B8B" (#1105)
+        #   X2D series: "20P9"
+        is_dual_nozzle = self.serial_number.startswith(("094", "20P9", "31B8B"))
 
         if is_dual_nozzle:
             # H2D format: uses extruder_id, nozzle_id, nozzle_diameter
@@ -4132,7 +4372,9 @@ class BambuMQTTClient:
         """Load filament from a specific AMS tray.
 
         Args:
-            tray_id: Global tray ID (0-15 for AMS slots, or 254 for external spool)
+            tray_id: Global tray ID — 0..15 for AMS slots, 254 for external spool
+                (single-external printers and Ext-L on dual-nozzle H2D),
+                255 for Ext-R on dual-nozzle H2D.
             extruder_id: Unused - kept for API compatibility
 
         Returns:
@@ -4142,18 +4384,33 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot load filament: not connected", self.serial_number)
             return False
 
-        # Calculate ams_id and slot_id for logging
-        if tray_id == 254:
-            ams_id = 255  # External spool
+        # Build the ams_change_filament command. Encoding differs by target type:
+        #   - AMS slots (0..15): slot_id is the local slot, curr/tar_temp = -1.
+        #   - External spool (tray_id=254): legacy capture from a single-extruder
+        #     printer used slot_id=254, curr/tar_temp=-1; preserved here.
+        #   - Ext-R on dual-nozzle H2D (tray_id=255): captured shape from
+        #     BambuStudio uses slot_id=0 (extruder index, 0=right), and
+        #     curr_temp/tar_temp = the actual right-nozzle temp.  See #891.
+        self._sequence_id += 1
+        if tray_id == 255:
+            ams_id = 255
+            slot_id = 0  # extruder index for the right nozzle
+            right_temp = int(self.state.temperatures.get("nozzle_2", 0) or 0)
+            if right_temp < 180:
+                right_temp = 215  # Reasonable default if right nozzle is cold/unknown
+            curr_temp = right_temp
+            tar_temp = right_temp
+        elif tray_id == 254:
+            ams_id = 255
             slot_id = 254
+            curr_temp = -1
+            tar_temp = -1
         else:
-            ams_id = tray_id // 4  # AMS unit (0, 1, 2, 3...)
-            slot_id = tray_id % 4  # Slot within AMS (0, 1, 2, 3)
+            ams_id = tray_id // 4
+            slot_id = tray_id % 4
+            curr_temp = -1
+            tar_temp = -1
 
-        # Command format from BambuStudio traffic capture:
-        # - No extruder_id field
-        # - curr_temp and tar_temp are -1 (not 0)
-        self._sequence_id += 1
         command = {
             "print": {
                 "command": "ams_change_filament",
@@ -4161,8 +4418,8 @@ class BambuMQTTClient:
                 "ams_id": ams_id,
                 "slot_id": slot_id,
                 "target": tray_id,
-                "curr_temp": -1,
-                "tar_temp": -1,
+                "curr_temp": curr_temp,
+                "tar_temp": tar_temp,
             }
         }
 

+ 12 - 2
backend/app/services/camera.py

@@ -162,6 +162,16 @@ async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "as
             proxy_url = f"rtsp://127.0.0.1:{_local_port[0]}".encode()
             real_url = f"rtsps://{target_host}:{target_port}".encode()
 
+            # Note on the broad except below: dst.write() raises RuntimeError
+            # under uvloop when the underlying handle has already been torn
+            # down (uvloop.loop.UVHandle._ensure_alive). asyncio's default
+            # selector loop reports the same situation as ConnectionResetError
+            # / OSError, so a tuple that doesn't include RuntimeError leaks the
+            # uvloop variant up to asyncio's unhandled-exception logger
+            # ("Unhandled exception in client_connected_cb"). The forwarders
+            # are intentionally fire-and-forget on tear-down — once either
+            # peer drops, both halves of the proxy should exit quietly.
+
             async def _fwd_to_server(src: asyncio.StreamReader, dst: asyncio.StreamWriter):
                 """Forward client→server, rewriting RTSP request-line URLs only."""
                 try:
@@ -172,7 +182,7 @@ async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "as
                         data = rewrite_rtsp_request_url(data, proxy_url, real_url)
                         dst.write(data)
                         await dst.drain()
-                except (ConnectionError, OSError, asyncio.CancelledError):
+                except (ConnectionError, OSError, asyncio.CancelledError, RuntimeError):
                     pass
                 finally:
                     if not dst.is_closing():
@@ -190,7 +200,7 @@ async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "as
                             break
                         dst.write(data)
                         await dst.drain()
-                except (ConnectionError, OSError, asyncio.CancelledError):
+                except (ConnectionError, OSError, asyncio.CancelledError, RuntimeError):
                     pass
                 finally:
                     if not dst.is_closing():

+ 280 - 0
backend/app/services/camera_fanout.py

@@ -0,0 +1,280 @@
+"""MJPEG fan-out broadcaster for camera streams.
+
+Most Bambu Lab printers only allow one concurrent camera connection: the
+RTSP socket on X1/H2/P2 models, the chamber-image socket on port 6000 on
+A1/P1 models. Without fan-out, opening a second viewer either fails or
+kicks the first viewer off — see issue #1089.
+
+This module owns a single upstream connection per printer and pushes each
+frame to N independent subscriber queues. New viewers tap the existing
+upstream; no new printer connection is opened. When the last subscriber
+leaves, the upstream is torn down after a short grace window so that a
+quick page refresh or second-tab open does not pay a reconnect.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import AsyncGenerator, Awaitable, Callable
+
+logger = logging.getLogger(__name__)
+
+# How long to keep the upstream pump alive after the last subscriber leaves.
+# A short grace window absorbs page refreshes and "open camera in new tab"
+# without paying a fresh ffmpeg/RTSP handshake (which can take several seconds
+# on some firmwares and is the very reconnect cost we are trying to avoid).
+_GRACE_SECONDS = 5.0
+
+# Per-subscriber queue depth. Small on purpose: if a viewer can't keep up
+# with the printer's frame rate we drop frames for that viewer rather than
+# blocking the broadcaster. Live video — old frames have no value.
+_SUBSCRIBER_QUEUE_SIZE = 4
+
+# Sentinel pushed to subscriber queues when the upstream pump exits, so each
+# subscriber's read loop can break out cleanly instead of hanging on get().
+_UPSTREAM_GONE = b""
+
+UpstreamFactory = Callable[[asyncio.Event], AsyncGenerator[bytes, None]]
+
+
+class MjpegBroadcaster:
+    """Single upstream MJPEG stream, fanned out to N subscribers."""
+
+    def __init__(self, key: str, factory: UpstreamFactory) -> None:
+        self._key = key
+        self._factory = factory
+        self._subscribers: list[asyncio.Queue[bytes]] = []
+        self._lock = asyncio.Lock()
+        self._pump_task: asyncio.Task | None = None
+        self._grace_task: asyncio.Task | None = None
+        # Disconnect event passed to the upstream generator so we can ask it to
+        # stop reconnecting when the last subscriber leaves.
+        self._upstream_disconnect = asyncio.Event()
+        self._stopped = False
+
+    @property
+    def key(self) -> str:
+        return self._key
+
+    @property
+    def subscriber_count(self) -> int:
+        return len(self._subscribers)
+
+    @property
+    def stopped(self) -> bool:
+        return self._stopped
+
+    async def subscribe(self) -> asyncio.Queue[bytes]:
+        """Add a subscriber and ensure the upstream pump is running."""
+        async with self._lock:
+            if self._stopped:
+                raise RuntimeError(f"broadcaster {self._key!r} is stopped")
+
+            # Cancel any pending grace-window shutdown — a viewer just rejoined.
+            if self._grace_task is not None and not self._grace_task.done():
+                self._grace_task.cancel()
+                self._grace_task = None
+
+            queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=_SUBSCRIBER_QUEUE_SIZE)
+            self._subscribers.append(queue)
+
+            if self._pump_task is None or self._pump_task.done():
+                # Reset the disconnect signal in case a previous pump set it.
+                self._upstream_disconnect = asyncio.Event()
+                self._pump_task = asyncio.create_task(self._pump(), name=f"camera-fanout-pump-{self._key}")
+            return queue
+
+    async def unsubscribe(self, queue: asyncio.Queue[bytes]) -> int:
+        """Remove a subscriber and return the remaining count (atomic).
+
+        If this was the last subscriber, schedule grace shutdown.
+        """
+        async with self._lock:
+            try:
+                self._subscribers.remove(queue)
+            except ValueError:
+                return len(self._subscribers)  # Already removed (e.g. force_shutdown)
+            remaining = len(self._subscribers)
+            if remaining == 0 and not self._stopped:
+                # Last subscriber left. Schedule grace-window teardown.
+                self._grace_task = asyncio.create_task(self._grace_then_stop(), name=f"camera-fanout-grace-{self._key}")
+            return remaining
+
+    async def force_shutdown(self) -> None:
+        """Tear down immediately, kick all subscribers. Idempotent."""
+        pump_task = await self._mark_stopped_locked(notify_subscribers=True)
+        await self._await_pump_cancellation(pump_task)
+
+    async def _grace_then_stop(self) -> None:
+        try:
+            await asyncio.sleep(_GRACE_SECONDS)
+        except asyncio.CancelledError:
+            return  # New subscriber arrived during grace
+        # Re-check under the lock — a subscriber may have rejoined between
+        # the sleep finishing and us acquiring the lock.
+        pump_task: asyncio.Task | None = None
+        async with self._lock:
+            if self._subscribers or self._stopped:
+                return
+            self._upstream_disconnect.set()
+            pump_task = self._pump_task
+            self._pump_task = None
+            self._grace_task = None
+            self._stopped = True
+        await self._await_pump_cancellation(pump_task)
+
+    async def _mark_stopped_locked(self, *, notify_subscribers: bool) -> asyncio.Task | None:
+        """Mark the broadcaster stopped and detach the pump task.
+
+        Caller MUST NOT hold ``self._lock`` (we acquire it here). Returns the
+        pump task (if any) so the caller can await its cancellation OUTSIDE
+        the lock — the pump's ``finally`` block needs the lock to wake up
+        subscribers, so we'd deadlock if we awaited it under the lock.
+        """
+        async with self._lock:
+            if self._stopped and self._pump_task is None:
+                return None
+            self._upstream_disconnect.set()
+            if notify_subscribers:
+                for queue in self._subscribers:
+                    try:
+                        queue.put_nowait(_UPSTREAM_GONE)
+                    except asyncio.QueueFull:
+                        pass
+                self._subscribers.clear()
+            pump_task = self._pump_task
+            self._pump_task = None
+            self._stopped = True
+            if self._grace_task is not None and not self._grace_task.done():
+                self._grace_task.cancel()
+                self._grace_task = None
+            return pump_task
+
+    async def _await_pump_cancellation(self, pump_task: asyncio.Task | None) -> None:
+        if pump_task is None or pump_task.done():
+            return
+        pump_task.cancel()
+        try:
+            await pump_task
+        except (asyncio.CancelledError, Exception):
+            # Pump exceptions are already logged inside _pump; swallow here so
+            # teardown can never propagate a stray crash.
+            pass
+
+    async def _pump(self) -> None:
+        """Drive the upstream generator and broadcast each chunk."""
+        try:
+            async for chunk in self._factory(self._upstream_disconnect):
+                # Snapshot subscribers under lock so we don't iterate a list
+                # mutated by subscribe()/unsubscribe() while we are putting.
+                async with self._lock:
+                    targets = list(self._subscribers)
+                for queue in targets:
+                    try:
+                        queue.put_nowait(chunk)
+                    except asyncio.QueueFull:
+                        # Slow viewer — drop this frame for them. They'll catch
+                        # up on the next frame. Don't unsubscribe: a brief
+                        # browser stall shouldn't end the stream.
+                        pass
+        except asyncio.CancelledError:
+            raise
+        except Exception:
+            logger.exception("Camera fan-out pump crashed for %s", self._key)
+        finally:
+            # Pump is exiting — wake up any subscribers still hanging on get().
+            async with self._lock:
+                for queue in self._subscribers:
+                    try:
+                        queue.put_nowait(_UPSTREAM_GONE)
+                    except asyncio.QueueFull:
+                        pass
+
+
+# Global registry. Keyed by printer_id (as str) so a chamber-mode printer
+# and an RTSP-mode printer can never collide on the same key.
+_broadcasters: dict[str, MjpegBroadcaster] = {}
+_registry_lock = asyncio.Lock()
+
+
+async def get_or_create_broadcaster(key: str, factory: UpstreamFactory) -> MjpegBroadcaster:
+    """Return the live broadcaster for `key`, creating one if needed.
+
+    A broadcaster that has been stopped (force shutdown or grace timeout) is
+    replaced with a fresh instance — the caller will subscribe to the new one.
+    """
+    async with _registry_lock:
+        existing = _broadcasters.get(key)
+        if existing is not None and not existing.stopped:
+            return existing
+        new_bc = MjpegBroadcaster(key, factory)
+        _broadcasters[key] = new_bc
+        return new_bc
+
+
+async def shutdown_broadcaster(key: str) -> bool:
+    """Force-shutdown the broadcaster for `key`. Returns True if one was running."""
+    async with _registry_lock:
+        bc = _broadcasters.pop(key, None)
+    if bc is None:
+        return False
+    await bc.force_shutdown()
+    return True
+
+
+async def shutdown_all_broadcasters() -> None:
+    """Tear down every broadcaster (for app shutdown)."""
+    async with _registry_lock:
+        bcs = list(_broadcasters.values())
+        _broadcasters.clear()
+    await asyncio.gather(*(bc.force_shutdown() for bc in bcs), return_exceptions=True)
+
+
+def active_broadcaster_keys() -> list[str]:
+    """Snapshot of keys with a live (non-stopped) broadcaster. For diagnostics."""
+    return [k for k, bc in _broadcasters.items() if not bc.stopped]
+
+
+# ---------------------------------------------------------------------------
+# AsyncGenerator helper — turns a subscriber queue into an async generator
+# that yields MJPEG chunks until the upstream signals it's gone.
+# ---------------------------------------------------------------------------
+
+
+async def iter_subscriber(
+    broadcaster: MjpegBroadcaster,
+    queue: asyncio.Queue[bytes],
+    *,
+    is_disconnected: Callable[[], Awaitable[bool]] | None = None,
+    on_unsubscribe: Callable[[int], None] | None = None,
+) -> AsyncGenerator[bytes, None]:
+    """Yield chunks from a subscriber queue until upstream ends or client leaves.
+
+    Always unsubscribes from the broadcaster on exit, even on cancellation.
+    The optional ``on_unsubscribe`` callback receives the post-unsubscribe
+    subscriber count — useful for accurate detach-log lines that don't race
+    with concurrent unsubscribes.
+    """
+    try:
+        while True:
+            try:
+                chunk = await asyncio.wait_for(queue.get(), timeout=30.0)
+            except asyncio.TimeoutError:
+                # No frame in 30s — check whether the client is still there.
+                # If yes, keep waiting; if no, bail out.
+                if is_disconnected is not None and await is_disconnected():
+                    break
+                continue
+            if chunk == _UPSTREAM_GONE:
+                break
+            yield chunk
+            if is_disconnected is not None and await is_disconnected():
+                break
+    finally:
+        remaining = await broadcaster.unsubscribe(queue)
+        if on_unsubscribe is not None:
+            try:
+                on_unsubscribe(remaining)
+            except Exception:
+                logger.exception("on_unsubscribe callback raised")

+ 63 - 29
backend/app/services/external_camera.py

@@ -173,17 +173,30 @@ def get_ffmpeg_path() -> str | None:
     return None
 
 
-async def capture_frame(url: str, camera_type: str, timeout: int = 15) -> bytes | None:
+async def capture_frame(
+    url: str,
+    camera_type: str,
+    timeout: int = 15,
+    snapshot_url: str | None = None,
+) -> bytes | None:
     """Capture single frame from external camera.
 
     Args:
-        url: Camera URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path)
-        camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
-        timeout: Connection timeout in seconds
+        url: Live-stream URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path).
+        camera_type: "mjpeg", "rtsp", "snapshot", or "usb".
+        timeout: Connection timeout in seconds.
+        snapshot_url: Optional override for single-frame capture. When set, fetched
+            via plain HTTP GET regardless of `camera_type`. Bypasses MJPEG warm-up
+            handling on sources that expose a dedicated frame endpoint (e.g. go2rtc's
+            `/api/frame.jpeg` reliably returns a clean image while the MJPEG stream's
+            first frame is often the encoder's stale keyframe). #1177.
 
     Returns:
         JPEG bytes or None on failure
     """
+    if snapshot_url:
+        logger.debug("capture_frame using snapshot override url=%s...", snapshot_url[:50])
+        return await _capture_snapshot(snapshot_url, timeout)
     logger.debug("capture_frame called: type=%s, url=%s...", camera_type, url[:50] if url else "None")
     if camera_type == "mjpeg":
         return await _capture_mjpeg_frame(url, timeout)
@@ -280,18 +293,32 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
 
 
 async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
-    """Extract single frame from MJPEG stream.
-
-    Note: This function intentionally makes requests to user-configured URLs.
-    External camera support requires connecting to user-specified camera endpoints.
-    URL is sanitized and dangerous destinations are blocked.
+    """Extract a single representative frame from an MJPEG stream.
+
+    Many MJPEG sources — go2rtc most notably (#1177), and several IP cameras —
+    emit a "warm-up" frame on the byte that follows connection accept: usually
+    the last keyframe held in the encoder, which is often black or stale until
+    the encoder catches up to live content. To return a frame that's actually
+    representative of the scene we read past the first frame and return the
+    second; if the connection closes / times out / hits the buffer cap before
+    a second frame ever arrives we fall back to the first so callers still
+    get *something* (better than degrading slow / single-frame streams to None,
+    which would regress every code path that consumed pre-fix behaviour).
+
+    Note: this function intentionally makes requests to user-configured URLs.
+    External camera support requires connecting to user-specified camera
+    endpoints. URL is sanitized and dangerous destinations are blocked.
     """
-    # Sanitize URL - returns reconstructed URL from validated components
     safe_url = _sanitize_camera_url(url, ("http", "https"))
     if not safe_url:
         logger.error("Invalid MJPEG URL format: %s...", url[:50])
         return None
 
+    jpeg_start = b"\xff\xd8"
+    jpeg_end = b"\xff\xd9"
+    first_frame: bytes | None = None  # warm-up frame; fallback if no second arrives
+    buffer = b""
+
     try:
         async with (
             aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session,
@@ -301,38 +328,45 @@ async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
                 logger.error("MJPEG stream returned status %s", response.status)
                 return None
 
-            # Read chunks until we find a complete JPEG frame
-            buffer = b""
-            jpeg_start = b"\xff\xd8"
-            jpeg_end = b"\xff\xd9"
-
             async for chunk in response.content.iter_chunked(8192):
                 buffer += chunk
 
-                # Look for complete JPEG frame
-                start_idx = buffer.find(jpeg_start)
-                if start_idx == -1:
-                    continue
-
-                end_idx = buffer.find(jpeg_end, start_idx + 2)
-                if end_idx != -1:
-                    # Found complete frame
+                # A single chunk can carry multiple frames (e.g. high-FPS sources)
+                # or a partial frame. Drain every complete frame we already have
+                # before pulling the next chunk.
+                while True:
+                    start_idx = buffer.find(jpeg_start)
+                    if start_idx == -1:
+                        # No frame start yet — drop trailing garbage, keep waiting.
+                        break
+                    end_idx = buffer.find(jpeg_end, start_idx + 2)
+                    if end_idx == -1:
+                        # Partial frame; trim already-discarded prefix so the
+                        # buffer stays bounded across long-running streams.
+                        if start_idx > 0:
+                            buffer = buffer[start_idx:]
+                        break
                     frame = buffer[start_idx : end_idx + 2]
-                    return frame
+                    buffer = buffer[end_idx + 2 :]
+                    if first_frame is None:
+                        first_frame = frame  # warm-up; keep but don't return yet
+                        continue
+                    return frame  # representative second frame
 
-                # Keep searching, but limit buffer size
                 if len(buffer) > 5 * 1024 * 1024:  # 5MB limit
                     logger.warning("MJPEG buffer exceeded 5MB without finding frame")
-                    return None
+                    break  # exit chunk loop, fall through to first_frame fallback
 
     except TimeoutError:
         logger.warning("MJPEG frame capture timed out after %ss", timeout)
-        return None
     except (aiohttp.ClientError, OSError) as e:
         logger.error("MJPEG frame capture failed: %s", e)
-        return None
 
-    return None
+    # Stream ended / timed out / buffer cap before a second frame arrived.
+    # Return whatever warm-up frame we managed to read; better an iffy frame
+    # than None for callers that need *some* image (snapshot UX, plate-detect
+    # CV, finish photo). None only if no frame ever arrived at all.
+    return first_frame
 
 
 async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:

+ 113 - 0
backend/app/services/filament_requirements.py

@@ -0,0 +1,113 @@
+"""Parse per-slot filament requirements out of a 3MF file.
+
+The scheduler used to own this logic (`PrintScheduler._get_filament_requirements`)
+because it ran during dispatch decisions. Extracted here so the VP queue-mode
+write path can use the same parser to populate `filament_overrides` /
+`required_filament_types` at upload time (#1188 — Bambuddy was creating queue
+items with no filament fields, which made the scheduler fall through to
+model-only matching and dispatch onto whatever printer happened to be free
+regardless of loaded colour).
+
+The shape returned here matches the `filament_overrides` JSON shape the
+scheduler validates against, minus the `force_color_match` flag — callers
+add that themselves based on their own setting.
+"""
+
+from __future__ import annotations
+
+import logging
+import xml.etree.ElementTree as ET
+import zipfile
+from pathlib import Path
+
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
+
+logger = logging.getLogger(__name__)
+
+
+def extract_filament_requirements(file_path: Path, plate_id: int | None = None) -> list[dict]:
+    """Parse `[{slot_id, type, color, tray_info_idx, used_grams, nozzle_id?}]` from a 3MF.
+
+    Args:
+        file_path: Path to the 3MF.
+        plate_id: When set, only return filaments used on that plate. When
+            None, return every filament with `used_g > 0` across the file.
+
+    Returns:
+        Sorted list (by `slot_id`) of filament dicts. Empty list when the
+        3MF is unreadable, missing `Metadata/slice_info.config`, or has no
+        filaments matching the plate filter — callers treat that as "no
+        requirements" rather than an error so a malformed 3MF doesn't break
+        the upload path.
+    """
+    if not file_path.exists():
+        return []
+
+    filaments: list[dict] = []
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return []
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)  # noqa: S314  # nosec B314
+
+            if plate_id is not None:
+                for plate_elem in root.findall("./plate"):
+                    plate_index = None
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "index":
+                            try:
+                                plate_index = int(meta.get("value", "0"))
+                            except ValueError:
+                                pass
+                            break
+                    if plate_index == plate_id:
+                        _collect_filaments(plate_elem, filaments)
+                        break
+            else:
+                _collect_filaments(root, filaments)
+
+            filaments.sort(key=lambda x: x["slot_id"])
+
+            # Dual-nozzle printers (H2D / X2D) — annotate which extruder each
+            # slot is fed into. Empty mapping for single-nozzle printers, in
+            # which case we just don't add the key.
+            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+            if nozzle_mapping:
+                for filament in filaments:
+                    filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
+    except Exception as e:
+        logger.warning("Failed to parse filament requirements from %s: %s", file_path, e)
+        return []
+
+    return filaments
+
+
+def _collect_filaments(parent: ET.Element, into: list[dict]) -> None:
+    """Walk every `./filament` child under `parent` and append normalised
+    entries to `into`. Skips filaments with `used_g <= 0` (slot present in
+    the slicer config but not consumed by this plate)."""
+    for filament_elem in parent.findall("./filament"):
+        filament_id = filament_elem.get("id")
+        if not filament_id:
+            continue
+        try:
+            used_grams = float(filament_elem.get("used_g", "0"))
+        except (ValueError, TypeError):
+            continue
+        if used_grams <= 0:
+            continue
+        try:
+            slot_id = int(filament_id)
+        except (ValueError, TypeError):
+            continue
+        into.append(
+            {
+                "slot_id": slot_id,
+                "type": filament_elem.get("type", ""),
+                "color": filament_elem.get("color", ""),
+                "tray_info_idx": filament_elem.get("tray_info_idx", ""),
+                "used_grams": round(used_grams, 1),
+            }
+        )

+ 4 - 0
backend/app/services/git_providers/__init__.py

@@ -0,0 +1,4 @@
+from backend.app.services.git_providers.base import GitProviderBackend
+from backend.app.services.git_providers.factory import get_provider_backend
+
+__all__ = ["GitProviderBackend", "get_provider_backend"]

+ 78 - 0
backend/app/services/git_providers/base.py

@@ -0,0 +1,78 @@
+"""Abstract base class for Git hosting provider backends."""
+
+import hashlib
+from abc import ABC, abstractmethod
+
+import httpx
+
+
+class GitProviderBackend(ABC):
+    """Abstract base for Git hosting provider API backends."""
+
+    @staticmethod
+    def _blob_sha(content_bytes: bytes) -> str:
+        """Compute the git blob SHA for content_bytes (sha1("blob {len}\\0" + data))."""
+        return hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False).hexdigest()
+
+    @staticmethod
+    def _truncated_response_text(response: httpx.Response, max_length: int = 200) -> str:
+        """Return a bounded response body for errors surfaced to logs/UI."""
+        text = response.text
+        if len(text) <= max_length:
+            return text
+        return f"{text[: max_length - 3]}..."
+
+    @staticmethod
+    def _read_sha(response: httpx.Response, *path: str) -> tuple[str | None, str | None]:
+        """Walk a JSON path to a string SHA value.
+
+        Returns ``(sha, None)`` on success, ``(None, reason)`` if the body is
+        not JSON, the path is missing, or the leaf is not a string. Callers
+        use the reason to build a clear failure message instead of letting
+        ``KeyError``/``JSONDecodeError`` bubble to the outer catch-all (which
+        surfaces cryptic one-word strings like ``"'object'"`` to operators).
+        """
+        try:
+            data = response.json()
+        except ValueError:
+            return None, "non-JSON response body"
+        for key in path:
+            if not isinstance(data, dict):
+                return None, f"unexpected shape at key {key!r}"
+            if key not in data:
+                return None, f"missing key {key!r}"
+            data = data[key]
+        if not isinstance(data, str):
+            return None, f"value at {'.'.join(path)} is not a string"
+        return data, None
+
+    def get_headers(self, token: str) -> dict:
+        """Return HTTP headers for authenticated API requests."""
+        return {
+            "Authorization": f"token {token}",
+            "Accept": "application/vnd.github.v3+json",
+            "User-Agent": "Bambuddy-Backup",
+        }
+
+    @abstractmethod
+    def parse_repo_url(self, url: str) -> tuple[str, str]:
+        """Return (owner, repo) extracted from the repository URL."""
+
+    @abstractmethod
+    def get_api_base(self, repo_url: str) -> str:
+        """Return the API base URL for this provider instance."""
+
+    @abstractmethod
+    async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
+        """Test API connectivity and push permissions. Returns success/message/repo_name/permissions."""
+
+    @abstractmethod
+    async def push_files(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        files: dict,
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """Push files to the repository. Returns status/message/commit_sha/files_changed."""

+ 22 - 0
backend/app/services/git_providers/factory.py

@@ -0,0 +1,22 @@
+"""Factory for instantiating the correct Git provider backend."""
+
+from backend.app.services.git_providers.base import GitProviderBackend
+from backend.app.services.git_providers.forgejo import ForgejoBackend
+from backend.app.services.git_providers.gitea import GiteaBackend
+from backend.app.services.git_providers.github import GitHubBackend
+from backend.app.services.git_providers.gitlab import GitLabBackend
+
+_BACKENDS: dict[str, type[GitProviderBackend]] = {
+    "github": GitHubBackend,
+    "gitea": GiteaBackend,
+    "forgejo": ForgejoBackend,
+    "gitlab": GitLabBackend,
+}
+
+
+def get_provider_backend(provider: str) -> GitProviderBackend:
+    """Return an instantiated backend for the given provider key."""
+    backend_cls = _BACKENDS.get(provider)
+    if backend_cls is None:
+        raise ValueError(f"Unknown Git provider: {provider!r}")
+    return backend_cls()

+ 101 - 0
backend/app/services/git_providers/forgejo.py

@@ -0,0 +1,101 @@
+"""Forgejo backend — diverges from Gitea on token-scope validation (v15+)."""
+
+import logging
+
+import httpx
+
+from backend.app.services.git_providers.gitea import GiteaBackend
+
+logger = logging.getLogger(__name__)
+
+
+class ForgejoBackend(GiteaBackend):
+    """Backend for Forgejo instances.
+
+    Forgejo v15+ returns 404 (not 403) for private repositories when the token
+    lacks repository scope, requiring a /user pre-check to distinguish bad tokens
+    from inaccessible repos. test_connection is overridden to handle this.
+    Other methods are inherited from GiteaBackend unchanged.
+    """
+
+    async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            # Verify token validity before hitting the repo. On Forgejo v15+,
+            # private repos return 404 (not 403) when the token lacks repo scope,
+            # so we must distinguish "bad token" from "token OK but repo not visible".
+            user_resp = await client.get(f"{api_base}/user", headers=headers)
+            if user_resp.status_code == 401:
+                return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
+            if user_resp.status_code == 403:
+                return {
+                    "success": False,
+                    "message": "Token has no read:user scope; cannot validate identity",
+                    "repo_name": None,
+                    "permissions": None,
+                }
+            if user_resp.status_code != 200:
+                return {
+                    "success": False,
+                    "message": f"Forgejo API error on /user: {user_resp.status_code}",
+                    "repo_name": None,
+                    "permissions": None,
+                }
+
+            repo_resp = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
+
+            if repo_resp.status_code == 404:
+                return {
+                    "success": False,
+                    "message": (
+                        "Repository not found or token cannot access it. "
+                        "On Forgejo v15+, private repositories return 404 (not 403) "
+                        "when the token lacks repository scope."
+                    ),
+                    "repo_name": None,
+                    "permissions": None,
+                }
+
+            if repo_resp.status_code != 200:
+                return {
+                    "success": False,
+                    "message": f"API error: {repo_resp.status_code}",
+                    "repo_name": None,
+                    "permissions": None,
+                }
+
+            data = repo_resp.json()
+            permissions = data.get("permissions", {})
+
+            if not permissions.get("push", False):
+                return {
+                    "success": False,
+                    "message": "Token does not have push permission to this repository",
+                    "repo_name": data.get("full_name"),
+                    "permissions": permissions,
+                }
+
+            return {
+                "success": True,
+                "message": "Connection successful",
+                "repo_name": data.get("full_name"),
+                "permissions": permissions,
+            }
+
+        except Exception as e:
+            logger.exception("Forgejo connection test failed")
+            detail = str(e)[:200]
+            message = (
+                f"Connection failed: {type(e).__name__}: {detail}"
+                if detail
+                else f"Connection failed: {type(e).__name__}"
+            )
+            return {
+                "success": False,
+                "message": message,
+                "repo_name": None,
+                "permissions": None,
+            }

+ 365 - 0
backend/app/services/git_providers/gitea.py

@@ -0,0 +1,365 @@
+"""Gitea backend — overrides GitHubBackend where Gitea's API diverges."""
+
+import base64
+import json
+import logging
+import re
+from datetime import datetime, timezone
+
+import httpx
+
+from backend.app.services.git_providers.github import GitHubBackend
+
+logger = logging.getLogger(__name__)
+
+
+class GiteaBackend(GitHubBackend):
+    """Backend for Gitea instances.
+
+    Gitea's Git Data API (/api/v1/repos/{owner}/{repo}/git/...) is *mostly*
+    compatible with GitHub's, but diverges on three points that broke real-world
+    backups (#1224, #1225, #1239):
+
+    1. ``GET /git/refs/heads/{branch}`` returns a *list* of matching refs even
+       when only one matches; GitHub returns a single object. The push paths
+       below extract the SHA via ``_ref_sha()`` instead of the GitHub-style
+       ``["object"]["sha"]`` chain.
+
+    2. The Git Data API (blobs/trees/commits/refs) refuses writes against an
+       empty repository — every blob POST returns 404 until the repo has at
+       least one commit. ``_create_initial_commit()`` is overridden to use the
+       Contents API, which seeds the branch + initial commit in a single call.
+
+    3. The Git Data API does not support atomic multi-file commits — each file
+       requires a separate blob POST followed by a tree/commit/ref sequence.
+       ``push_files()`` is overridden to use the Contents API
+       (``POST /repos/.../contents`` with a ``files`` array), which commits all
+       changed files in a single round-trip and avoids partial-commit failures.
+    """
+
+    @staticmethod
+    def _ref_sha(ref_data) -> str:
+        """Extract the commit SHA from Gitea's list-shaped ref response."""
+        if isinstance(ref_data, list):
+            if not ref_data:
+                raise ValueError("Empty refs list returned by Gitea API")
+            return ref_data[0]["object"]["sha"]
+        return ref_data["object"]["sha"]
+
+    @staticmethod
+    def _commit_tree_sha(commit_data: dict) -> str | None:
+        """Extract the tree SHA from a commit response.
+
+        GitHub's ``GET /git/commits/{sha}`` returns the GitCommit schema with
+        ``tree`` at the top level. Gitea's same-named endpoint may return the
+        wrapped Commit schema where ``tree`` lives under ``commit``. Try the
+        flat shape first (GitHub-compatible deployments and some Gitea/Forgejo
+        versions) then fall back to the wrapped shape.
+        """
+        tree_node = commit_data.get("tree")
+        if not isinstance(tree_node, dict):
+            tree_node = (commit_data.get("commit") or {}).get("tree")
+        if isinstance(tree_node, dict):
+            return tree_node.get("sha")
+        return None
+
+    def parse_repo_url(self, url: str) -> tuple[str, str]:
+        """Return (owner, repo) — accepts both https:// and http:// for self-hosted instances."""
+        if not url or len(url) > 500:
+            raise ValueError("Invalid Git URL: URL too long or empty")
+        match = re.match(
+            r"https?://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
+            url,
+        )
+        if match:
+            return match.group(2), match.group(3).removesuffix(".git")
+        match = re.match(
+            r"git@[\w.\-]+:([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?$",
+            url,
+        )
+        if match:
+            return match.group(1), match.group(2).removesuffix(".git")
+        raise ValueError(f"Cannot parse repository URL: {url}")
+
+    def get_api_base(self, repo_url: str) -> str:
+        """Derive API base from the repository URL's scheme and host."""
+        match = re.match(r"(https?://[\w.\-]+(:\d+)?)/", repo_url)
+        if match:
+            return f"{match.group(1)}/api/v1"
+        raise ValueError(f"Cannot derive API base from URL: {repo_url}")
+
+    def get_headers(self, token: str) -> dict:
+        headers = super().get_headers(token)
+        headers["Accept"] = "application/json"
+        return headers
+
+    async def push_files(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        files: dict,
+        client: httpx.AsyncClient,
+        _allow_branch_create: bool = True,
+    ) -> dict:
+        """Push files via the Git Data API, normalising Gitea's list-shaped ref response."""
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            ref_response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers)
+
+            if ref_response.status_code == 404:
+                if not _allow_branch_create:
+                    return {
+                        "status": "failed",
+                        "message": (
+                            f"Branch '{branch}' not found after creation — possible replication lag. "
+                            "The next scheduled backup will retry."
+                        ),
+                    }
+                return await self._create_branch_and_push(
+                    client, headers, api_base, owner, repo, branch, files, repo_url, token
+                )
+
+            if ref_response.status_code != 200:
+                return {
+                    "status": "failed",
+                    "message": f"Failed to get branch ref: {ref_response.status_code}",
+                    "error": self._truncated_response_text(ref_response),
+                }
+
+            current_commit_sha = self._ref_sha(ref_response.json())
+
+            commit_response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers
+            )
+            if commit_response.status_code != 200:
+                msg = f"Failed to get current commit (HTTP {commit_response.status_code}): {self._truncated_response_text(commit_response)}"
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            current_tree_sha = self._commit_tree_sha(commit_response.json())
+            if not current_tree_sha:
+                msg = (
+                    f"Failed to extract tree SHA from commit response: {self._truncated_response_text(commit_response)}"
+                )
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            tree_response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers
+            )
+            if tree_response.status_code != 200:
+                msg = f"Failed to list existing tree (HTTP {tree_response.status_code}): {self._truncated_response_text(tree_response)}"
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg, "error": self._truncated_response_text(tree_response)}
+            tree_data = tree_response.json()
+            # Gitea's tree API can report ``truncated: true`` for large
+            # listings; if we honour the partial map, the dedup check misses
+            # and every file gets re-uploaded each run.
+            if tree_data.get("truncated"):
+                msg = (
+                    "Repository tree exceeds the Gitea API listing limit (truncated=true). "
+                    "Rotate the backup repository to avoid silent file-by-file churn on every backup."
+                )
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+            existing_files: dict[str, str] = {}
+            for item in tree_data.get("tree", []):
+                if item.get("type") != "blob":
+                    continue
+                path, sha = item.get("path"), item.get("sha")
+                if not path or not sha:
+                    logger.warning("push_files: skipping malformed tree entry: %s", item)
+                    continue
+                existing_files[path] = sha
+
+            api_files = []
+            files_changed = 0
+
+            for path, content in files.items():
+                content_str = json.dumps(content, indent=2, default=str)
+                content_bytes = content_str.encode("utf-8")
+                content_b64 = base64.b64encode(content_bytes).decode()
+                content_sha = self._blob_sha(content_bytes)
+
+                if path in existing_files:
+                    if existing_files[path] == content_sha:
+                        continue
+                    api_files.append(
+                        {"operation": "update", "path": path, "content": content_b64, "sha": existing_files[path]}
+                    )
+                else:
+                    api_files.append({"operation": "create", "path": path, "content": content_b64})
+                files_changed += 1
+
+            if not api_files:
+                return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
+
+            commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
+            response = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/contents",
+                headers=headers,
+                json={"branch": branch, "message": commit_message, "files": api_files},
+            )
+
+            if response.status_code == 404:
+                return {
+                    "status": "failed",
+                    "message": "Contents API endpoint not found — your Gitea instance may be older than v1.18 or the API may be disabled by an administrator (POST /contents returned 404)",
+                }
+            if response.status_code == 409:
+                return {
+                    "status": "failed",
+                    "message": (
+                        "Conflict committing files — the branch likely advanced concurrently "
+                        "(web-UI edit, another backup run, or path-vs-tree collision). "
+                        "The next scheduled backup will re-read the current tree and resolve this."
+                    ),
+                }
+            if response.status_code not in (200, 201):
+                return {
+                    "status": "failed",
+                    "message": f"Backup commit failed: {self._truncated_response_text(response)}",
+                }
+
+            commit_sha = (response.json().get("commit") or {}).get("sha")
+            message = (
+                f"Backup successful - {files_changed} files updated"
+                if commit_sha
+                else f"Backup successful - {files_changed} files updated (commit SHA not reported by server)"
+            )
+            return {
+                "status": "success",
+                "message": message,
+                "commit_sha": commit_sha,
+                "files_changed": files_changed,
+            }
+
+        except Exception as e:
+            logger.exception("push_files failed for %s branch=%s", repo_url, branch)
+            return {"status": "failed", "message": str(e), "error": str(e)}
+
+    async def _create_branch_and_push(
+        self,
+        client: httpx.AsyncClient,
+        headers: dict,
+        api_base: str,
+        owner: str,
+        repo: str,
+        branch: str,
+        files: dict,
+        repo_url: str,
+        token: str,
+    ) -> dict:
+        """Create branch (from default branch or as initial commit) then push."""
+        try:
+            repo_response = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
+            if repo_response.status_code != 200:
+                msg = f"Failed to get repo info (HTTP {repo_response.status_code}): {self._truncated_response_text(repo_response)}"
+                logger.warning("_create_branch_and_push %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            default_branch = repo_response.json().get("default_branch", "main")
+
+            # GET the default branch to confirm the repo is non-empty; SHA is intentionally unused —
+            # POST /branches takes a branch name, not a SHA.
+            ref_response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers
+            )
+            if ref_response.status_code != 200:
+                return await self._create_initial_commit(client, headers, api_base, owner, repo, branch, files)
+
+            create_ref = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/branches",
+                headers=headers,
+                json={"new_branch_name": branch, "old_ref_name": default_branch},
+            )
+            if create_ref.status_code == 403:
+                msg = f"Permission denied creating branch '{branch}' — token may lack write access to this repository"
+                logger.warning("_create_branch_and_push %s/%s: 403 %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+            if create_ref.status_code == 409:
+                msg = f"Branch '{branch}' already exists (possible race condition)"
+                logger.warning("_create_branch_and_push %s/%s: 409 %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+            if create_ref.status_code != 201:
+                msg = f"Failed to create branch '{branch}' (HTTP {create_ref.status_code}): {self._truncated_response_text(create_ref)}"
+                logger.warning("_create_branch_and_push %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            logger.info("Re-entering push_files after branch create %s/%s -> %s", owner, repo, branch)
+            return await self.push_files(repo_url, token, branch, files, client, _allow_branch_create=False)
+
+        except Exception as e:
+            logger.exception("_create_branch_and_push failed for %s/%s branch=%s", owner, repo, branch)
+            return {"status": "failed", "message": str(e), "error": str(e)}
+
+    async def _create_initial_commit(
+        self,
+        client: httpx.AsyncClient,
+        headers: dict,
+        api_base: str,
+        owner: str,
+        repo: str,
+        branch: str,
+        files: dict,
+    ) -> dict:
+        """Seed an empty Gitea repository via the Contents API.
+
+        Gitea's Git Data API requires the repository to have at least one
+        commit before it accepts blob/tree/commit writes; on an empty repo
+        every ``POST /git/blobs`` returns 404. The Contents API is the
+        documented bootstrap path: a single ``POST /repos/{owner}/{repo}/contents``
+        with a ``files`` array creates the initial commit and the target
+        branch in one round-trip (Gitea 1.18+, Forgejo all versions).
+        """
+        try:
+            if not files:
+                return {"status": "skipped", "message": "No files to commit", "commit_sha": None, "files_changed": 0}
+
+            api_files = []
+            for path, content in files.items():
+                content_str = json.dumps(content, indent=2, default=str)
+                content_b64 = base64.b64encode(content_str.encode("utf-8")).decode()
+                api_files.append({"operation": "create", "path": path, "content": content_b64})
+
+            commit_message = f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
+            body = {
+                "branch": branch,
+                "new_branch": branch,
+                "message": commit_message,
+                "files": api_files,
+            }
+
+            response = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/contents",
+                headers=headers,
+                json=body,
+            )
+
+            if response.status_code not in (200, 201):
+                return {
+                    "status": "failed",
+                    "message": f"Failed to create initial commit: {self._truncated_response_text(response)}",
+                }
+
+            data = response.json()
+            commit_sha = (data.get("commit") or {}).get("sha")
+            message = (
+                f"Initial backup created - {len(files)} files"
+                if commit_sha
+                else f"Initial backup created - {len(files)} files (commit SHA not reported by server)"
+            )
+            return {
+                "status": "success",
+                "message": message,
+                "commit_sha": commit_sha,
+                "files_changed": len(files),
+            }
+
+        except Exception as e:
+            logger.exception("_create_initial_commit failed for %s/%s branch=%s", owner, repo, branch)
+            return {"status": "failed", "message": str(e), "error": str(e)}

+ 431 - 0
backend/app/services/git_providers/github.py

@@ -0,0 +1,431 @@
+"""GitHub backend — implements GitProviderBackend using the GitHub Git Data API."""
+
+import base64
+import json
+import logging
+import re
+from datetime import datetime, timezone
+
+import httpx
+
+from backend.app.services.git_providers.base import GitProviderBackend
+
+logger = logging.getLogger(__name__)
+
+
+class GitHubBackend(GitProviderBackend):
+    """Backend for github.com using the GitHub Git Data API."""
+
+    def get_api_base(self, repo_url: str) -> str:
+        m = re.match(r"https?://([\w.\-]+(:\d+)?)/", repo_url)
+        if m:
+            host = m.group(1)
+            return "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
+        m = re.match(r"git@([\w.\-]+):", repo_url)
+        if m:
+            host = m.group(1)
+            return "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
+        return "https://api.github.com"
+
+    def parse_repo_url(self, url: str) -> tuple[str, str]:
+        """Return (owner, repo) from a Git HTTPS or SSH URL."""
+        if not url or len(url) > 500:
+            raise ValueError("Invalid Git URL: URL too long or empty")
+
+        # HTTPS: https://<host>[:<port>]/<owner>/<repo>[.git][/]
+        match = re.match(
+            r"https://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
+            url,
+        )
+        if match:
+            return match.group(2), match.group(3).removesuffix(".git")
+
+        # SSH: git@<host>:<owner>/<repo>[.git]
+        match = re.match(
+            r"git@[\w.\-]+:([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?$",
+            url,
+        )
+        if match:
+            return match.group(1), match.group(2).removesuffix(".git")
+
+        raise ValueError(f"Cannot parse repository URL: {url}")
+
+    async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
+        """Test API access and push permission for the repository."""
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            response = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
+
+            if response.status_code == 401:
+                return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
+
+            if response.status_code == 404:
+                return {
+                    "success": False,
+                    "message": "Repository not found. Check URL and token permissions.",
+                    "repo_name": None,
+                    "permissions": None,
+                }
+
+            if response.status_code != 200:
+                return {
+                    "success": False,
+                    "message": f"API error: {response.status_code}",
+                    "repo_name": None,
+                    "permissions": None,
+                }
+
+            data = response.json()
+            permissions = data.get("permissions", {})
+
+            if not permissions.get("push", False):
+                return {
+                    "success": False,
+                    "message": "Token does not have push permission to this repository",
+                    "repo_name": data.get("full_name"),
+                    "permissions": permissions,
+                }
+
+            return {
+                "success": True,
+                "message": "Connection successful",
+                "repo_name": data.get("full_name"),
+                "permissions": permissions,
+            }
+
+        except Exception as e:
+            logger.exception("Git connection test failed")
+            detail = str(e)[:200]
+            message = (
+                f"Connection failed: {type(e).__name__}: {detail}"
+                if detail
+                else f"Connection failed: {type(e).__name__}"
+            )
+            return {
+                "success": False,
+                "message": message,
+                "repo_name": None,
+                "permissions": None,
+            }
+
+    async def push_files(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        files: dict,
+        client: httpx.AsyncClient,
+        _allow_branch_create: bool = True,
+    ) -> dict:
+        """Push files to the repository using the Git Data API."""
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            ref_response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers)
+
+            if ref_response.status_code == 404:
+                if not _allow_branch_create:
+                    return {
+                        "status": "failed",
+                        "message": (
+                            f"Branch '{branch}' not found after creation — possible replication lag. "
+                            "The next scheduled backup will retry."
+                        ),
+                    }
+                return await self._create_branch_and_push(
+                    client, headers, api_base, owner, repo, branch, files, repo_url, token
+                )
+
+            if ref_response.status_code != 200:
+                msg = f"Failed to get branch ref (HTTP {ref_response.status_code}): {self._truncated_response_text(ref_response)}"
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg, "error": self._truncated_response_text(ref_response)}
+
+            current_commit_sha, err = self._read_sha(ref_response, "object", "sha")
+            if err:
+                msg = f"Malformed ref response ({err}): {self._truncated_response_text(ref_response)}"
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            commit_response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers
+            )
+            if commit_response.status_code != 200:
+                msg = f"Failed to get current commit (HTTP {commit_response.status_code}): {self._truncated_response_text(commit_response)}"
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            current_tree_sha, err = self._read_sha(commit_response, "tree", "sha")
+            if err:
+                msg = f"Malformed commit response ({err}): {self._truncated_response_text(commit_response)}"
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            tree_response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers
+            )
+            if tree_response.status_code != 200:
+                msg = f"Failed to list existing tree (HTTP {tree_response.status_code}): {self._truncated_response_text(tree_response)}"
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg, "error": self._truncated_response_text(tree_response)}
+            tree_data = tree_response.json()
+            # GitHub's tree API truncates >7MB / >100k entries. A truncated tree
+            # listing makes the SHA-equality dedup miss and every file gets
+            # re-uploaded as a new blob each run — silent churn until someone
+            # notices the bloated history. Fail loudly so the user rotates the
+            # backup repo.
+            if tree_data.get("truncated"):
+                msg = (
+                    "Repository tree exceeds the GitHub API listing limit (truncated=true). "
+                    "Rotate the backup repository to avoid silent file-by-file churn on every backup."
+                )
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+            existing_files: dict[str, str] = {}
+            for item in tree_data.get("tree", []):
+                if item.get("type") != "blob":
+                    continue
+                path, sha = item.get("path"), item.get("sha")
+                if not path or not sha:
+                    logger.warning("push_files: skipping malformed tree entry: %s", item)
+                    continue
+                existing_files[path] = sha
+
+            tree_items = []
+            files_changed = 0
+
+            for path, content in files.items():
+                content_str = json.dumps(content, indent=2, default=str)
+                content_bytes = content_str.encode("utf-8")
+                content_sha = self._blob_sha(content_bytes)
+
+                if path in existing_files and existing_files[path] == content_sha:
+                    continue
+
+                blob_response = await client.post(
+                    f"{api_base}/repos/{owner}/{repo}/git/blobs",
+                    headers=headers,
+                    json={"content": base64.b64encode(content_bytes).decode(), "encoding": "base64"},
+                )
+                if blob_response.status_code == 404:
+                    msg = "GitHub API returned 404 for POST /git/blobs — check repository visibility and token scope"
+                    logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                    return {"status": "failed", "message": msg}
+                if blob_response.status_code != 201:
+                    msg = f"Failed to create blob for {path} (HTTP {blob_response.status_code}): {self._truncated_response_text(blob_response)}"
+                    logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                    return {"status": "failed", "message": msg}
+
+                blob_sha, err = self._read_sha(blob_response, "sha")
+                if err:
+                    msg = f"Malformed blob response for {path} ({err}): {self._truncated_response_text(blob_response)}"
+                    logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                    return {"status": "failed", "message": msg}
+                tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_sha})
+                files_changed += 1
+
+            if not tree_items:
+                return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
+
+            tree_response = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/git/trees",
+                headers=headers,
+                json={"base_tree": current_tree_sha, "tree": tree_items},
+            )
+            if tree_response.status_code != 201:
+                msg = f"Failed to create tree (HTTP {tree_response.status_code}): {self._truncated_response_text(tree_response)}"
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            new_tree_sha, err = self._read_sha(tree_response, "sha")
+            if err:
+                msg = f"Malformed tree-create response ({err}): {self._truncated_response_text(tree_response)}"
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+            commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
+            commit_response = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/git/commits",
+                headers=headers,
+                json={"message": commit_message, "tree": new_tree_sha, "parents": [current_commit_sha]},
+            )
+            if commit_response.status_code != 201:
+                msg = f"Failed to create commit (HTTP {commit_response.status_code}): {self._truncated_response_text(commit_response)}"
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            new_commit_sha, err = self._read_sha(commit_response, "sha")
+            if err:
+                msg = f"Malformed commit-create response ({err}): {self._truncated_response_text(commit_response)}"
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            ref_update = await client.patch(
+                f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{branch}",
+                headers=headers,
+                json={"sha": new_commit_sha},
+            )
+            if ref_update.status_code != 200:
+                msg = f"Failed to update branch (HTTP {ref_update.status_code}): {self._truncated_response_text(ref_update)}"
+                logger.warning("push_files %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            return {
+                "status": "success",
+                "message": f"Backup successful - {files_changed} files updated",
+                "commit_sha": new_commit_sha,
+                "files_changed": files_changed,
+            }
+
+        except Exception as e:
+            logger.exception("push_files failed for %s branch=%s", repo_url, branch)
+            return {"status": "failed", "message": str(e), "error": str(e)}
+
+    async def _create_branch_and_push(
+        self,
+        client: httpx.AsyncClient,
+        headers: dict,
+        api_base: str,
+        owner: str,
+        repo: str,
+        branch: str,
+        files: dict,
+        repo_url: str,
+        token: str,
+    ) -> dict:
+        """Create branch (from default branch or as initial commit) then push."""
+        try:
+            repo_response = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
+            if repo_response.status_code != 200:
+                msg = f"Failed to get repo info (HTTP {repo_response.status_code}): {self._truncated_response_text(repo_response)}"
+                logger.warning("_create_branch_and_push %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            try:
+                default_branch = repo_response.json().get("default_branch", "main")
+            except ValueError:
+                msg = f"Malformed repo-info response (non-JSON body): {self._truncated_response_text(repo_response)}"
+                logger.warning("_create_branch_and_push %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            ref_response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers
+            )
+            if ref_response.status_code != 200:
+                return await self._create_initial_commit(client, headers, api_base, owner, repo, branch, files)
+
+            base_sha, err = self._read_sha(ref_response, "object", "sha")
+            if err:
+                msg = f"Malformed default-branch ref response ({err}): {self._truncated_response_text(ref_response)}"
+                logger.warning("_create_branch_and_push %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            create_ref = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/git/refs",
+                headers=headers,
+                json={"ref": f"refs/heads/{branch}", "sha": base_sha},
+            )
+            if create_ref.status_code != 201:
+                msg = f"Failed to create branch '{branch}' (HTTP {create_ref.status_code}): {self._truncated_response_text(create_ref)}"
+                logger.warning("_create_branch_and_push %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            logger.info("Re-entering push_files after branch create %s/%s -> %s", owner, repo, branch)
+            return await self.push_files(repo_url, token, branch, files, client, _allow_branch_create=False)
+
+        except Exception as e:
+            logger.exception("_create_branch_and_push failed for %s/%s branch=%s", owner, repo, branch)
+            return {"status": "failed", "message": str(e), "error": str(e)}
+
+    async def _create_initial_commit(
+        self,
+        client: httpx.AsyncClient,
+        headers: dict,
+        api_base: str,
+        owner: str,
+        repo: str,
+        branch: str,
+        files: dict,
+    ) -> dict:
+        """Create the first commit in an empty repository."""
+        try:
+            tree_items = []
+            for path, content in files.items():
+                content_str = json.dumps(content, indent=2, default=str)
+                blob_response = await client.post(
+                    f"{api_base}/repos/{owner}/{repo}/git/blobs",
+                    headers=headers,
+                    json={"content": base64.b64encode(content_str.encode()).decode(), "encoding": "base64"},
+                )
+                if blob_response.status_code == 404:
+                    msg = "GitHub API returned 404 for POST /git/blobs — check repository visibility and token scope"
+                    logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
+                    return {"status": "failed", "message": msg}
+                if blob_response.status_code != 201:
+                    msg = f"Failed to create blob for {path} (HTTP {blob_response.status_code}): {self._truncated_response_text(blob_response)}"
+                    logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
+                    return {"status": "failed", "message": msg}
+                blob_sha, err = self._read_sha(blob_response, "sha")
+                if err:
+                    msg = f"Malformed blob response for {path} ({err}): {self._truncated_response_text(blob_response)}"
+                    logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
+                    return {"status": "failed", "message": msg}
+                tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_sha})
+
+            tree_response = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/git/trees",
+                headers=headers,
+                json={"tree": tree_items},
+            )
+            if tree_response.status_code != 201:
+                msg = f"Failed to create tree (HTTP {tree_response.status_code}): {self._truncated_response_text(tree_response)}"
+                logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            tree_sha, err = self._read_sha(tree_response, "sha")
+            if err:
+                msg = f"Malformed tree-create response ({err}): {self._truncated_response_text(tree_response)}"
+                logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+            commit_response = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/git/commits",
+                headers=headers,
+                json={
+                    "message": f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
+                    "tree": tree_sha,
+                },
+            )
+            if commit_response.status_code != 201:
+                msg = f"Failed to create commit (HTTP {commit_response.status_code}): {self._truncated_response_text(commit_response)}"
+                logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            commit_sha, err = self._read_sha(commit_response, "sha")
+            if err:
+                msg = f"Malformed commit-create response ({err}): {self._truncated_response_text(commit_response)}"
+                logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+            ref_response = await client.post(
+                f"{api_base}/repos/{owner}/{repo}/git/refs",
+                headers=headers,
+                json={"ref": f"refs/heads/{branch}", "sha": commit_sha},
+            )
+            if ref_response.status_code != 201:
+                msg = f"Failed to create branch ref (HTTP {ref_response.status_code}): {self._truncated_response_text(ref_response)}"
+                logger.warning("_create_initial_commit %s/%s: %s", owner, repo, msg)
+                return {"status": "failed", "message": msg}
+
+            return {
+                "status": "success",
+                "message": f"Initial backup created - {len(files)} files",
+                "commit_sha": commit_sha,
+                "files_changed": len(files),
+            }
+
+        except Exception as e:
+            logger.exception("_create_initial_commit failed for %s/%s branch=%s", owner, repo, branch)
+            return {"status": "failed", "message": str(e), "error": str(e)}

+ 257 - 0
backend/app/services/git_providers/gitlab.py

@@ -0,0 +1,257 @@
+"""GitLab backend — implements GitProviderBackend using the GitLab REST API v4."""
+
+import base64
+import json
+import logging
+import re
+import urllib.parse
+from datetime import datetime, timezone
+
+import httpx
+
+from backend.app.services.git_providers.base import GitProviderBackend
+
+logger = logging.getLogger(__name__)
+
+
+class GitLabBackend(GitProviderBackend):
+    """Backend for gitlab.com and self-hosted GitLab instances."""
+
+    def get_api_base(self, repo_url: str) -> str:
+        match = re.match(r"(https?://[\w.\-]+(:\d+)?)/", repo_url)
+        if not match:
+            raise ValueError(f"Cannot derive API base from URL: {repo_url}")
+        return f"{match.group(1)}/api/v4"
+
+    def get_headers(self, token: str) -> dict:
+        return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
+
+    def parse_repo_url(self, url: str) -> tuple[str, str]:
+        """Return (namespace, repo) from HTTPS or SSH URL.
+
+        namespace may include subgroups, e.g. 'group/subgroup' for
+        gitlab.com/group/subgroup/project. Callers join them with '/' and
+        URL-encode the result for /api/v4/projects/{encoded_path}.
+        """
+        if not url or len(url) > 500:
+            raise ValueError("Invalid Git URL: URL too long or empty")
+        match = re.match(r"https?://[\w.\-]+(:\d+)?/(.+?)(?:\.git)?/?$", url)
+        if match:
+            full_path = match.group(2)
+            if "/" not in full_path:
+                raise ValueError(f"Cannot parse repository URL: {url}")
+            namespace, _, repo = full_path.rpartition("/")
+            return namespace, repo
+        match = re.match(r"git@[\w.\-]+:(.+?)(?:\.git)?$", url)
+        if match:
+            full_path = match.group(1)
+            if "/" not in full_path:
+                raise ValueError(f"Cannot parse repository URL: {url}")
+            namespace, _, repo = full_path.rpartition("/")
+            return namespace, repo
+        raise ValueError(f"Cannot parse repository URL: {url}")
+
+    async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+            encoded_path = urllib.parse.quote(f"{owner}/{repo}", safe="")
+
+            response = await client.get(f"{api_base}/projects/{encoded_path}", headers=headers)
+
+            if response.status_code == 401:
+                return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
+            if response.status_code == 404:
+                return {
+                    "success": False,
+                    "message": "Repository not found. Check URL and token permissions.",
+                    "repo_name": None,
+                    "permissions": None,
+                }
+            if response.status_code != 200:
+                return {
+                    "success": False,
+                    "message": f"API error: {response.status_code}",
+                    "repo_name": None,
+                    "permissions": None,
+                }
+
+            data = response.json()
+            perms = data.get("permissions") or {}
+            project_level = (perms.get("project_access") or {}).get("access_level", 0)
+            group_level = (perms.get("group_access") or {}).get("access_level", 0)
+            effective = max(project_level, group_level)
+
+            if effective < 30:  # Developer = 30, Maintainer = 40, Owner = 50
+                return {
+                    "success": False,
+                    "message": "Token requires Developer access or higher to push",
+                    "repo_name": data.get("name_with_namespace"),
+                    "permissions": perms,
+                }
+
+            return {
+                "success": True,
+                "message": "Connection successful",
+                "repo_name": data.get("name_with_namespace"),
+                "permissions": perms,
+            }
+        except Exception as e:
+            logger.error("GitLab connection test failed: %s", e)
+            return {
+                "success": False,
+                "message": f"Connection failed: {type(e).__name__}",
+                "repo_name": None,
+                "permissions": None,
+            }
+
+    async def push_files(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        files: dict,
+        client: httpx.AsyncClient,
+    ) -> dict:
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+            encoded_path = urllib.parse.quote(f"{owner}/{repo}", safe="")
+
+            encoded_branch = urllib.parse.quote(branch, safe="")
+            branch_response = await client.get(
+                f"{api_base}/projects/{encoded_path}/repository/branches/{encoded_branch}",
+                headers=headers,
+            )
+
+            if branch_response.status_code == 404:
+                proj_response = await client.get(f"{api_base}/projects/{encoded_path}", headers=headers)
+                if proj_response.status_code != 200:
+                    return {"status": "failed", "message": "Failed to get project info"}
+
+                default_branch = proj_response.json().get("default_branch", "main")
+                default_encoded = urllib.parse.quote(default_branch, safe="")
+                default_response = await client.get(
+                    f"{api_base}/projects/{encoded_path}/repository/branches/{default_encoded}",
+                    headers=headers,
+                )
+
+                if default_response.status_code != 200:
+                    return await self._create_initial_commit(client, headers, api_base, encoded_path, branch, files)
+
+                create_response = await client.post(
+                    f"{api_base}/projects/{encoded_path}/repository/branches",
+                    headers=headers,
+                    json={"branch": branch, "ref": default_branch},
+                )
+                if create_response.status_code not in (200, 201):
+                    return {"status": "failed", "message": f"Failed to create branch: {create_response.status_code}"}
+            elif branch_response.status_code != 200:
+                return {"status": "failed", "message": f"Failed to check branch: {branch_response.status_code}"}
+
+            existing_blobs: dict[str, str] = {}
+            page = 1
+            while True:
+                tree_response = await client.get(
+                    f"{api_base}/projects/{encoded_path}/repository/tree",
+                    headers=headers,
+                    params={"recursive": "true", "ref": branch, "per_page": 100, "page": page},
+                )
+                if tree_response.status_code != 200:
+                    break
+                items = tree_response.json()
+                if not items:
+                    break
+                for item in items:
+                    if item.get("type") == "blob":
+                        existing_blobs[item["path"]] = item["id"]
+                page += 1
+
+            actions = []
+            for path, content in files.items():
+                content_str = json.dumps(content, indent=2, default=str)
+                content_bytes = content_str.encode("utf-8")
+                content_sha = self._blob_sha(content_bytes)
+
+                if path in existing_blobs and existing_blobs[path] == content_sha:
+                    continue
+
+                actions.append(
+                    {
+                        "action": "update" if path in existing_blobs else "create",
+                        "file_path": path,
+                        "content": base64.b64encode(content_bytes).decode(),
+                        "encoding": "base64",
+                    }
+                )
+
+            if not actions:
+                return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
+
+            commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
+            commit_response = await client.post(
+                f"{api_base}/projects/{encoded_path}/repository/commits",
+                headers=headers,
+                json={"branch": branch, "commit_message": commit_message, "actions": actions},
+            )
+            if commit_response.status_code not in (200, 201):
+                return {
+                    "status": "failed",
+                    "message": f"Failed to create commit: {self._truncated_response_text(commit_response)}",
+                }
+
+            return {
+                "status": "success",
+                "message": f"Backup successful - {len(actions)} files updated",
+                "commit_sha": commit_response.json().get("id"),
+                "files_changed": len(actions),
+            }
+        except Exception as e:
+            logger.error("Push to GitLab failed: %s", e)
+            return {"status": "failed", "message": str(e), "error": str(e)}
+
+    async def _create_initial_commit(
+        self,
+        client: httpx.AsyncClient,
+        headers: dict,
+        api_base: str,
+        encoded_path: str,
+        branch: str,
+        files: dict,
+    ) -> dict:
+        """Create the first commit in an empty repository."""
+        try:
+            actions = []
+            for path, content in files.items():
+                content_str = json.dumps(content, indent=2, default=str)
+                actions.append(
+                    {
+                        "action": "create",
+                        "file_path": path,
+                        "content": base64.b64encode(content_str.encode()).decode(),
+                        "encoding": "base64",
+                    }
+                )
+
+            commit_message = f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}"
+            commit_response = await client.post(
+                f"{api_base}/projects/{encoded_path}/repository/commits",
+                headers=headers,
+                json={"branch": branch, "commit_message": commit_message, "actions": actions, "start_branch": branch},
+            )
+            if commit_response.status_code not in (200, 201):
+                return {
+                    "status": "failed",
+                    "message": f"Failed to create initial commit: {self._truncated_response_text(commit_response)}",
+                }
+
+            return {
+                "status": "success",
+                "message": f"Initial backup created - {len(files)} files",
+                "commit_sha": commit_response.json().get("id"),
+                "files_changed": len(files),
+            }
+        except Exception as e:
+            return {"status": "failed", "message": str(e)}

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott