Просмотр исходного кода

Merge branch 'dev' into feature/p2s-x2d-accessory-fans

MartinNYHC 1 месяц назад
Родитель
Сommit
9ebfcddbdb
79 измененных файлов с 6098 добавлено и 280 удалено
  1. 1 0
      BACKERS.md
  2. 8 0
      CHANGELOG.md
  3. 1 0
      README.md
  4. 14 10
      backend/app/api/routes/_oidc_helpers.py
  5. 10 56
      backend/app/api/routes/_spoolman_helpers.py
  6. 77 10
      backend/app/api/routes/_url_safety.py
  7. 10 0
      backend/app/api/routes/archives.py
  8. 284 29
      backend/app/api/routes/camera.py
  9. 35 2
      backend/app/api/routes/library.py
  10. 100 13
      backend/app/api/routes/settings.py
  11. 36 6
      backend/app/api/routes/support.py
  12. 110 0
      backend/app/core/database.py
  13. 84 15
      backend/app/main.py
  14. 24 15
      backend/app/schemas/auth.py
  15. 56 1
      backend/app/schemas/settings.py
  16. 96 15
      backend/app/services/bambu_mqtt.py
  17. 133 3
      backend/app/services/camera.py
  18. 25 2
      backend/app/services/camera_diagnose.py
  19. 24 4
      backend/app/services/external_camera.py
  20. 20 1
      backend/app/services/layer_timelapse.py
  21. 94 7
      backend/app/services/notification_service.py
  22. 15 0
      backend/app/services/obico_detection.py
  23. 20 8
      backend/app/services/plate_detection.py
  24. 117 17
      backend/app/services/print_scheduler.py
  25. 15 2
      backend/app/services/slicer_3mf_convert.py
  26. 45 0
      backend/app/utils/threemf_tools.py
  27. 362 5
      backend/tests/integration/test_library_slice_api.py
  28. 288 0
      backend/tests/unit/services/test_camera_capture_coalescing.py
  29. 73 0
      backend/tests/unit/services/test_camera_diagnose.py
  30. 39 16
      backend/tests/unit/services/test_notification_service.py
  31. 50 0
      backend/tests/unit/services/test_slicer_3mf_convert.py
  32. 255 0
      backend/tests/unit/services/test_total_layers_print_start.py
  33. 118 7
      backend/tests/unit/test_camera_ffmpeg_termination.py
  34. 287 0
      backend/tests/unit/test_camera_stderr_tail.py
  35. 238 0
      backend/tests/unit/test_camera_stream_registry_isolation.py
  36. 4 0
      backend/tests/unit/test_camera_usb_stream_cleanup.py
  37. 29 0
      backend/tests/unit/test_config_env_warnings.py
  38. 144 0
      backend/tests/unit/test_dispatch_claim_recovery.py
  39. 263 0
      backend/tests/unit/test_expected_print_rollback.py
  40. 297 0
      backend/tests/unit/test_external_camera_live_frame_reuse.py
  41. 376 0
      backend/tests/unit/test_outbound_url_ssrf_guards.py
  42. 166 0
      backend/tests/unit/test_pool_fits_server.py
  43. 163 0
      backend/tests/unit/test_spoolman_settings_value_coercion.py
  44. 166 0
      backend/tests/unit/test_support_helpers.py
  45. 107 0
      backend/tests/unit/test_telegram_forum_topic.py
  46. 106 0
      backend/tests/unit/test_threemf_tools.py
  47. 2 0
      frontend/src/App.tsx
  48. 90 0
      frontend/src/__tests__/components/AddNotificationModal.test.tsx
  49. 96 0
      frontend/src/__tests__/components/SliceModal.test.tsx
  50. 185 0
      frontend/src/__tests__/contexts/SliceJobTrackerContext.test.tsx
  51. 110 0
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  52. 157 0
      frontend/src/__tests__/hooks/usePrintProgressTitle.test.tsx
  53. 4 1
      frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx
  54. 44 1
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  55. 14 0
      frontend/src/api/client.ts
  56. 22 0
      frontend/src/components/AddNotificationModal.tsx
  57. 8 2
      frontend/src/components/SliceModal.tsx
  58. 47 15
      frontend/src/contexts/SliceJobTrackerContext.tsx
  59. 15 0
      frontend/src/contexts/ThemeContext.tsx
  60. 38 11
      frontend/src/hooks/useFilamentMapping.ts
  61. 162 0
      frontend/src/hooks/usePrintProgressTitle.ts
  62. 3 3
      frontend/src/i18n/index.ts
  63. 6 0
      frontend/src/i18n/locales/de.ts
  64. 6 0
      frontend/src/i18n/locales/en.ts
  65. 6 0
      frontend/src/i18n/locales/es.ts
  66. 6 0
      frontend/src/i18n/locales/fr.ts
  67. 6 0
      frontend/src/i18n/locales/it.ts
  68. 6 0
      frontend/src/i18n/locales/ja.ts
  69. 6 0
      frontend/src/i18n/locales/ko.ts
  70. 6 0
      frontend/src/i18n/locales/pt-BR.ts
  71. 6 0
      frontend/src/i18n/locales/ru.ts
  72. 6 0
      frontend/src/i18n/locales/tr.ts
  73. 6 0
      frontend/src/i18n/locales/uk.ts
  74. 6 0
      frontend/src/i18n/locales/zh-CN.ts
  75. 6 0
      frontend/src/i18n/locales/zh-TW.ts
  76. 18 2
      frontend/src/pages/FileManagerPage.tsx
  77. 19 0
      frontend/src/pages/SettingsPage.tsx
  78. 0 0
      static/assets/index-apAuCUp0.js
  79. 1 1
      static/index.html

+ 1 - 0
BACKERS.md

@@ -37,6 +37,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
 - [@MethodicalMartian](https://github.com/MethodicalMartian)
 - [@brianharwell](https://github.com/brianharwell)
 - [@shosier01](https://github.com/shosier01)
+- [@freifunk-bamberg](https://github.com/freifunk-bamberg)
 
 ## Backers ($5/mo+)
 

Разница между файлами не показана из-за своего большого размера
+ 8 - 0
CHANGELOG.md


+ 1 - 0
README.md

@@ -156,6 +156,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
+- **Print progress in the browser tab** — optional (off by default, toggle under Settings → Appearance): shows the soonest-finishing print's percentage in the tab title and a progress-ring favicon in your theme accent colour
 - 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
 - **Cam Wall view** — Toggle the Printers page from cards into a responsive grid of camera tiles for at-a-glance monitoring across the whole farm. On-screen tiles stream live up to a configurable cap (default 4) so RPi installs stay sustainable; the rest fall back to periodic snapshot polling, and off-screen tiles pause entirely. Per-user settings (live cap, snapshot interval); click any tile to open the floating viewer or the dedicated camera window depending on your existing camera-view preference
 - **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)

+ 14 - 10
backend/app/api/routes/_oidc_helpers.py

@@ -1,9 +1,11 @@
 """Pure helper functions for OIDC routes.
 
-Hosts the SSRF guard for admin-supplied icon URLs. Stricter than
-``_spoolman_helpers.assert_safe_spoolman_url`` — Spoolman intentionally allows
-loopback/RFC-1918 (same-LAN topology) while OIDC icons must be reachable on
-the public internet (IdP-hosted), so private addresses there are SSRF probes.
+Hosts the public-internet SSRF guard, used for both admin-supplied icon URLs
+and OIDC issuer URLs (via ``schemas.auth._validate_issuer_url``). Stricter
+than ``_url_safety.assert_safe_lan_service_url`` — LAN services intentionally
+allow loopback/RFC-1918 (same-host/same-LAN topology) while an IdP must be
+reachable on the public internet, so a private address there is an SSRF probe
+rather than a configuration.
 """
 
 from __future__ import annotations
@@ -17,9 +19,10 @@ from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE
 def assert_safe_public_https_url(url: str) -> None:
     """Raise ValueError if *url* is unsafe to fetch as a public HTTPS resource.
 
-    Used for OIDC provider icon URLs (#1333). Stricter than the Spoolman SSRF
-    guard: also rejects loopback, private (RFC-1918), and link-local addresses
-    because an OIDC icon legitimately lives only on the public internet.
+    Used for OIDC provider icon URLs (#1333) and OIDC issuer URLs. Stricter
+    than the LAN-service SSRF guard: also rejects loopback, private
+    (RFC-1918), and link-local addresses because an IdP and its icon
+    legitimately live only on the public internet.
 
     Checks performed:
     - Scheme must be ``https`` (no ``http://``, ``file://``, ``gopher://``, …).
@@ -35,9 +38,10 @@ def assert_safe_public_https_url(url: str) -> None:
     - IPv4-mapped IPv6 (``::ffff:127.0.0.1``) — unwrapped before the IP-class
       check so an attacker can't bypass via IPv6 encoding.
 
-    Hostname-based addresses are accepted without DNS resolution (consistent
-    with ``_validate_issuer_url`` policy — the operator is trusted to
-    configure a sensible IdP host).
+    Hostname-based addresses are accepted without DNS resolution — the
+    operator is trusted to configure a sensible IdP host, and resolving here
+    would both add a TOCTOU gap (DNS can change between validation and
+    request) and make the validator issue network requests of its own.
     """
     parsed = urlparse(url)
     if parsed.scheme.lower() != "https":

+ 10 - 56
backend/app/api/routes/_spoolman_helpers.py

@@ -5,17 +5,15 @@ No heavy dependencies — importable in unit tests without the full backend stac
 
 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
 
-from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
+from backend.app.api.routes._url_safety import assert_safe_lan_service_url
 
 logger = logging.getLogger(__name__)
 
@@ -80,61 +78,17 @@ class NormalizedFilament(TypedDict):
 
 
 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()
+    """Raise ValueError if the Spoolman *url* should be blocked as an SSRF risk.
 
-    # 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 NUMERIC_IP_RE.match(hostname):
-        raise ValueError("Spoolman URL must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
+    Thin wrapper over the shared LAN-service policy — see
+    ``_url_safety.assert_safe_lan_service_url`` for what is and isn't
+    rejected, and why loopback/RFC-1918 are deliberately permitted (running
+    Spoolman on the same host or home LAN is THE normal topology).
 
-    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 = unwrap_ipv4_mapped(addr)
-
-    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")
+    Kept as a named function because the "Spoolman URL …" wording in its
+    errors is user-facing and asserted by existing tests.
+    """
+    assert_safe_lan_service_url(url, label="Spoolman URL")
 
 
 _COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")

+ 77 - 10
backend/app/api/routes/_url_safety.py

@@ -1,19 +1,31 @@
-"""Shared URL-safety primitives used by both SSRF guards in this package.
-
-The two top-level assertion functions —
-``_spoolman_helpers.assert_safe_spoolman_url`` (Spoolman, deliberately allows
-loopback/RFC-1918 because same-LAN deployment is the standard topology) and
-``_oidc_helpers.assert_safe_public_https_url`` (OIDC icons, must be reachable
-on the public internet, so loopback/private are rejected) — share the
-*data* (cloud-metadata IP set, numeric-encoded-IP regex) but not the
-*policy*. Only the data lives here. The functions stay in their respective
-modules with their distinct policies intact.
+"""Shared URL-safety primitives for the SSRF guards in this package.
+
+Bambuddy has exactly two outbound-URL policies, and which one applies is a
+property of the *service*, not of the caller:
+
+- **LAN-service** (``assert_safe_lan_service_url`` below) — the service
+  legitimately lives on the same host or home LAN, so loopback and RFC-1918
+  must be permitted; blocking them would break the normal topology. Used for
+  Spoolman, self-hosted notification servers (ntfy, Bark, Gotify, custom
+  webhooks), Home Assistant, the Obico ML endpoint and the slicer sidecars.
+- **Public-internet** (``_oidc_helpers.assert_safe_public_https_url``) — the
+  resource can only sensibly live on the public internet, so a private
+  address is an SSRF probe rather than a configuration. Used for OIDC issuer
+  and icon URLs.
+
+Both reject the cases that are dangerous regardless of topology: non-HTTP
+schemes, numeric-encoded IPs, cloud-metadata endpoints, multicast and
+unspecified addresses, and IPv4-mapped IPv6 encodings of any of the above.
+
+The LAN-service policy lives here because it now has several callers; the
+public-internet policy stays in ``_oidc_helpers`` next to its only consumer.
 """
 
 from __future__ import annotations
 
 import ipaddress
 import re
+from urllib.parse import urlparse
 
 # Cloud-provider metadata endpoints — the classic SSRF credential-exfil
 # targets. Both guards reject these unconditionally.
@@ -49,3 +61,58 @@ def unwrap_ipv4_mapped(
     if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
         return addr.ipv4_mapped
     return addr
+
+
+def assert_safe_lan_service_url(url: str, *, label: str) -> None:
+    """Raise ValueError if *url* is unsafe for a service that may live on the LAN.
+
+    ``label`` names the setting in the error message ("Spoolman URL", "ntfy
+    server URL", …) so the user sees which field they need to correct.
+
+    Loopback (127.0.0.1) and RFC-1918 private ranges are deliberately
+    **permitted** — Bambuddy is self-hosted and running Spoolman, ntfy,
+    Bark, Home Assistant, an Obico ML endpoint or a slicer sidecar on the
+    same host or home LAN is THE normal topology, not an attack. A blanket
+    private-address block would break those integrations for most installs.
+
+    What is rejected is dangerous under any topology:
+
+    - Schemes other than http/https. ``httpx`` already raises
+      ``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
+      about returning a clear validation error at configuration time rather
+      than an opaque failure at delivery time.
+    - Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) —
+      libc and browsers resolve these, but Python's ``ipaddress`` raises
+      ValueError on them, so they would slip past the checks below.
+    - Cloud-provider metadata endpoints — the high-value SSRF target, and
+      never a legitimate destination for any of these services.
+    - Multicast and unspecified addresses — pointless as a destination and
+      indicative of misuse.
+    - IPv4-mapped IPv6 encodings of any of the above.
+
+    Symbolic hostnames are accepted without DNS resolution, matching the
+    public-internet guard: resolution here would be both a TOCTOU (DNS can
+    change between validation and request) and a request the validator
+    shouldn't be making.
+    """
+    parsed = urlparse(url)
+    if parsed.scheme.lower() not in ("http", "https"):
+        raise ValueError(f"{label} must use http or https")
+
+    hostname = (parsed.hostname or "").lower()
+
+    if NUMERIC_IP_RE.match(hostname):
+        raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
+
+    try:
+        addr = ipaddress.ip_address(hostname)
+    except ValueError:
+        return  # symbolic hostname — out of scope by design (no DNS check)
+
+    effective = unwrap_ipv4_mapped(addr)
+
+    if effective in CLOUD_METADATA_IPS:
+        raise ValueError(f"{label} must not point to a cloud metadata endpoint")
+
+    if effective.is_multicast or effective.is_unspecified:
+        raise ValueError(f"{label} must not point to a multicast or unspecified address")

+ 10 - 0
backend/app/api/routes/archives.py

@@ -33,6 +33,7 @@ from backend.app.services.design_settings import overrides_from_config
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.safe_path import safe_join_under
 from backend.app.utils.threemf_tools import (
+    expand_to_project_slots,
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
@@ -3937,6 +3938,7 @@ async def get_filament_requirements(
     archive_id: int,
     plate_id: int | None = None,
     request_id: str | None = None,
+    full_slots: bool = False,
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -4046,6 +4048,14 @@ async def get_filament_requirements(
                                 }
                             )
 
+            # Re-slicing a source that already carries slice_info (#2712).
+            # See library.py for the full rationale: the slice modal's list is
+            # positional, so a source using only slot 4 must still present
+            # four slots or the pick lands on slot 1. The print path keeps the
+            # used-only list it depends on.
+            if full_slots and filaments:
+                filaments = expand_to_project_slots(zf, filaments)
+
             # 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

+ 284 - 29
backend/app/api/routes/camera.py

@@ -1,10 +1,13 @@
 """Camera streaming API endpoints for Bambu Lab printers."""
 
 import asyncio
+import contextlib
 import logging
 import os
 import subprocess
 import sys
+import time
+import uuid
 from collections.abc import AsyncGenerator
 
 from fastapi import APIRouter, Depends, HTTPException, Request
@@ -46,12 +49,25 @@ from backend.app.services.camera_profiles import get_camera_profile
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["camera"])
 
-# Upper bound on waiting for a SIGKILLed ffmpeg to be reaped (#2580). A killed
-# ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily
-# long to exit — an unbounded post-kill wait() parked the fan-out stream
-# coroutine for 12 hours on a P2S, leaving every viewer attached to a stalled
-# broadcaster. Abandoning the wait is safe: cleanup_orphaned_streams' /proc scan
-# reaps any Bambu ffmpeg not attached to an active stream on its next pass.
+# Grace period for a SIGTERMed ffmpeg to shut down before we SIGKILL it. Only
+# reachable when ffmpeg genuinely ignores SIGTERM: _terminate_ffmpeg drains the
+# pipes first, and a drained ffmpeg exits in ~0.15s.
+_FFMPEG_TERM_TIMEOUT = 2.0
+
+# Upper bound on waiting for a SIGKILLed ffmpeg to be reaped (#2580).
+#
+# The original diagnosis — "a killed ffmpeg stuck in uninterruptible I/O on a
+# dead RTSP socket" — was wrong, and this bound was capping a deadlock of our
+# own making rather than waiting out a stuck process. A process that survives
+# SIGKILL would have to be in uninterruptible sleep (state D); the ffmpeg seen
+# doing this was in state S, and its returncode was already set to -9 while
+# wait() was still blocked. The real cause was undrained pipes (see
+# _terminate_ffmpeg), which made this timeout fire on *every* camera close.
+#
+# Kept as a backstop now that the cause is fixed: it should no longer be
+# reachable, and if it ever is, abandoning the wait is still safe because
+# cleanup_orphaned_streams' /proc scan reaps any Bambu ffmpeg not attached to
+# an active stream on its next pass.
 _FFMPEG_KILL_TIMEOUT = 2.0
 
 # Track active ffmpeg processes for cleanup
@@ -83,6 +99,14 @@ _disconnect_events: dict[str, asyncio.Event] = {}
 # Track last frame time per stream_id (not just per printer_id) for stale detection
 _stream_last_frame_times: dict[str, float] = {}
 
+# How much of a streaming ffmpeg's stderr to retain: enough for the input
+# analysis plus a burst of errors, capped so a long-running stream can't grow it.
+_FFMPEG_STDERR_TAIL_BYTES = 16384
+
+# Live stderr collectors by pid — see _FfmpegStderrTail. Present means "this
+# process's stderr already has a reader; do not open a second one".
+_stderr_tails: dict[int, "_FfmpegStderrTail"] = {}
+
 
 def get_buffered_frame(printer_id: int) -> bytes | None:
     """Get the last buffered frame for a printer from an active stream.
@@ -194,8 +218,6 @@ async def generate_chamber_mjpeg_stream(
 
             # Save frame to buffer for photo capture and track timestamp
             if printer_id is not None:
-                import time
-
                 _last_frames[printer_id] = frame
                 _last_frame_times[printer_id] = time.time()
 
@@ -227,10 +249,7 @@ async def generate_chamber_mjpeg_stream(
             _stream_last_frame_times.pop(stream_id, None)
 
         # Clean up frame buffer and timestamps
-        if printer_id is not None:
-            _last_frames.pop(printer_id, None)
-            _last_frame_times.pop(printer_id, None)
-            _stream_start_times.pop(printer_id, None)
+        _release_printer_frame_state(printer_id)
 
         # Close the connection
         try:
@@ -241,14 +260,127 @@ async def generate_chamber_mjpeg_stream(
         logger.info("Chamber image stream stopped for %s (stream_id=%s)", ip_address, stream_id)
 
 
+def _new_fanout_stream_id(printer_id: int) -> str:
+    """Registry key for one fan-out stream INSTANCE, not for the printer.
+
+    A plain ``f"{printer_id}-fanout"`` meant every successive stream for a
+    printer shared one key, so a departing generator's cleanup removed the entry
+    its successor had just registered. The external-camera path already carries a
+    per-instance suffix for exactly this reason (#2675); this gives the fan-out
+    path the same property.
+
+    The ``f"{printer_id}-"`` prefix is load-bearing — ``is_stream_active``,
+    ``stop_camera_stream`` and ``/camera/status`` all find a printer's streams by
+    scanning for it — so the suffix goes on the end.
+    """
+    return f"{printer_id}-fanout-{uuid.uuid4().hex[:8]}"
+
+
+def live_frame_for_capture(printer_id: int) -> tuple[bool, bytes | None]:
+    """Should a one-shot capture stand down for the live view, and to what frame?
+
+    Returns ``(defer, frame)``. ``defer`` True means DO NOT open a capture of
+    your own: use ``frame`` when it isn't None, and otherwise skip this attempt
+    rather than competing.
+
+    Both camera kinds allow exactly one reader — Bambu firmware permits one
+    connection, and a USB camera permits one V4L2 handle — so a capture that
+    races the live view doesn't degrade, it fails outright. #2707 measured 0 of
+    87 and 0 of 105 layer-timelapse captures on prints watched throughout, and
+    finish photos going out with no image attached.
+
+    Skipping when the buffer is momentarily empty (stream starting, mid-
+    reconnect) rather than falling through to a capture is the #1348 rule:
+    opening a competing handle kicks the viewer off, which is a worse outcome
+    than missing one frame.
+    """
+    if not is_stream_active(printer_id):
+        return False, None
+    return True, _last_frames.get(printer_id)
+
+
+def _release_printer_frame_state(printer_id: int | None) -> None:
+    """Drop a printer's buffered frame and timings — unless a stream still owns them.
+
+    These three dicts are keyed by printer, not by stream, so a departing
+    generator must not clear them while a newer stream for the same printer is
+    running. That used to happen routinely: stream ids were per-printer, so a
+    predecessor's cleanup wiped its successor's state, leaving
+    ``is_stream_active()`` False with a viewer attached (which is exactly what
+    the #1348 / #1271 guards read before deciding whether it is safe to open a
+    second camera connection), the janitor free to reap the live ffmpeg as an
+    orphan, and snapshots without a frame to reuse.
+
+    Call this AFTER removing the departing stream's own key, so the check
+    reports on other streams rather than on the caller.
+    """
+    if printer_id is None or is_stream_active(printer_id):
+        return
+    _last_frames.pop(printer_id, None)
+    _last_frame_times.pop(printer_id, None)
+    _stream_start_times.pop(printer_id, None)
+
+
+async def _drain_pipe(reader) -> None:
+    """Read a subprocess pipe to EOF and discard, so it can never block.
+
+    Best-effort by design: any read failure means we cannot drain further, and
+    the caller is tearing the process down regardless.
+    """
+    if reader is None:
+        return
+    try:
+        while await reader.read(65536):
+            pass
+    except asyncio.CancelledError:
+        raise
+    except Exception:  # noqa: BLE001 — teardown must not fail on a dying pipe
+        return
+
+
 async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str | None = None) -> None:
-    """Terminate an ffmpeg process gracefully, then kill if needed."""
+    """Terminate an ffmpeg process gracefully, then kill if needed.
+
+    Drains stdout/stderr throughout, which is load-bearing rather than hygiene.
+    ffmpeg is spawned with both as pipes, and every caller of this has already
+    stopped reading stdout — so by the time we get here ffmpeg is typically
+    blocked in write() on a full 64 KiB pipe. Two things then go wrong:
+
+    * SIGTERM cannot be acted on. ffmpeg's handler only sets a flag that its
+      main loop polls, and a loop blocked in write() never reaches the check,
+      so the whole grace period is dead time.
+    * SIGKILL does kill it, but wait() cannot observe that. asyncio resolves
+      Process.wait()'s waiter through BaseSubprocessTransport._try_finish(),
+      which requires every pipe transport to report disconnected; paused,
+      unread pipes never reach EOF, so wait() blocks with returncode already
+      set. That is what made the "did not exit within Ns of SIGKILL" error
+      fire on every single camera close, and unbounded it was the 12-hour
+      hang in #2580.
+
+    Draining fixes both: SIGTERM becomes actionable and the exit observable.
+    Measured on an H2D: 4.0s of dead time per close before, ~0.15s after —
+    which matters because the printer allows exactly one camera connection,
+    so every one of those seconds was a connection nobody could use.
+
+    Discarding what we drain is deliberate. The stream loop already reads
+    stderr on its error paths (_read_ffmpeg_stderr), and it does so before
+    calling this, so nothing diagnostic is lost.
+    """
     if process.returncode is not None:
+        _spawned_ffmpeg_pids.pop(process.pid, None)
         return  # Already dead
+
+    drainers = [asyncio.create_task(_drain_pipe(process.stdout))]
+    # A streaming ffmpeg's stderr already has a reader (_FfmpegStderrTail), and
+    # it keeps draining right through teardown, which is all we need here. Adding
+    # a second reader would race it — asyncio rejects concurrent reads on one
+    # StreamReader — so only drain stderr when nobody else owns it.
+    if process.pid not in _stderr_tails:
+        drainers.append(asyncio.create_task(_drain_pipe(process.stderr)))
     try:
         process.terminate()
         try:
-            await asyncio.wait_for(process.wait(), timeout=2.0)
+            await asyncio.wait_for(process.wait(), timeout=_FFMPEG_TERM_TIMEOUT)
         except TimeoutError:
             logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
             process.kill()
@@ -257,7 +389,8 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
             except TimeoutError:
                 # Do NOT keep waiting (#2580): the caller is the stream
                 # generator, and blocking here pins the fan-out pump forever.
-                # The orphan janitor reaps the process later.
+                # The orphan janitor reaps the process later. With the pipes
+                # drained this should be unreachable — see _FFMPEG_KILL_TIMEOUT.
                 logger.error(
                     "ffmpeg did not exit within %.1fs of SIGKILL; abandoning wait (stream_id=%s)",
                     _FFMPEG_KILL_TIMEOUT,
@@ -267,7 +400,11 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
         pass  # Already dead
     except OSError as e:
         logger.warning("Error terminating ffmpeg: %s", e)
-    _spawned_ffmpeg_pids.pop(process.pid, None)
+    finally:
+        for drainer in drainers:
+            drainer.cancel()
+        await asyncio.gather(*drainers, return_exceptions=True)
+        _spawned_ffmpeg_pids.pop(process.pid, None)
 
 
 def _summarize_ffmpeg_stderr(text: str | None) -> str:
@@ -303,6 +440,82 @@ def _summarize_ffmpeg_stderr(text: str | None) -> str:
     return "\n".join(meaningful[-10:])
 
 
+class _FfmpegStderrTail:
+    """Owns a long-lived ffmpeg's stderr: drains it continuously, keeps the tail.
+
+    Reading stderr only when something has already gone wrong leaves a pipe
+    nobody reads for the whole life of the stream. ffmpeg writes its banner, the
+    input analysis and then a progress line at a steady rate, so a 64 KiB pipe
+    fills eventually and ffmpeg blocks writing to it — at which point it stops
+    producing frames, the stream's own read timeout fires, and the log says
+    "RTSP read timeout" with no hint that we starved it ourselves.
+
+    How long that takes is unmeasured and may be a long time: one H2D upstream
+    ran 21m36s continuously without stalling, so this is a bounded resource
+    being treated as unbounded rather than an observed failure. Draining removes
+    the ceiling either way, and the tail is *better* diagnostic material than
+    the old on-demand read: it holds ffmpeg's most recent output at the moment
+    things went wrong, where reading the buffered pipe returned whatever was
+    printed first (usually the startup banner, which the summariser then strips).
+
+    Registers itself in ``_stderr_tails`` so the two other readers of this pipe
+    can defer to it — asyncio raises if two coroutines read one StreamReader
+    concurrently. See ``_read_ffmpeg_stderr`` and ``_terminate_ffmpeg``.
+    """
+
+    def __init__(self, process: asyncio.subprocess.Process) -> None:
+        self._process = process
+        self._buffer = bytearray()
+        self._task: asyncio.Task | None = None
+        if process.stderr is None:
+            return
+        self._task = asyncio.create_task(self._pump())
+        _stderr_tails[process.pid] = self
+
+    async def _pump(self) -> None:
+        reader = self._process.stderr
+        try:
+            while True:
+                chunk = await reader.read(8192)
+                if not chunk:
+                    return  # EOF — ffmpeg has exited
+                self._buffer.extend(chunk)
+                excess = len(self._buffer) - _FFMPEG_STDERR_TAIL_BYTES
+                if excess > 0:
+                    del self._buffer[:excess]
+        except asyncio.CancelledError:
+            raise
+        except Exception:  # noqa: BLE001 — a broken pipe just ends the tail
+            return
+
+    def text(self) -> str | None:
+        """The retained tail, summarised. None when nothing was captured.
+
+        Goes through _summarize_ffmpeg_stderr like every other stderr log in
+        this module: ffmpeg echoes its input URL, which carries the access code.
+        """
+        if not self._buffer:
+            return None
+        return _summarize_ffmpeg_stderr(self._buffer.decode(errors="replace")) or None
+
+    async def aclose(self) -> None:
+        """Stop draining and release ownership of the pipe. Idempotent.
+
+        Awaits the cancelled pump rather than firing and forgetting, so the task
+        is finished before the caller moves on — an abandoned pending task
+        becomes an "unraisable exception" warning at an arbitrary later point,
+        usually during interpreter or loop teardown.
+        """
+        task, self._task = self._task, None
+        if _stderr_tails.get(self._process.pid) is self:
+            del _stderr_tails[self._process.pid]
+        if task is None:
+            return
+        task.cancel()
+        with contextlib.suppress(asyncio.CancelledError):
+            await task
+
+
 async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None:
     """Read whatever ffmpeg has written to stderr so far (best-effort).
 
@@ -313,8 +526,18 @@ async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None
     banner + stream-analysis lines ffmpeg already printed. Reading in bounded
     chunks returns the buffered output promptly whether or not ffmpeg has
     exited. Returns the content with ffmpeg's boilerplate banner stripped.
+
+    When a _FfmpegStderrTail owns this process's stderr — every streaming
+    ffmpeg — its retained tail is returned instead. Reading the pipe here as
+    well would race that collector, and asyncio refuses two concurrent readers
+    on one StreamReader outright.
     """
-    if not process or not process.stderr:
+    if not process:
+        return None
+    tail = _stderr_tails.get(getattr(process, "pid", None))
+    if tail is not None:
+        return tail.text()
+    if not process.stderr:
         return None
     chunks: list[bytes] = []
     total = 0
@@ -435,6 +658,7 @@ async def generate_rtsp_mjpeg_stream(
     jpeg_end = b"\xff\xd9"
     reconnect_count = 0
     process = None
+    stderr_tail: _FfmpegStderrTail | None = None
     got_any_frames = False
 
     try:
@@ -487,6 +711,14 @@ async def generate_rtsp_mjpeg_stream(
                 reconnect_count += 1
                 continue
 
+            # Take ownership of stderr for the life of this process. Started
+            # only after the immediate-failure check above, which reads the pipe
+            # directly (correct there: the process is already dead, so
+            # read-to-EOF returns at once and cannot be raced by a collector).
+            # Nothing is lost by starting late — the banner ffmpeg printed in the
+            # meantime is still sitting in the pipe.
+            stderr_tail = _FfmpegStderrTail(process)
+
             # Read JPEG frames from ffmpeg stdout
             buffer = b""
             stream_ended = False
@@ -530,8 +762,6 @@ async def generate_rtsp_mjpeg_stream(
                         got_any_frames = True
 
                         if printer_id is not None:
-                            import time
-
                             _last_frames[printer_id] = frame
                             _last_frame_times[printer_id] = time.time()
                             if stream_id:
@@ -562,6 +792,12 @@ async def generate_rtsp_mjpeg_stream(
 
             # Clean up this ffmpeg process before reconnecting or exiting
             await _terminate_ffmpeg(process, stream_id)
+            # Released after teardown, not before: _terminate_ffmpeg deliberately
+            # leaves stderr to this collector, which has to keep draining while
+            # the process is stopped or wait() can't observe the exit.
+            if stderr_tail is not None:
+                await stderr_tail.aclose()
+                stderr_tail = None
             process = None
 
             if client_gone:
@@ -604,15 +840,16 @@ async def generate_rtsp_mjpeg_stream(
             _stream_last_frame_times.pop(stream_id, None)
 
         # Clean up frame buffer and timestamps
-        if printer_id is not None:
-            _last_frames.pop(printer_id, None)
-            _last_frame_times.pop(printer_id, None)
-            _stream_start_times.pop(printer_id, None)
+        _release_printer_frame_state(printer_id)
 
         if process:
             await _terminate_ffmpeg(process, stream_id)
             logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
 
+        # Same order as in the loop: terminate first, then release stderr.
+        if stderr_tail is not None:
+            await stderr_tail.aclose()
+
         # Shut down the TLS proxy
         proxy_server.close()
         await proxy_server.wait_closed()
@@ -672,9 +909,11 @@ async def camera_stream(
 
     # Check for external camera first
     if printer.external_camera_enabled and printer.external_camera_url:
-        import time
-        import uuid
-
+        # NB: no `import time` / `import uuid` here, and don't reintroduce them.
+        # A local import anywhere in this function makes the name function-local
+        # for the WHOLE function, so the RTSP/chamber path below — which never
+        # executes this branch — would raise UnboundLocalError on any printer
+        # without an external camera. Both are imported at module level.
         from backend.app.services.external_camera import generate_mjpeg_stream
 
         # Limit external camera FPS to reduce browser load
@@ -710,6 +949,18 @@ async def camera_stream(
             _spawned_ffmpeg_pids[proc.pid] = time.time()
             _stream_last_frame_times[stream_id] = time.time()
 
+        def _publish_external_frame(frame: bytes) -> None:
+            """Make the live frame reusable by one-shot consumers (#2707).
+
+            Only the built-in camera paths populated _last_frames, so every
+            external-camera consumer — layer timelapse, finish photo, Obico,
+            plate check — found an empty buffer and opened its own handle on a
+            device that allows exactly one reader, which simply failed while a
+            viewer was attached. Raw frame, not the multipart-wrapped chunk the
+            generator yields, because that is what those consumers expect.
+            """
+            _last_frames[printer_id] = frame
+
         async def external_stream_wrapper():
             """Wrap external stream to track start/stop and update frame times."""
             try:
@@ -718,6 +969,7 @@ async def camera_stream(
                     printer.external_camera_type,
                     fps,
                     on_process=_register_external_process,
+                    on_frame=_publish_external_frame,
                     stop_event=stop_event,
                 ):
                     # generate_mjpeg_stream already handles rate limiting;
@@ -738,6 +990,11 @@ async def camera_stream(
                 _disconnect_events.pop(stream_id, None)
                 _stream_last_frame_times.pop(stream_id, None)
                 _active_external_streams.discard(printer_id)
+                # Now that this path publishes a buffered frame, it has to
+                # retract it too — ownership-checked, so a concurrent viewer of
+                # the same printer keeps its own. Also clears the per-printer
+                # timings this path used to leave behind.
+                _release_printer_frame_state(printer_id)
                 logger.info("External camera stream ended for printer %s", printer_id)
 
         return StreamingResponse(
@@ -769,8 +1026,6 @@ async def camera_stream(
     # 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.setdefault(printer_id, time.time())
 
     # Fan-out broadcaster (#1089): one upstream connection per printer, shared
@@ -783,7 +1038,7 @@ async def camera_stream(
     # 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"
+    upstream_stream_id = _new_fanout_stream_id(printer_id)
 
     def _factory(disconnect_event: asyncio.Event):
         # Re-bind locals into the closure so the async generator below sees

+ 35 - 2
backend/app/api/routes/library.py

@@ -73,6 +73,7 @@ from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_miss
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
 from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
 from backend.app.utils.threemf_tools import (
+    expand_to_project_slots,
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
@@ -3114,6 +3115,7 @@ async def get_library_file_filament_requirements(
     file_id: int,
     plate_id: int | None = None,
     request_id: str | None = None,
+    full_slots: bool = False,
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -3130,6 +3132,10 @@ async def get_library_file_filament_requirements(
     Args:
         file_id: The library file ID
         plate_id: Optional plate index to get filaments for a specific plate
+        full_slots: Return one entry per *project* slot rather than only the
+            slots the plate consumes. See :func:`_expand_to_project_slots`.
+            Only the slice modal wants this; print-time AMS matching must keep
+            the used-only list.
     """
     import defusedxml.ElementTree as ET
 
@@ -3232,6 +3238,17 @@ async def get_library_file_filament_requirements(
                                 }
                             )
 
+            # Re-slicing a source that already carries slice_info (#2712).
+            # The block above answers "what does this plate consume", which is
+            # what print-time AMS matching needs. The slice modal needs "what
+            # slots exist", because its list is positional and the CLI binds
+            # entry N to slot N — so a source using only slot 4 handed the
+            # user's single pick to slot 1 and sliced slot 4 with the source's
+            # embedded default. Widen here rather than in the modal so the
+            # print path keeps the narrow list it depends on.
+            if full_slots and filaments:
+                filaments = expand_to_project_slots(zf, filaments)
+
             # Unsliced project files: slice_info had no per-plate data.
             # Return the FULL project_settings.config AMS slot list so
             # the slicer CLI receives a profile for every project slot
@@ -3761,10 +3778,26 @@ async def _run_slicer_with_fallback(
     # with printer …" (#2628). Replace unused-slot entries with the
     # plate's lowest used slot before the real slice so the loaded set is
     # materially homogeneous and printer-correct.
-    if is_3mf and request.plate is not None:
+    #
+    # ``plate`` is absent for single-plate and STL sources — the SliceModal
+    # skips the picker and omits the field — and absent means plate 1, the
+    # same reading as ``plate_num`` further down and as the schema's own
+    # description. Treating it as "unknown plate" instead is what left every
+    # single-plate 3MF unsubstituted (#2711): a MakerWorld project defining
+    # four filaments but painting only one reached the CLI with the other
+    # three still holding presets baked into the source for a different
+    # printer, and the slice died on the first of them.
+    #
+    # ``plate=0`` is the slice-all sentinel, not a plate: every slot is used
+    # by some plate, so there is nothing to substitute. It has to be excluded
+    # explicitly because the support-filament slots unioned in below are
+    # read from the project config and are not plate-scoped — they would
+    # survive the (empty) geometry lookup for plate 0 and become the anchor,
+    # collapsing every colour of a slice-all onto the support filament.
+    if is_3mf and request.plate != 0:
         from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
 
-        filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate, filament_jsons)
+        filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate or 1, filament_jsons)
 
     # Cross-class slice-all loop (#1493): when the user asks for
     # ``plate=0`` (all plates) AND the source's nozzle class differs from

+ 100 - 13
backend/app/api/routes/settings.py

@@ -42,6 +42,88 @@ async def get_setting(db: AsyncSession, key: str) -> str | None:
     return setting.value if setting else None
 
 
+# Accepted spellings for a boolean settings value. Settings live in a VARCHAR
+# column and every reader compares them as strings, so these are normalised to
+# "true"/"false" on the way in. The sets are deliberately generous: these
+# endpoints are part of the documented REST surface, reached by scripts and by
+# Home Assistant rest_command, where "True", "1" and "on" are all natural.
+_TRUTHY_SETTING_VALUES = frozenset({"true", "1", "yes", "on"})
+_FALSY_SETTING_VALUES = frozenset({"false", "0", "no", "off"})
+
+
+def setting_is_true(value: object) -> bool:
+    """Return True if a *stored* settings value means "on".
+
+    Deliberately narrower than the spellings ``normalize_bool_setting`` accepts:
+    it matches only what every other reader in the codebase treats as on
+    (``value.lower() == "true"``). Submitted values are canonicalised on write,
+    so a stored value is always "true"/"false"/""; accepting "1" or "on" here
+    would make this function disagree with the rest of the app about any legacy
+    row containing them.
+
+    A bool is tolerated for the case of a row written before values were
+    normalised, where SQLite coerced a raw bool into the VARCHAR column.
+    """
+    if isinstance(value, bool):
+        return value
+    if value is None:
+        return False
+    return str(value).strip().lower() == "true"
+
+
+def normalize_bool_setting(key: str, value: object) -> str:
+    """Coerce a boolean-ish settings value to the canonical "true"/"false".
+
+    Raises HTTPException(400) for values with no sensible interpretation, so an
+    API client gets a message naming the field instead of a 500.
+
+    A JSON boolean is the natural thing for an API client to send, and before
+    this normalisation it caused two distinct failures on
+    ``PUT /settings/spoolman``: ``bool.lower()`` raised AttributeError, and the
+    raw bool was written into a VARCHAR column, which SQLite silently coerces
+    to 1/0 while asyncpg rejects outright. Both surfaced as an opaque 500.
+    """
+    if isinstance(value, bool):  # must precede the int branch — bool is an int
+        return "true" if value else "false"
+    if isinstance(value, int):
+        if value in (0, 1):
+            return "true" if value else "false"
+        raise HTTPException(400, f"{key} must be a boolean; got the number {value}")
+    if isinstance(value, str):
+        candidate = value.strip().lower()
+        if not candidate:
+            # Empty is stored verbatim rather than normalised to "false".
+            # get_spoolman_settings reads these with ``or "<default>"``, so an
+            # empty stored value means "use the default" — and two of them
+            # (spoolman_report_partial_usage, auto_add_unknown_rfid) default to
+            # ON. Rewriting "" to "false" would silently switch them off for any
+            # client that submits a blank value.
+            return ""
+        if candidate in _TRUTHY_SETTING_VALUES:
+            return "true"
+        if candidate in _FALSY_SETTING_VALUES:
+            return "false"
+        raise HTTPException(400, f"{key} must be a boolean; got {value!r}")
+    raise HTTPException(400, f"{key} must be a boolean; got {type(value).__name__}")
+
+
+def normalize_str_setting(key: str, value: object) -> str:
+    """Return a string settings value, rejecting types that would store garbage.
+
+    ``str()`` on a dict or list would persist its repr, so those are refused
+    rather than silently written. Numbers are accepted and stringified: a port
+    or a bare host submitted unquoted is a plausible client mistake, not a
+    reason to fail the request.
+    """
+    if isinstance(value, str):
+        return value
+    if value is None:
+        return ""
+    if isinstance(value, bool | int | float):
+        return str(value)
+    raise HTTPException(400, f"{key} must be a string; got {type(value).__name__}")
+
+
 async def get_external_login_url(db: AsyncSession) -> str:
     """Get the external URL for the login page.
 
@@ -435,14 +517,20 @@ async def update_spoolman_settings(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
-    """Update Spoolman integration settings."""
+    """Update Spoolman integration settings.
+
+    The body is a free-form dict rather than a schema, so each value is
+    normalised before it is persisted — see ``normalize_bool_setting`` for why
+    a JSON boolean used to produce a 500 here.
+    """
     if "spoolman_enabled" in settings:
-        old_val = await get_setting(db, "spoolman_enabled") or "false"
-        new_val = settings["spoolman_enabled"]
+        was_enabled = setting_is_true(await get_setting(db, "spoolman_enabled"))
+        new_val = normalize_bool_setting("spoolman_enabled", settings["spoolman_enabled"])
+        now_enabled = new_val == "true"
         await set_setting(db, "spoolman_enabled", new_val)
 
         # Switching to Spoolman: clear built-in inventory slot assignments
-        if old_val.lower() != "true" and new_val.lower() == "true":
+        if not was_enabled and now_enabled:
             from backend.app.models.spool_assignment import SpoolAssignment
 
             result = await db.execute(delete(SpoolAssignment))
@@ -452,21 +540,20 @@ async def update_spoolman_settings(
         # spoolman_slot_assignments rows linger and would wrongly count as
         # "assigned" in any mode-agnostic check (e.g. the missing-spool-
         # assignment notification, which unions both tables — #1473).
-        elif old_val.lower() == "true" and new_val.lower() != "true":
+        elif was_enabled and not now_enabled:
             from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
             result = await db.execute(delete(SpoolmanSlotAssignment))
             logger.info("Cleared %d Spoolman slot assignments on switch to internal mode", result.rowcount)
     if "spoolman_url" in settings:
-        await set_setting(db, "spoolman_url", settings["spoolman_url"])
+        await set_setting(db, "spoolman_url", normalize_str_setting("spoolman_url", settings["spoolman_url"]))
     if "spoolman_sync_mode" in settings:
-        await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
-    if "spoolman_disable_weight_sync" in settings:
-        await set_setting(db, "spoolman_disable_weight_sync", settings["spoolman_disable_weight_sync"])
-    if "spoolman_report_partial_usage" in settings:
-        await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
-    if "auto_add_unknown_rfid" in settings:
-        await set_setting(db, "auto_add_unknown_rfid", settings["auto_add_unknown_rfid"])
+        await set_setting(
+            db, "spoolman_sync_mode", normalize_str_setting("spoolman_sync_mode", settings["spoolman_sync_mode"])
+        )
+    for bool_key in ("spoolman_disable_weight_sync", "spoolman_report_partial_usage", "auto_add_unknown_rfid"):
+        if bool_key in settings:
+            await set_setting(db, bool_key, normalize_bool_setting(bool_key, settings[bool_key]))
 
     spoolman_changed = "spoolman_enabled" in settings or "spoolman_url" in settings
 

+ 36 - 6
backend/app/api/routes/support.py

@@ -1227,6 +1227,35 @@ def _redact_raw_push_status(raw: dict) -> dict:
     return out
 
 
+def _sanitize_push_status_values(node, sensitive_strings: dict[str, str]):
+    """Sanitize a push_status snapshot's string *values*, never its JSON text.
+
+    This used to run :func:`sanitize_log_content` over the serialised snapshot.
+    That pass includes a generic Bambu-serial regex
+    (``0[0-3][A-Z0-9][A-Z0-9]{9,13}`` in ``log_reader``) which matches the
+    decimal expansion of a float just as happily as a serial: an AMS ``k`` flow
+    factor of ``0.0199999995529652`` came out as ``0.[SERIAL]``, and the bundle
+    shipped invalid JSON — unusable for exactly the ground-truth purpose the
+    snapshot exists for (found while diagnosing #2702).
+
+    Walking the structure instead leaves numbers, bools and None untouched, so
+    the output always parses. Keys are structural and never rewritten.
+    """
+    if isinstance(node, str):
+        return sanitize_log_content(node, sensitive_strings)
+    if isinstance(node, dict):
+        return {k: _sanitize_push_status_values(v, sensitive_strings) for k, v in node.items()}
+    if isinstance(node, list | tuple):
+        # Tuples too: `json.dumps` renders them as arrays, so stringifying one
+        # here would change the file's shape rather than just its content.
+        return [_sanitize_push_status_values(v, sensitive_strings) for v in node]
+    if node is None or isinstance(node, bool | int | float):
+        return node
+    # Anything else (datetime, Decimal, …) would be stringified by json.dumps'
+    # ``default=str`` *after* this pass and so escape sanitisation entirely.
+    return sanitize_log_content(str(node), sensitive_strings)
+
+
 async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
     """Get recent log lines, sanitized for inclusion in bug reports."""
     # Collect sensitive strings from DB for redaction
@@ -1300,12 +1329,13 @@ async def generate_support_bundle(
                 "captured_at": datetime.now(timezone.utc).isoformat(),
                 "raw_data": redacted,
             }
-            # Belt-and-suspenders: pass the JSON text through the string-based
-            # sanitizer so any user-named string (printer name, serial baked
-            # into a tray uuid) the structural pass missed still gets caught.
-            snapshot_json = json.dumps(snapshot, indent=2, default=str)
-            snapshot_json = sanitize_log_content(snapshot_json, sensitive_strings)
-            zf.writestr(f"push-status/printer-{i + 1}.json", snapshot_json)
+            # Belt-and-suspenders: pass every string value through the
+            # string-based sanitizer so any user-named string (printer name,
+            # serial baked into a tray uuid) the structural pass missed still
+            # gets caught. Values only — sanitizing the serialised JSON text
+            # corrupted numeric literals (see _sanitize_push_status_values).
+            snapshot = _sanitize_push_status_values(snapshot, sensitive_strings)
+            zf.writestr(f"push-status/printer-{i + 1}.json", json.dumps(snapshot, indent=2, default=str))
 
         # Add log file
         # Off the event loop: this reads up to 10 MB and then runs one full regex

+ 110 - 0
backend/app/core/database.py

@@ -27,6 +27,11 @@ def _set_sqlite_pragmas(dbapi_conn, connection_record):
 # /system/db-pool can report it without re-deriving the dialect defaults.
 _pool_config: dict = {}
 
+# What the PostgreSQL server itself will allow, read once at startup. None on
+# SQLite, or when the probe could not run. Reported by get_pool_status() so a
+# support bundle carries both sides of the comparison.
+_server_connection_limits: dict | None = None
+
 
 def _resolve_pool_kwargs() -> dict:
     """Build the pool kwargs for ``create_async_engine`` (issue #2572).
@@ -151,6 +156,10 @@ def get_pool_status() -> dict:
     return {
         "dialect": "sqlite" if is_sqlite() else "postgresql",
         "config": dict(_pool_config),
+        # Both sides of the ceiling-vs-server comparison, so a support bundle
+        # shows whether a TooManyConnectionsError was a misconfiguration or a
+        # genuine leak. None on SQLite or if the startup probe couldn't run.
+        "server_limits": dict(_server_connection_limits) if _server_connection_limits else None,
         **gauges,
     }
 
@@ -317,6 +326,107 @@ async def init_db():
     await seed_spool_catalog()
     await seed_color_catalog()
 
+    await check_pool_fits_server()
+
+
+async def check_pool_fits_server() -> None:
+    """Warn when the pool may ask PostgreSQL for more connections than it allows.
+
+    ``pool_size + max_overflow`` is the most connections one worker process will
+    ever open. If that exceeds what the server permits, the pool never reaches
+    its own limit and so never queues: it goes straight to the server, which
+    refuses with ``TooManyConnectionsError``. That surfaces wherever the next
+    connection happened to be needed — in the reported case, halfway through a
+    queue dispatch, which then left an expected-print registration and a dispatch
+    claim behind (#2702 follow-up).
+
+    The distinction is worth knowing when reading a log: SQLAlchemy's own
+    ``QueuePool limit ... timed out`` means the pool is the bottleneck (too much
+    concurrency, or connections held too long), whereas asyncpg's
+    ``TooManyConnectionsError`` means the pool's ceiling is above the server's.
+
+    Not clamped, deliberately. Pool sizes are fixed when the engine is created,
+    which happens at import — before any connection exists to ask the server
+    with — and ``engine`` / ``async_session`` are imported by name in ~150 places,
+    so swapping the engine afterwards would leave stale references. The correct
+    ceiling also depends on the worker count and on anything else sharing the
+    server, neither of which Bambuddy can see. So this reports the mismatch with
+    both numbers and the knobs to fix it, and leaves the choice to the operator.
+    """
+    global _server_connection_limits
+    if is_sqlite():
+        return
+
+    from sqlalchemy import text
+
+    in_use: int | None = None
+    try:
+        async with engine.connect() as conn:
+            max_conn = int((await conn.execute(text("SHOW max_connections"))).scalar_one())
+            reserved = int((await conn.execute(text("SHOW superuser_reserved_connections"))).scalar_one())
+            try:
+                in_use = int(
+                    (
+                        await conn.execute(
+                            text("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'")
+                        )
+                    ).scalar_one()
+                )
+            except Exception as exc:
+                # `pg_stat_activity.backend_type` is PostgreSQL 10+, and a
+                # restricted role sees fewer rows. The count is a nice-to-have
+                # for spotting other clients; the warning itself only needs the
+                # two settings above, so losing it must not cost the warning.
+                # Done last on purpose: a failed statement can abort the
+                # transaction, and nothing else uses this connection after it.
+                logger.debug("Could not count client backends: %s", exc)
+    except Exception as exc:
+        # A diagnostic must never be the reason startup fails. An older server
+        # or a restricted role may refuse these.
+        logger.debug("Could not read PostgreSQL connection limits: %s", exc)
+        return
+
+    available = max_conn - reserved
+    ceiling = _pool_config.get("pool_size", 0) + _pool_config.get("max_overflow", 0)
+    _server_connection_limits = {
+        "max_connections": max_conn,
+        "superuser_reserved_connections": reserved,
+        "available_to_bambuddy": available,
+        "client_backends_at_startup": in_use,
+        "pool_ceiling_per_worker": ceiling,
+    }
+
+    if ceiling > available:
+        in_use_note = (
+            f" {in_use} client connection(s) are open on the server right now, including "
+            "this one — a count well above 1 means something else shares it."
+            if in_use is not None
+            else ""
+        )
+        logger.warning(
+            "DB pool may exceed what PostgreSQL allows: this worker can open up to %d "
+            "connections (pool_size %d + max_overflow %d) but the server permits %d "
+            "(max_connections %d minus %d reserved for superusers).%s Exhaustion surfaces "
+            "as TooManyConnectionsError at whatever ran next, not as a pool timeout. "
+            "Lower DB_POOL_SIZE / DB_MAX_OVERFLOW, or raise the server's "
+            "max_connections — and account for every worker process and any other "
+            "client sharing this server.",
+            ceiling,
+            _pool_config.get("pool_size", 0),
+            _pool_config.get("max_overflow", 0),
+            available,
+            max_conn,
+            reserved,
+            in_use_note,
+        )
+    else:
+        logger.info(
+            "DB pool fits the server: up to %d connection(s) per worker, %d available (max_connections %d).",
+            ceiling,
+            available,
+            max_conn,
+        )
+
 
 # B2: Module-level counter exposing the number of rows skipped during the last
 # _migrate_encrypt_legacy_secrets() invocation. Surfaced via /encryption-status

+ 84 - 15
backend/app/main.py

@@ -740,6 +740,51 @@ def register_expected_print(
     )
 
 
+def unregister_expected_print(printer_id: int, filename: str, archive_id: int) -> None:
+    """Undo :func:`register_expected_print` when the print never went out.
+
+    Registration has to happen *before* the MQTT print command, because the
+    printer can report the print before the line after the send executes. So
+    every path that registers and then fails to send — a cancel winning the
+    #1853 CAS race, a ``start_print()`` that returns False, or any exception in
+    between — leaves an expectation for a print that will never arrive.
+
+    The TTL sweep evicts those after two hours, which is far longer than it
+    takes a user to react to a failed dispatch by pressing print again: that
+    reprint would be folded into the *old* archive and take the stale
+    ``ams_mapping`` / ``plate_id`` with it. Hence the explicit inverse.
+
+    Mirrors the sweep's rules, including the one that is easy to get wrong:
+    ``_print_ams_mappings`` / ``_print_plate_ids`` are keyed by archive, not by
+    file, so they may only be dropped once no live key still points at that
+    archive.
+    """
+    keys = [(printer_id, filename)]
+    if filename.endswith(".3mf"):
+        base = filename[:-4]
+        keys.append((printer_id, base))
+        keys.append((printer_id, f"{base}.gcode"))
+
+    removed = False
+    for key in keys:
+        if _expected_prints.pop(key, None) is not None:
+            removed = True
+        _expected_print_creators.pop(key, None)
+        _expected_print_registered_at.pop(key, None)
+
+    if archive_id not in set(_expected_prints.values()):
+        _print_ams_mappings.pop(archive_id, None)
+        _print_plate_ids.pop(archive_id, None)
+
+    if removed:
+        logging.getLogger(__name__).info(
+            "Unregistered expected print: printer=%s, file=%s, archive=%s (print was never sent)",
+            printer_id,
+            filename,
+            archive_id,
+        )
+
+
 def _compute_run_filament_grams(
     status: str,
     archive_filament_used_grams: float | None,
@@ -2172,13 +2217,21 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
         # Try external camera first
         if printer.external_camera_enabled and printer.external_camera_url:
             logger.info("[SNAPSHOT] Capturing from external camera for printer %s", printer_id)
+            from backend.app.api.routes.camera import live_frame_for_capture
             from backend.app.services.external_camera import capture_frame
 
-            frame_data = await capture_frame(
-                printer.external_camera_url,
-                printer.external_camera_type or "mjpeg",
-                snapshot_url=printer.external_camera_snapshot_url,
-            )
+            # An external camera allows one reader, so capturing while a viewer
+            # is attached fails (#2707). A None here falls through to the paths
+            # below exactly as a failed capture did.
+            defer, buffered = live_frame_for_capture(printer_id)
+            if defer:
+                frame_data = buffered
+            else:
+                frame_data = await capture_frame(
+                    printer.external_camera_url,
+                    printer.external_camera_type or "mjpeg",
+                    snapshot_url=printer.external_camera_snapshot_url,
+                )
             if frame_data and len(frame_data) <= 2_500_000:
                 logger.info("[SNAPSHOT] External camera frame: %s bytes", len(frame_data))
                 return _apply_camera_rotation(frame_data, printer, logger)
@@ -4337,13 +4390,21 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
                 )
 
         if frame_bytes is None and printer.external_camera_enabled and printer.external_camera_url:
+            from backend.app.api.routes.camera import live_frame_for_capture
             from backend.app.services.external_camera import capture_frame
 
-            frame_bytes = await capture_frame(
-                printer.external_camera_url,
-                printer.external_camera_type or "mjpeg",
-                snapshot_url=printer.external_camera_snapshot_url,
-            )
+            # #2707: this used to collide with the live view and fail, which is
+            # how finish-photo notifications went out with no image attached.
+            # Leaving frame_bytes None keeps the rest of the fallback chain.
+            defer, buffered = live_frame_for_capture(printer_id)
+            if defer:
+                frame_bytes = buffered
+            else:
+                frame_bytes = await capture_frame(
+                    printer.external_camera_url,
+                    printer.external_camera_type or "mjpeg",
+                    snapshot_url=printer.external_camera_snapshot_url,
+                )
             if frame_bytes:
                 logger.info(
                     "[FINISH-PHOTO-MOMENT] captured external-camera frame (%d bytes)",
@@ -5307,13 +5368,21 @@ async def on_print_complete(printer_id: int, data: dict):
             if not photo_filename:
                 if printer.external_camera_enabled and printer.external_camera_url:
                     logger.info("[PHOTO-BG] Using external camera")
+                    from backend.app.api.routes.camera import live_frame_for_capture
                     from backend.app.services.external_camera import capture_frame
 
-                    frame_data = await capture_frame(
-                        printer.external_camera_url,
-                        printer.external_camera_type or "mjpeg",
-                        snapshot_url=printer.external_camera_snapshot_url,
-                    )
+                    # #2707: the second half of the finish-photo failure — the
+                    # pre-capture and this fallback both collided with the live
+                    # view. None here continues down the fallback chain.
+                    defer, buffered = live_frame_for_capture(printer_id)
+                    if defer:
+                        frame_data = buffered
+                    else:
+                        frame_data = await capture_frame(
+                            printer.external_camera_url,
+                            printer.external_camera_type or "mjpeg",
+                            snapshot_url=printer.external_camera_snapshot_url,
+                        )
                     if frame_data:
                         photos_dir = archive_dir / "photos"
                         photos_dir.mkdir(parents=True, exist_ok=True)

+ 24 - 15
backend/app/schemas/auth.py

@@ -360,28 +360,37 @@ def _validate_icon_url(v: str | None) -> str | None:
 
 
 def _validate_issuer_url(v: str | None) -> str | None:
-    """Nit4: Reject non-HTTPS issuer URLs and private/loopback/link-local hosts.
-
-    HTTP is no longer accepted — OIDC providers must be reachable over TLS.
-    Private-network and loopback addresses are rejected to prevent SSRF attacks
-    where an admin-supplied URL could reach internal services.
+    """Reject non-HTTPS issuer URLs and SSRF-unsafe hosts.
+
+    An OIDC provider must be reachable over TLS on the public internet, so
+    this uses the public-internet policy: private, loopback and link-local
+    addresses are all rejected.
+
+    Delegates to the runtime guard ``assert_safe_public_https_url`` for the
+    same reason ``_validate_icon_url`` does — no policy drift between the
+    schema layer and the fetcher. The hand-rolled version this replaced
+    checked only ``is_private | is_loopback | is_link_local``, which left
+    numeric-encoded IPs (``https://2130706433/``), IPv4-mapped IPv6
+    (``https://[::ffff:127.0.0.1]/``), multicast and unspecified addresses
+    able to express a target the policy meant to forbid. The guard's
+    docstring already claimed the two were consistent; now they are.
+
+    Lazy-imported because ``_oidc_helpers`` lives under ``api/routes/`` and
+    schemas avoid top-level imports from that layer.
     """
-    import ipaddress
-    from urllib.parse import urlparse
-
     if v is None:
         return v
     if not v.startswith("https://"):
         raise ValueError("issuer_url must start with https://")
-    host = urlparse(v).hostname or ""
+    from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
+
     try:
-        addr = ipaddress.ip_address(host)
-        if addr.is_private or addr.is_loopback or addr.is_link_local:
-            raise ValueError("issuer_url must not point to a private, loopback, or link-local address")
+        assert_safe_public_https_url(v)
     except ValueError as exc:
-        if "issuer_url" in str(exc):
-            raise
-        # hostname is a domain name, not a bare IP — that's fine
+        # The guard's messages say "icon URL" — rewrite for this field so the
+        # user sees the setting they actually submitted.
+        detail = str(exc).replace("icon URL", "issuer_url")
+        raise ValueError(detail) from exc
     return v
 
 

+ 56 - 1
backend/app/schemas/settings.py

@@ -1,9 +1,23 @@
 import json
 
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, ValidationInfo, field_validator
 
 from backend.app.schemas.print_queue import TriState
 
+# Outbound service URLs validated on save, so a bad value is rejected at
+# configuration time with a clear message rather than failing opaquely at
+# request time. Every one of these services is commonly self-hosted on the same
+# host or LAN as Bambuddy, so the LAN-service policy applies: loopback and
+# RFC-1918 stay permitted, while cloud-metadata endpoints, numeric-encoded IPs,
+# IPv4-mapped IPv6 and non-HTTP schemes are rejected. See
+# ``_url_safety.assert_safe_lan_service_url``.
+#
+# Module-level rather than a class attribute so the CI backstop in
+# tests/unit/test_outbound_url_ssrf_guards.py can import the real list and
+# cannot drift from it. Any new outbound-URL setting belongs here (or, if it
+# must be reachable on the public internet, on the stricter OIDC guard).
+LAN_SERVICE_URL_SETTINGS = ("ha_url", "obico_ml_url", "orcaslicer_api_url", "bambu_studio_api_url")
+
 
 class AppSettings(BaseModel):
     """Application settings schema."""
@@ -600,6 +614,47 @@ class AppSettingsUpdate(BaseModel):
     default_sidebar_order: str | None = None
     forecast_global_lead_time_days: int | None = Field(default=None, ge=0)
 
+    @field_validator(*LAN_SERVICE_URL_SETTINGS)
+    @classmethod
+    def validate_lan_service_url(cls, v: str | None, info: ValidationInfo) -> str | None:
+        """Reject SSRF-unsafe outbound service URLs on save.
+
+        Empty (and whitespace-only) is the documented "not configured / fall
+        back to the env var" value for all four fields and must keep passing.
+
+        Values that are not absolute URLs at all ("192.168.1.10:3333",
+        "localhost:3333") are left alone rather than rejected. Two reasons:
+
+        - They are inert. Every consumer of these four settings goes through
+          httpx, which raises UnsupportedProtocol for a URL with no scheme, so
+          no request is ever issued and there is nothing to guard against.
+        - They were storable before this validator existed, and the settings
+          UI is a plain text input with no scheme enforcement. Newly rejecting
+          them would break saves that have nothing to do with the URL: the
+          Obico panel, for one, sends obico_ml_url with every change and
+          auto-saves, so one legacy value would block toggling detection on or
+          off. A pre-existing misconfiguration should keep failing where it
+          already failed (at request time), not spread to unrelated fields.
+
+        ``urlparse`` is no help in telling the two apart — it reads
+        "localhost:3333" as scheme "localhost" — so the test is the literal
+        "://" that makes a string an absolute URL.
+        """
+        if v is None or not v.strip():
+            return v
+        candidate = v.strip()
+        if "://" not in candidate:
+            return v
+        # Lazy-imported: schemas avoid top-level imports from api/routes,
+        # matching the existing pattern in auth.py's _validate_icon_url.
+        from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
+        try:
+            assert_safe_lan_service_url(candidate, label=info.field_name or "URL")
+        except ValueError as exc:
+            raise ValueError(str(exc)) from exc
+        return v
+
     @field_validator("gcode_snippets")
     @classmethod
     def validate_gcode_snippets(cls, v: str | None) -> str | None:

+ 96 - 15
backend/app/services/bambu_mqtt.py

@@ -736,6 +736,12 @@ class BambuMQTTClient:
         # and the FINISH-state fallback don't both fire on the same
         # print. Reset to False on every print start.
         self._finish_photo_captured: bool = False
+        # #2702: one-shot re-request of the layer total. Armed at print start
+        # when the starting frame carried no `total_layer_num`, spent on the
+        # first layer advance that still has no denominator. Bambu firmware
+        # only re-sends *changed* fields, so a total we never received (or
+        # dropped) is only recoverable via a full pushall.
+        self._total_layers_refresh_armed: bool = False
         # #2547 end-of-print telemetry probe state. `_armed` is cleared once the
         # window has run for a print so a late FINISH re-send can't reopen it.
         self._eop_probe_armed: bool = True
@@ -2993,8 +2999,49 @@ class BambuMQTTClient:
                     f"{self.state.mc_print_sub_stage} -> {new_sub_stage}"
                 )
             self.state.mc_print_sub_stage = new_sub_stage
+        # Positive `total_layer_num` carried by *this* frame, or 0. Read up
+        # front because three places below consult it and they run in an order
+        # that is not the order they read most naturally in: the layer-advance
+        # refresh (#2702) must not fire on a frame that already answers it, the
+        # apply step must ignore firmware-reset 0s (#1771), and the new-print
+        # reset must not discard a total that belongs to the starting print.
+        total_from_this_frame = 0
+        if "total_layer_num" in data:
+            try:
+                total_from_this_frame = max(int(data["total_layer_num"] or 0), 0)
+            except (TypeError, ValueError):
+                # Must not escape. `_on_message` catches only JSONDecodeError
+                # and paho is left at `suppress_exceptions = False`, so an
+                # exception raised here is re-raised on the network thread and
+                # takes the printer connection down over one unusable field.
+                # Treat it as "not reported": the refresh below then recovers
+                # the real total from a pushall.
+                logger.debug(
+                    "[%s] ignoring unusable total_layer_num: %r",
+                    self.serial_number,
+                    data["total_layer_num"],
+                )
+
         if "layer_num" in data:
-            new_layer = int(data["layer_num"])
+            try:
+                new_layer = int(data["layer_num"])
+            except (TypeError, ValueError):
+                # Contained for the same reason as `total_layer_num` above: an
+                # exception raised here escapes `_update_state` and paho
+                # re-raises it on the network thread. Losing this frame would
+                # also lose the print-start and completion detection further
+                # down, which is worse than losing a layer number.
+                #
+                # Held at the last known layer rather than substituted with 0:
+                # a fabricated 0 reads as the firmware's cancel reset, which
+                # would move `_last_valid_layer_num` and show layer 0 in the UI
+                # until the next good frame.
+                logger.debug(
+                    "[%s] ignoring unusable layer_num: %r",
+                    self.serial_number,
+                    data["layer_num"],
+                )
+                new_layer = self.state.layer_num
             old_layer = self.state.layer_num
             # Save last non-zero layer for usage tracking (firmware resets to 0 on cancel)
             if old_layer > 0:
@@ -3003,6 +3050,25 @@ class BambuMQTTClient:
             # Trigger layer change callback if layer increased
             if new_layer > old_layer and self.on_layer_change:
                 self.on_layer_change(new_layer)
+            # #2702: the print is demonstrably laying down layers but we still
+            # have no denominator, so the pushall requested at print start
+            # either went unanswered or raced the printer learning the total.
+            # Ask once more — by layer 1 the printer definitely knows it.
+            # One-shot: an unanswered pushall must not turn into a per-layer
+            # retry loop for the rest of the print.
+            if (
+                new_layer > old_layer
+                and self._total_layers_refresh_armed
+                and not self.state.total_layers
+                and not total_from_this_frame
+            ):
+                self._total_layers_refresh_armed = False
+                logger.debug(
+                    "[%s] layer %s with no total_layer_num — re-requesting full status",
+                    self.serial_number,
+                    new_layer,
+                )
+                self._request_push_all()
             # #1867 last-layer finish-photo trigger. A1 Mini (and other
             # firmware variants) skips `stg_cur=22`, so the fallback fires
             # at gcode_state=FINISH — which runs AFTER user End G-code
@@ -3032,15 +3098,12 @@ class BambuMQTTClient:
                         "timelapse_was_active": self._timelapse_during_print,
                     }
                 )
-        if "total_layer_num" in data:
-            # Some firmware (P1S observed) resets `total_layer_num` to 0 at
-            # print end — same shape as the `layer_num` reset guarded above.
-            # Preserve the last known good value so the usage-tracker split
-            # path (#1771) has a denominator that survives the reset frame.
-            # Explicit reset to 0 happens on print start (`_handle_print_start`).
-            new_total = int(data["total_layer_num"])
-            if new_total > 0:
-                self.state.total_layers = new_total
+        if total_from_this_frame:
+            # Firmware (P1S observed) resets `total_layer_num` to 0 at print
+            # end — same shape as the `layer_num` reset guarded above. Applying
+            # only positive values preserves the last known good denominator so
+            # the usage-tracker split path (#1771) survives the reset frame.
+            self.state.total_layers = total_from_this_frame
 
         # Fan speeds (MQTT sends as string "0"-"15" representing speed levels, or percentage)
         # Convert to 0-100 percentage for display
@@ -4152,11 +4215,29 @@ class BambuMQTTClient:
             # Reset layer tracking for new print (needed for layer-based timelapse)
             self.state.layer_num = 0
             # Reset total_layers so the previous print's value can't bleed into
-            # this print's usage-tracker split before the new push_status arrives
-            # with the slicer's total (#1771 follow-on to the preservation guard
-            # above at line ~2135 — the guard now ignores firmware-reset 0s, so
-            # the explicit reset has to happen here instead).
-            self.state.total_layers = 0
+            # this print's usage-tracker split (#1771 follow-on to the
+            # preservation guard at the `total_layer_num` parse above — that
+            # guard ignores firmware-reset 0s, so the explicit reset has to
+            # happen here instead).
+            #
+            # #2702: reset to *this frame's* total, not to 0. The frame that
+            # trips the new-print detection can carry the new print's
+            # `total_layer_num` as well — the parse above has already applied
+            # it, and zeroing unconditionally threw it away. That looked
+            # harmless but is not recoverable: Bambu firmware sends only
+            # changed fields, so the printer never offers the total again, and
+            # the print runs to completion at `n/0` in the UI, in
+            # `{total_layers}` notifications, and as the usage-split
+            # denominator. The value only reappears on the next full pushall
+            # (reconnect / Force Refresh), which is why the symptom looked
+            # random and why a *stable* connection made it worse.
+            self.state.total_layers = total_from_this_frame
+            # If the starting frame brought no total, ask for one. Costs one
+            # MQTT message per print and covers the ordering where the printer
+            # published the total a frame or two before the state flip.
+            self._total_layers_refresh_armed = not total_from_this_frame
+            if self._total_layers_refresh_armed:
+                self._request_push_all()
             # Reset completion tracking for new print
             self._was_running = True
             self._completion_triggered = False

+ 133 - 3
backend/app/services/camera.py

@@ -6,6 +6,7 @@ Supports two camera protocols:
 """
 
 import asyncio
+import functools
 import logging
 import os
 import shutil
@@ -34,6 +35,26 @@ _rtsp_socket_timeout_flag: str | None = None
 # The cleanup task in routes/camera.py checks this set to avoid killing active captures.
 _active_capture_pids: set[int] = set()
 
+# In-flight one-shot captures, keyed by printer IP (#2705).
+#
+# Bambu firmware allows exactly one camera connection, and the existing guards
+# (is_stream_active / try_get_active_buffered_frame, #1271 + #1348) only stop a
+# capturer from competing with the fan-out BROADCASTER. They do nothing for
+# capturer-vs-capturer with no viewer attached, where every consumer correctly
+# concludes it isn't competing with a viewer and then collides with the others.
+# Eight paths reach capture_camera_frame_bytes() independently — Obico polling,
+# /camera/snapshot, the finish-photo moment and its disk-writing sibling, plate
+# detection, the camera test and the diagnose tool — so the single-flight lives
+# at the bottom of the stack and needs no call-site changes.
+#
+# Keyed by IP rather than printer_id because IP is what the firmware's one-
+# connection limit applies to: two printer rows pointing at the same address
+# still share one camera. (This function never sees a printer_id anyway.) The
+# key deliberately excludes the timeout, or callers that disagree about it —
+# and they all do, from 10s to 30s — would never coalesce, which is exactly
+# the Obico-vs-snapshot pair from the report.
+_inflight_captures: dict[str, asyncio.Task[bytes | None]] = {}
+
 
 def get_ffmpeg_path() -> str | None:
     """Find the ffmpeg executable path.
@@ -529,6 +550,38 @@ async def capture_camera_frame(
     return False
 
 
+def capture_in_flight(ip_address: str) -> bool:
+    """Return True iff a one-shot capture for this IP is running right now.
+
+    For callers that need to know whether they will JOIN someone else's
+    capture rather than perform their own — currently only the diagnose tool,
+    which reports on what it measured and so must not present a coalesced
+    frame as proof that it opened its own connection (see camera_diagnose).
+
+    Ordinary consumers should ignore this: they want "a recent frame", and
+    capture_camera_frame_bytes() already does the right thing for them.
+    """
+    task = _inflight_captures.get(ip_address)
+    return task is not None and not task.done()
+
+
+def _discard_inflight_capture(ip_address: str, task: asyncio.Task) -> None:
+    """Done-callback: drop the finished task from the in-flight registry.
+
+    Guarded on identity so a slow task that finishes after a newer capture
+    has registered can't evict its successor.
+
+    Also retrieves the exception, if any. The leader normally awaits the task
+    and would surface it, but a leader whose own caller was cancelled leaves
+    nobody to collect it — and an unretrieved task exception is logged by
+    asyncio as a warning with a traceback at an arbitrary later point.
+    """
+    if _inflight_captures.get(ip_address) is task:
+        del _inflight_captures[ip_address]
+    if not task.cancelled() and task.exception() is not None:
+        logger.debug("In-flight camera capture for %s ended in an exception", ip_address)
+
+
 async def capture_camera_frame_bytes(
     ip_address: str,
     access_code: str,
@@ -537,18 +590,95 @@ async def capture_camera_frame_bytes(
 ) -> bytes | None:
     """Capture a single frame and return as JPEG bytes (no disk write).
 
-    Uses the same protocol selection as capture_camera_frame but returns
-    bytes directly instead of writing to disk.
+    Concurrent callers for the same printer share one capture (#2705): the
+    first opens the connection, everyone arriving while it is in flight awaits
+    the same result. Every consumer here wants "a recent frame" rather than
+    "a frame captured at exactly my timestamp", so handing identical bytes to
+    simultaneous callers is correct — and it is the only way to honour the
+    firmware's one-connection limit without serialising captures behind a lock
+    (which would just turn a collision into a queue).
+
+    This coalesces; it does not cache. A call that arrives after the previous
+    capture finished always captures fresh. Two consumers of these frames —
+    plate detection and the finish-photo path — decide things about a running
+    print from them, and a stale frame there is worse than a slow one: the
+    whole of #1397 was a finish photo taken seconds late showing the bed
+    already lowered.
 
     Args:
         ip_address: Printer IP address
         access_code: Printer access code
         model: Printer model (X1, H2D, P1, A1, etc.)
-        timeout: Timeout in seconds for the capture operation
+        timeout: Timeout in seconds for the capture operation. Applies to this
+            caller's own wait, including when it joins another caller's
+            capture — the call sites disagree about the value (10s for plate
+            detection, 20s for Obico), and a follower must not silently
+            inherit the leader's deadline in either direction.
 
     Returns:
         JPEG bytes if capture was successful, None otherwise
     """
+    # A follower whose leader fails takes a turn of its own rather than
+    # inheriting a failure it never had a chance to avoid — by then the leader
+    # has finished, so there is no socket left to compete with. Bounded at two
+    # rounds: if the capture we joined AND its replacement both failed, a third
+    # connection won't help, and this caller has already spent its patience.
+    for _ in range(2):
+        leader = _inflight_captures.get(ip_address)
+        if leader is None or leader.done():
+            break
+        try:
+            frame = await asyncio.wait_for(asyncio.shield(leader), timeout=timeout)
+        except TimeoutError:
+            # shield() keeps the capture running for whoever else is still
+            # waiting on it — giving up is this caller's decision alone.
+            logger.warning(
+                "Gave up waiting %ss on the in-flight camera capture for %s",
+                timeout,
+                ip_address,
+            )
+            return None
+        except asyncio.CancelledError:
+            # Distinguish "the capture I joined was cancelled" from "I was
+            # cancelled". Only the former is ours to recover from.
+            if not leader.cancelled():
+                raise
+            logger.info("In-flight camera capture for %s was cancelled; capturing our own", ip_address)
+            continue
+        if frame is not None:
+            logger.info(
+                "Reusing in-flight camera capture for %s: %s bytes (no second connection opened)",
+                ip_address,
+                len(frame),
+            )
+            return frame
+        logger.info("In-flight camera capture for %s failed; capturing our own", ip_address)
+    else:
+        return None
+
+    task = asyncio.create_task(_capture_camera_frame_bytes_uncoalesced(ip_address, access_code, model, timeout))
+    _inflight_captures[ip_address] = task
+    task.add_done_callback(functools.partial(_discard_inflight_capture, ip_address))
+    # No wait_for here: this caller IS the capture, and the implementation
+    # already enforces `timeout` internally where it can also kill the ffmpeg
+    # process. A second deadline on top would abandon the subprocess instead.
+    # shield() so that a cancelled leader (a client navigating away mid-
+    # snapshot is routine) doesn't take the capture down with it — the
+    # followers already waiting on it still get their frame.
+    return await asyncio.shield(task)
+
+
+async def _capture_camera_frame_bytes_uncoalesced(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    timeout: int = 15,
+) -> bytes | None:
+    """Open a connection and capture one frame. See capture_camera_frame_bytes.
+
+    Callers want that wrapper, not this: it opens a socket unconditionally,
+    which is the collision #2705 is about.
+    """
     # Chamber image models: A1/P1 - returns bytes directly
     if is_chamber_image_model(model):
         logger.info("Capturing camera frame bytes from %s using chamber image protocol (model: %s)", ip_address, model)

+ 25 - 2
backend/app/services/camera_diagnose.py

@@ -35,6 +35,13 @@ out broadcaster to prevent). When ``is_stream_active`` reports True
 AND a buffered frame is fresh (last 10 s), we short-circuit the test
 with ``live_stream_active`` and report success — the user is
 literally watching the camera right now, no test needed.
+
+The related case is another one-shot capture (Obico polling, the cam
+wall) being in flight when the user hits Diagnose. There the capture
+layer coalesces for us (#2705) and no competing socket is opened, but
+the frame we get back was someone else's — so ``first_frame`` still
+passes and carries a ``coalesced_capture`` code, because a diagnostic
+that reports a connection it didn't open is worse than a slow one.
 """
 
 from __future__ import annotations
@@ -46,6 +53,7 @@ from dataclasses import dataclass, field
 
 from backend.app.services.camera import (
     capture_camera_frame_bytes,
+    capture_in_flight,
     get_camera_port,
     is_chamber_image_model,
 )
@@ -69,8 +77,10 @@ class CameraDiagnoseStage:
     name: str  # "tcp_reachable" | "first_frame" | "live_stream_active"
     status: str  # "ok" | "failed" | "skipped"
     duration_ms: int = 0
-    # Optional machine-readable code for failures so the frontend can
-    # render a stage-specific hint without parsing free-text errors.
+    # Optional machine-readable code so the frontend can render a stage-
+    # specific hint without parsing free-text errors. Usually a failure
+    # reason; "coalesced_capture" qualifies a PASS whose frame came from a
+    # capture already in flight, so duration_ms isn't a connection time.
     code: str | None = None
 
 
@@ -166,6 +176,15 @@ async def _check_first_frame(
     """Stage 2 — capture one frame end-to-end. Combines auth + protocol
     handshake + first keyframe; either it works or it doesn't."""
     started = time.monotonic()
+    # A capture already running for this printer (an Obico poll, the cam wall)
+    # means capture_camera_frame_bytes will hand us THAT capture's frame rather
+    # than opening its own connection (#2705). Good for the printer, but this
+    # stage exists to report what it measured: the frame would be real evidence
+    # the camera works, while duration_ms would be mostly time spent queueing,
+    # and a pass would be claimed for a connection we never opened. So the
+    # stage says so, the same way the live-stream shortcut above declares
+    # itself instead of quietly passing.
+    coalesced = capture_in_flight(ip_address)
     try:
         jpeg = await capture_camera_frame_bytes(
             ip_address=ip_address,
@@ -190,7 +209,11 @@ async def _check_first_frame(
             name="first_frame",
             status="ok",
             duration_ms=int((time.monotonic() - started) * 1000),
+            code="coalesced_capture" if coalesced else None,
         )
+    # No annotation on the failure path: a follower whose leader fails goes on
+    # to capture on its own, so a None here means this stage did get its own
+    # attempt (or watched two consecutive captures fail — same verdict).
     return CameraDiagnoseStage(
         name="first_frame",
         status="failed",

+ 24 - 4
backend/app/services/external_camera.py

@@ -607,6 +607,7 @@ async def generate_mjpeg_stream(
     fps: int = 10,
     *,
     on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
+    on_frame: Callable[[bytes], None] | None = None,
     stop_event: asyncio.Event | None = None,
 ) -> AsyncGenerator[bytes, None]:
     """Generator yielding MJPEG frames for streaming.
@@ -622,6 +623,16 @@ async def generate_mjpeg_stream(
             open (#2675). Without it the process is reachable only from this
             generator's own ``finally``, which an abrupt client disconnect can
             skip (same cancellation-timing class as #776).
+        on_frame: Called with each RAW frame, before it is wrapped for the wire,
+            so the route layer can publish it as the printer's buffered frame
+            (#2707). It has to be a callback: what this generator yields is
+            multipart-wrapped, so a consumer of the stream cannot recover the
+            JPEG, and until now nothing populated the buffer for external
+            cameras at all — leaving every one-shot consumer (layer timelapse,
+            finish photo, Obico, plate check) with nothing to reuse and no
+            option but to open a competing handle on a single-reader device.
+            Exceptions are logged and swallowed: buffering must never be able
+            to break the live stream.
         stop_event: When set, the reconnect loops stop retrying — so an explicit
             stop (which kills the current ffmpeg) doesn't immediately respawn a
             new process and reacquire the device.
@@ -632,6 +643,15 @@ async def generate_mjpeg_stream(
     frame_interval = 1.0 / max(fps, 1)
     last_frame_time = 0.0
 
+    def _publish(frame: bytes) -> bytes:
+        """Hand the raw frame to on_frame, then format it for the wire."""
+        if on_frame is not None:
+            try:
+                on_frame(frame)
+            except Exception:
+                logger.exception("on_frame callback raised")
+        return _format_mjpeg_frame(frame)
+
     if camera_type == "mjpeg":
         # Proxy MJPEG stream directly, with reconnect on timeout
         max_retries = 3
@@ -642,7 +662,7 @@ async def generate_mjpeg_stream(
                 current_time = asyncio.get_event_loop().time()
                 if current_time - last_frame_time >= frame_interval:
                     last_frame_time = current_time
-                    yield _format_mjpeg_frame(frame)
+                    yield _publish(frame)
             if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
                 break
             logger.warning(
@@ -659,7 +679,7 @@ async def generate_mjpeg_stream(
             frame_yielded = False
             async for frame in _stream_rtsp(url, fps, on_process=on_process):
                 frame_yielded = True
-                yield _format_mjpeg_frame(frame)
+                yield _publish(frame)
             if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
                 break
             logger.warning(
@@ -672,7 +692,7 @@ async def generate_mjpeg_stream(
     elif camera_type == "usb":
         # Use ffmpeg to stream from USB camera
         async for frame in _stream_usb(url, fps, on_process=on_process):
-            yield _format_mjpeg_frame(frame)
+            yield _publish(frame)
 
     elif camera_type == "snapshot":
         # Poll snapshot URL at interval
@@ -680,7 +700,7 @@ async def generate_mjpeg_stream(
             try:
                 frame = await _capture_snapshot(url, timeout=10)
                 if frame:
-                    yield _format_mjpeg_frame(frame)
+                    yield _publish(frame)
                 await asyncio.sleep(frame_interval)
             except asyncio.CancelledError:
                 break

+ 20 - 1
backend/app/services/layer_timelapse.py

@@ -67,7 +67,26 @@ class TimelapseSession:
         self.last_layer = layer_num
 
         try:
-            frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
+            # Reuse the live view's frame instead of opening a second handle on
+            # a single-reader device (#2707). Unguarded, a print watched from
+            # start to finish recorded zero successful layer captures, and the
+            # stitched video came out empty or badly truncated.
+            from backend.app.api.routes.camera import live_frame_for_capture
+
+            defer, buffered = live_frame_for_capture(self.printer_id)
+            if defer:
+                if not buffered:
+                    # Viewer attached but nothing buffered yet: skip this layer
+                    # rather than compete and kick them off (#1348).
+                    logger.debug(
+                        "Skipping layer %s for printer %s: viewer attached, no buffered frame yet",
+                        layer_num,
+                        self.printer_id,
+                    )
+                    return False
+                frame_data = buffered
+            else:
+                frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
             if frame_data:
                 frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
                 await asyncio.to_thread(frame_path.write_bytes, frame_data)

+ 94 - 7
backend/app/services/notification_service.py

@@ -64,6 +64,55 @@ def _looks_like_cloudflare_challenge(response: httpx.Response) -> bool:
     return "just a moment" in body or "cf-chl-bypass" in body or "cf-chl-opt" in body or "challenge-platform" in body
 
 
+def _assert_safe_provider_url(url: str, *, label: str) -> str | None:
+    """Validate a provider URL taken from user-supplied config.
+
+    Returns an error message on rejection, or None when the URL is
+    acceptable — the ``_send_*`` methods return ``tuple[bool, str]`` rather
+    than raising, so a message is more useful here than an exception.
+
+    Uses the LAN-service policy: self-hosting ntfy, Bark, Gotify or a webhook
+    receiver on the home LAN is normal and must keep working, so loopback and
+    RFC-1918 stay permitted. Cloud-metadata endpoints, numeric-encoded IPs and
+    non-HTTP schemes are rejected.
+    """
+    from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
+    try:
+        assert_safe_lan_service_url(url, label=label)
+    except ValueError as exc:
+        return str(exc)
+    return None
+
+
+def _opaque_http_failure(response: httpx.Response, *, label: str) -> str:
+    """Failure message for a provider whose destination host the user supplies.
+
+    The response body is deliberately **not** returned to the caller. Provider
+    URLs are configurable by anyone holding ``NOTIFICATIONS_CREATE`` — which
+    the default Operators group carries and which does not imply
+    ``SETTINGS_UPDATE`` — and ``POST /notifications/test-config`` accepts a URL
+    straight from the request body without persisting anything. Echoing the
+    response body there turned an intended "does my webhook work?" check into
+    an authenticated read primitive against any host the Bambuddy process can
+    reach, including services that are not exposed to the network at all.
+
+    Providers whose host Bambuddy hardcodes (Pushover, Telegram, CallMeBot)
+    keep returning the upstream body — there is no trust boundary to cross
+    when the destination cannot be influenced.
+
+    The body is logged at debug level, where it stays available to whoever
+    already administers the host without being handed back over the API.
+    """
+    logger.debug(
+        "%s delivery failed with HTTP %s; body: %s",
+        label,
+        response.status_code,
+        (response.text or "")[:200],
+    )
+    return f"HTTP {response.status_code} from the configured {label} (see server logs at debug level for details)"
+
+
 class NotificationService:
     """Service for sending notifications through various providers."""
 
@@ -265,6 +314,10 @@ class NotificationService:
         if not device_key:
             return False, "Device key is required"
 
+        url_error = _assert_safe_provider_url(server, label="Bark server URL")
+        if url_error:
+            return False, url_error
+
         payload: dict[str, Any] = {
             "device_key": device_key,
             "title": title,
@@ -291,9 +344,13 @@ class NotificationService:
             except ValueError:
                 body = None
             if isinstance(body, dict) and body.get("code") not in (200, None):
-                return False, f"Bark error {body.get('code')}: {str(body.get('message'))[:200]}"
+                # Only the numeric code is echoed. A server chosen by the caller
+                # controls this body too, so the free-text message is a (narrow)
+                # read channel of the same kind _opaque_http_failure closes.
+                logger.debug("Bark reported error %s: %s", body.get("code"), str(body.get("message"))[:200])
+                return False, f"Bark error {body.get('code')} (see server logs at debug level for details)"
             return True, "Message sent successfully"
-        return False, f"HTTP {response.status_code}: {response.text[:200]}"
+        return False, _opaque_http_failure(response, label="Bark server")
 
     async def _send_ntfy(
         self,
@@ -311,6 +368,10 @@ class NotificationService:
         if not topic:
             return False, "Topic is required"
 
+        url_error = _assert_safe_provider_url(server, label="ntfy server URL")
+        if url_error:
+            return False, url_error
+
         url = f"{server}/{topic}"
         # ntfy reads Title/Message from HTTP headers. httpx enforces ASCII
         # for str header values, but printer names and filenames can contain
@@ -363,7 +424,7 @@ class NotificationService:
                 "Fight Mode, or front the server with Cloudflare Access using a "
                 "service token. (#1534)"
             )
-        return False, f"HTTP {response.status_code}: {response.text[:200]}"
+        return False, _opaque_http_failure(response, label="ntfy server")
 
     async def _send_pushover(
         self, config: dict, title: str, message: str, image_data: bytes | None = None
@@ -438,6 +499,19 @@ class NotificationService:
         if not bot_token or not chat_id:
             return False, "Bot token and chat ID are required"
 
+        # Optional forum topic (#1518).  Telegram expects message_thread_id as an
+        # integer in the JSON sendMessage body — a string 400s there even though
+        # the multipart sendPhoto call below would happily accept one.  Coerce it
+        # once, up front, so both call sites agree and a bad value fails loudly
+        # instead of silently breaking only the text notifications.
+        thread_id_raw = str(config.get("message_thread_id") or "").strip()
+        message_thread_id: int | None = None
+        if thread_id_raw:
+            try:
+                message_thread_id = int(thread_id_raw)
+            except ValueError:
+                return False, f"Invalid message thread ID: {thread_id_raw!r} is not a number"
+
         # Escape underscores in the message body so Telegram Markdown
         # parsing doesn't break on job names like "A1_plate_8" or error
         # codes like "0300_0001".  The title is already wrapped in *bold*
@@ -452,18 +526,23 @@ class NotificationService:
         if image_data:
             # Use sendPhoto to attach the thumbnail with the caption
             url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
+            form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"}
+            if message_thread_id is not None:
+                form["message_thread_id"] = message_thread_id
             response = await client.post(
                 url,
-                data={"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"},
+                data=form,
                 files={"photo": ("photo.jpg", image_data, "image/jpeg")},
             )
         else:
             url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
-            data = {
+            data: dict[str, Any] = {
                 "chat_id": chat_id,
                 "text": message,
                 "parse_mode": "Markdown",
             }
+            if message_thread_id is not None:
+                data["message_thread_id"] = message_thread_id
             response = await client.post(url, json=data)
 
         if response.status_code == 200:
@@ -665,6 +744,10 @@ class NotificationService:
         if not webhook_url:
             return False, "Webhook URL is required"
 
+        url_error = _assert_safe_provider_url(webhook_url, label="Webhook URL")
+        if url_error:
+            return False, url_error
+
         # Build payload based on format
         if payload_format == "slack":
             # Slack/Mattermost format - just text field
@@ -710,7 +793,7 @@ class NotificationService:
             if response.status_code in (200, 201, 202, 204):
                 return True, "Webhook delivered successfully"
             else:
-                return False, f"HTTP {response.status_code}: {response.text[:200]}"
+                return False, _opaque_http_failure(response, label="webhook endpoint")
         except Exception as e:
             return False, f"Webhook error: {str(e)}"
 
@@ -811,7 +894,11 @@ class NotificationService:
         elif response.status_code == 401:
             return False, "Home Assistant authentication failed - check your token"
         else:
-            return False, f"HTTP {response.status_code}: {response.text[:200]}"
+            # ha_url comes from global settings (SETTINGS_UPDATE, admin-only), so
+            # this is a narrower channel than the per-request provider URLs — but
+            # it lands in the same NOTIFICATIONS_CREATE-gated test response, so it
+            # gets the same treatment.
+            return False, _opaque_http_failure(response, label="Home Assistant endpoint")
 
     async def _send_to_provider(
         self,

+ 15 - 0
backend/app/services/obico_detection.py

@@ -193,6 +193,21 @@ class ObicoDetectionService:
             return None
 
         if printer.external_camera_enabled and printer.external_camera_url:
+            # Same rule as the built-in branch below, which this used to skip:
+            # an external camera is single-reader too, so polling while a viewer
+            # is attached just fails (#2707).
+            from backend.app.api.routes.camera import live_frame_for_capture
+
+            defer, buffered = live_frame_for_capture(printer_id)
+            if defer:
+                if buffered:
+                    return buffered
+                logger.info(
+                    "Obico: viewer attached for printer %s but buffer empty; "
+                    "skipping this poll to avoid competing camera handle (#2707)",
+                    printer_id,
+                )
+                return None
             return await capture_external_frame(
                 printer.external_camera_url,
                 printer.external_camera_type,

+ 20 - 8
backend/app/services/plate_detection.py

@@ -604,16 +604,28 @@ async def capture_camera_image(
     # Try external camera first if requested and available
     if use_external and external_camera_url and external_camera_type:
         try:
+            from backend.app.api.routes.camera import live_frame_for_capture
             from backend.app.services.external_camera import capture_frame
 
-            image_data = await capture_frame(
-                external_camera_url,
-                external_camera_type,
-                snapshot_url=external_camera_snapshot_url,
-            )
-            if image_data:
-                camera_source = "external"
-                logger.debug("Captured frame from external camera for printer %s", printer_id)
+            # What this function's docstring already promised, but only the
+            # built-in fallback below delivered: an external camera is
+            # single-reader too, so capturing while a viewer watches fails
+            # (#2707).
+            defer, buffered = live_frame_for_capture(printer_id)
+            if defer:
+                if buffered:
+                    image_data = buffered
+                    camera_source = "external (buffered)"
+                    logger.debug("Using buffered external frame for printer %s", printer_id)
+            else:
+                image_data = await capture_frame(
+                    external_camera_url,
+                    external_camera_type,
+                    snapshot_url=external_camera_snapshot_url,
+                )
+                if image_data:
+                    camera_source = "external"
+                    logger.debug("Captured frame from external camera for printer %s", printer_id)
         except Exception as e:
             logger.warning("Failed to capture from external camera: %s", e)
 

+ 117 - 17
backend/app/services/print_scheduler.py

@@ -280,17 +280,29 @@ class PrintScheduler:
         # event-loop thread, so this dict needs no lock.
         # item_id -> (task, printer_id)
         self._inflight: dict[int, tuple[asyncio.Task, int | None]] = {}
+        # Expected prints registered by `_start_print` that have not yet had a
+        # print command sent. Populated at registration, dropped once
+        # `start_print()` succeeds, and rolled back by `_dispatch_one` on every
+        # other exit. Same threading argument as `_inflight` above: one
+        # sequential caller, callbacks on the same loop, so no lock.
+        # item_id -> (printer_id, remote_filename, archive_id)
+        self._unconfirmed_expected_print: dict[int, tuple[int, str, int]] = {}
 
     async def run(self):
         """Main loop - check queue every interval."""
         self._running = True
         logger.info("Print scheduler started")
 
-        await self._clear_stale_dispatch_claims()
+        await self._clear_stale_dispatch_claims(at_startup=True)
 
         while self._running:
             dispatched = False
             try:
+                # No-op while any upload is in flight; on a quiet tick it releases
+                # a claim whose best-effort clear failed (e.g. the database was
+                # briefly unreachable), instead of leaving the row wedged until
+                # the next restart.
+                await self._clear_stale_dispatch_claims()
                 dispatched = await self.check_queue()
             except Exception as e:
                 logger.error("Scheduler error: %s", e)
@@ -299,14 +311,29 @@ class PrintScheduler:
             # not stall behind the idle interval; otherwise sleep normally (#2555).
             await asyncio.sleep(self._fast_check_interval if dispatched else self._check_interval)
 
-    async def _clear_stale_dispatch_claims(self) -> None:
-        """Clear dispatch claims left behind by a crash/restart mid-upload (#2615).
-
-        A claim is only ever held by a live dispatch coroutine, and no coroutine
-        survives a process restart — so every ``dispatching_at`` present at startup
-        is stale. Clearing them lets those still-pending rows be re-selected for a
-        fresh, consistent dispatch instead of being wedged out of the selection
-        query forever. Called once at the top of ``run()``."""
+    async def _clear_stale_dispatch_claims(self, *, at_startup: bool = False) -> None:
+        """Clear dispatch claims with no live dispatch coroutine behind them (#2615).
+
+        A claim is only ever held by a live dispatch coroutine, so when this
+        process has nothing in ``_inflight`` every ``dispatching_at`` in the table
+        is stale. At startup that is trivially true — no coroutine survives a
+        restart. It is equally true on any later tick where no upload is running,
+        which is what makes this safe to repeat rather than only run once.
+
+        Repeating it matters because ``_clear_dispatch_claim`` is best-effort: if
+        the database is briefly unreachable at exactly the moment dispatch ends,
+        the claim survives and the row is wedged out of the selection query. That
+        used to last until the next restart (#2702 follow-up, seen when
+        PostgreSQL refused a connection mid-dispatch).
+
+        ``_inflight`` is populated when the task is spawned, before the coroutine
+        claims its row, and pruned by a done-callback that cannot run before the
+        coroutine's own ``finally`` — so "claim present, nothing in flight" has no
+        race window and needs no age threshold. A size-derived upload deadline
+        (``max(600s, size/25KB/s)``) has no safe fixed bound anyway.
+        """
+        if self._inflight:
+            return
         try:
             async with async_session() as db:
                 res = await db.execute(
@@ -314,9 +341,13 @@ class PrintScheduler:
                 )
                 await db.commit()
                 if res.rowcount:
-                    logger.info("Cleared %d stale dispatch claim(s) at startup (#2615)", res.rowcount)
+                    logger.info(
+                        "Cleared %d orphaned dispatch claim(s)%s (#2615)",
+                        res.rowcount,
+                        " at startup" if at_startup else "",
+                    )
         except Exception as exc:
-            logger.error("Failed to clear stale dispatch claims at startup: %s", exc)
+            logger.error("Failed to clear orphaned dispatch claims: %s", exc)
 
     def stop(self):
         """Stop the scheduler."""
@@ -929,6 +960,14 @@ class PrintScheduler:
                     return
                 await self._start_print(item_db, item)
             finally:
+                # Undo an expected-print registration whose print command never
+                # went out. One choke point covers every way `_start_print` can
+                # end without sending: a raised exception (a DB failure mid-
+                # dispatch is the reported case), an early return, a cancel
+                # winning the #1853 CAS, or `start_print()` returning False.
+                # A confirmed send removes the entry itself, so this is a no-op
+                # on the happy path.
+                self._rollback_unconfirmed_expected_print(item_id)
                 # Release the claim on every exit. Once dispatch has finished the
                 # row's status carries the lock (printing/failed/cancelled are all
                 # != pending), so the token is only needed for the duration of the
@@ -936,6 +975,30 @@ class PrintScheduler:
                 # dispatchable again on the next tick.
                 await self._clear_dispatch_claim(item_db, item_id)
 
+    def _rollback_unconfirmed_expected_print(self, item_id: int) -> None:
+        """Drop an expectation for a print command that was never sent.
+
+        Best-effort and never raises: this runs in the ``finally`` of dispatch,
+        where the interesting exception is usually the one already propagating.
+        """
+        pending = self._unconfirmed_expected_print.pop(item_id, None)
+        if pending is None:
+            return
+        printer_id, remote_filename, archive_id = pending
+        try:
+            from backend.app.main import unregister_expected_print
+
+            unregister_expected_print(printer_id, remote_filename, archive_id)
+        except Exception:
+            logger.warning(
+                "Queue item %s: failed to unregister expected print (printer=%s, file=%s, archive=%s)",
+                item_id,
+                printer_id,
+                remote_filename,
+                archive_id,
+                exc_info=True,
+            )
+
     async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
         """Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
 
@@ -954,12 +1017,39 @@ class PrintScheduler:
 
     async def _clear_dispatch_claim(self, db: AsyncSession, item_id: int) -> None:
         """Clear the dispatch claim (#2615). Best-effort: a failure here must not
-        mask the dispatch outcome, and startup reconciliation clears any leftover."""
-        try:
-            await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(dispatching_at=None))
-            await db.commit()
-        except Exception as exc:
-            logger.warning("Queue item %s: failed to clear dispatch claim: %s", item_id, exc)
+        mask the dispatch outcome.
+
+        Retried, because the failure mode in practice is transient and narrow: a
+        database that is momentarily unreachable — PostgreSQL out of connection
+        slots is the observed case — refuses this write for a second or two while
+        the dispatch that just ended is still holding the row out of the selection
+        query. One attempt was enough to wedge the item; a couple of spaced
+        attempts clear it. Each attempt rolls back first, since a failed write
+        leaves the session needing it before it can be reused.
+
+        If every attempt fails, ``_clear_stale_dispatch_claims`` picks the row up
+        on the next quiet tick.
+        """
+        for attempt in range(1, 4):
+            try:
+                await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(dispatching_at=None))
+                await db.commit()
+                return
+            except Exception as exc:
+                try:
+                    await db.rollback()
+                except Exception:
+                    pass
+                if attempt == 3:
+                    logger.warning(
+                        "Queue item %s: failed to clear dispatch claim after %d attempts: %s "
+                        "— a later quiet tick will release it",
+                        item_id,
+                        attempt,
+                        exc,
+                    )
+                    return
+                await asyncio.sleep(0.5 * attempt)
 
     async def _find_idle_printer_for_model(
         self,
@@ -3432,6 +3522,12 @@ class PrintScheduler:
                 created_by_id=item.created_by_id,
                 plate_id=item.plate_id,
             )
+            # Registration happens before the print command by necessity (the
+            # printer can report the print before the send returns), so record
+            # what to undo if we never get as far as sending. `_dispatch_one`
+            # rolls back anything still pending here on every exit — exception,
+            # early return, or cancel winning the CAS below.
+            self._unconfirmed_expected_print[item.id] = (item.printer_id, remote_filename, archive.id)
 
         # Propagate the queue item's owner into printer_manager so the
         # print-complete callback can credit the user in the PrintLogEntry
@@ -3556,6 +3652,10 @@ class PrintScheduler:
         )
 
         if started:
+            # The command is away, so the expectation is now legitimate and must
+            # survive. Anything still in this dict when _dispatch_one exits gets
+            # rolled back.
+            self._unconfirmed_expected_print.pop(item.id, None)
             logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
             # No dispatch-toast event here: the legacy bg-dispatch path kept
             # status='processing' from upload start until the printer acked

+ 15 - 2
backend/app/services/slicer_3mf_convert.py

@@ -264,14 +264,27 @@ def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | N
     doesn't even use.
 
     The substitution is a no-op when:
-    - ``plate_id`` is None (we can't determine which slots are unused),
+    - ``plate_id`` is not a real plate — ``None`` (caller couldn't say) or
+      ``0`` (the slice-all sentinel, where every slot is used by *some*
+      plate so there is nothing unused to substitute). Callers that know
+      "absent means plate 1" must resolve that themselves before calling;
+      this function will not guess, because guessing wrong rewrites a
+      filament the plate actually prints with.
     - the source isn't a valid 3MF / zip,
     - the source doesn't carry plate-extruder metadata (parse returns
       empty set — treat as "every slot is used", same fallback the
       SliceModal uses),
     - ``items`` has fewer than 2 entries (nothing to substitute).
+
+    The ``0`` guard is load-bearing rather than cosmetic. Plate ids are
+    1-indexed, so the geometry lookup for plate 0 matches nothing — but
+    the support-filament slots unioned in below come from the project
+    config and carry no plate scope at all. Without the guard a slice-all
+    of a project with a dedicated support slot would see ``used`` as just
+    that one slot, anchor on it, and rewrite every colour in the project
+    to the support material (#2711).
     """
-    if plate_id is None or len(items) < 2:
+    if plate_id is None or plate_id < 1 or len(items) < 2:
         return items
     # Local import keeps the bytes->ZipFile boundary in this module and
     # avoids dragging zipfile into every caller.

+ 45 - 0
backend/app/utils/threemf_tools.py

@@ -907,6 +907,51 @@ def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
     return out
 
 
+def expand_to_project_slots(zf: zipfile.ZipFile, used: list[dict]) -> list[dict]:
+    """Widen a used-only filament list to one entry per project slot.
+
+    ``used`` is the slice_info-derived list: only the slots whose G-code
+    actually consumed filament, each carrying real usage figures. That is the
+    right answer for print-time AMS matching, and the wrong one for the slice
+    modal, because the list the modal builds is **positional** — index 0 is
+    slot 1 all the way down to the ``filament_N.json`` parts handed to the CLI.
+    A source whose only used slot is 4 therefore produced a single dropdown
+    whose pick the CLI bound to slot 1, leaving slot 4 — the one the model
+    prints with — on whatever the source had baked in (#2712).
+
+    Returns the project's slots in slot order, each flagged ``used_in_plate``.
+    Rows present in ``used`` are kept whole, so their usage figures, resolved
+    type/colour and ``tray_info_idx`` survive; the rest come from the project
+    configuration with zero usage. A used slot beyond the project's slot count
+    is appended rather than dropped — the caller asked for a superset, and
+    silently losing the one slot that prints would be the original bug again.
+
+    ``used`` is returned unchanged when the file carries no project settings
+    to widen against: a narrower-than-ideal list still prints correctly, an
+    invented one might not.
+    """
+    project = extract_project_filaments_from_3mf(zf)
+    if not project:
+        return used
+
+    by_slot = {f["slot_id"]: f for f in used}
+    out: list[dict] = []
+    for slot in project:
+        known = by_slot.pop(slot["slot_id"], None)
+        if known is not None:
+            known["used_in_plate"] = True
+            out.append(known)
+        else:
+            slot["used_in_plate"] = False
+            out.append(slot)
+    # Anything slice_info reported that the project doesn't declare.
+    for leftover in by_slot.values():
+        leftover["used_in_plate"] = True
+        out.append(leftover)
+    out.sort(key=lambda f: f["slot_id"])
+    return out
+
+
 def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]:
     """Slots referenced by the process settings for support material.
 

+ 362 - 5
backend/tests/integration/test_library_slice_api.py

@@ -93,15 +93,24 @@ async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0)
 
 
 @pytest.fixture
-async def slice_test_setup(db_session, tmp_path):
-    """Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer."""
+async def slice_test_setup(db_session, tmp_path, monkeypatch):
+    """Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer.
+
+    ``base_dir`` is patched via ``monkeypatch`` rather than assigned and
+    restored by hand. ``app_settings`` is a process-wide singleton, and the
+    hand-rolled version only restored after the ``yield`` — so anything raising
+    during setup (a commit, a refresh) left ``base_dir`` pointing at a
+    ``tmp_path`` that pytest then deleted, and every later test in that xdist
+    worker which reads it failed. That was the cause of intermittent failures
+    in ``TestLibraryPathHelpers`` and ``TestArchivePlatesDesignOverrides``,
+    which share nothing with this module but land in the same worker.
+    """
     storage_dir = tmp_path / "library" / "files"
     storage_dir.mkdir(parents=True, exist_ok=True)
     src_path = storage_dir / "Cube.stl"
     src_path.write_bytes(b"solid Cube\nendsolid\n")
 
-    original_base_dir = app_settings.base_dir
-    app_settings.base_dir = tmp_path
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
 
     src_file = LibraryFile(
         filename="Cube.stl",
@@ -137,7 +146,6 @@ async def slice_test_setup(db_session, tmp_path):
         "tmp_path": tmp_path,
     }
 
-    app_settings.base_dir = original_base_dir
     slicer_api_module.set_shared_http_client(None)
 
 
@@ -1618,3 +1626,352 @@ class TestNozzleClassGuard:
         if resp.status_code == 400:
             detail = resp.json().get("detail", "")
             assert "isn't supported" not in detail, f"guard still firing on preset path: {detail!r}"
+
+
+class TestUnusedSlotSubstitutionOnSinglePlateSource:
+    """#2711: a single-plate 3MF must still get its unused slots substituted.
+
+    The SliceModal omits ``plate`` entirely for single-plate and STL sources —
+    it skips the plate picker, so ``selectedPlate`` stays null and the field
+    never reaches the body. The schema documents an absent plate as "plate 1",
+    but the substitution used to read it as "unknown plate" and skip, so every
+    single-plate project reached the CLI with the dropdown values of slots the
+    plate never paints with.
+
+    In the reported case that was a MakerWorld project declaring four filaments
+    while plate 1 paints with one, the other three carrying presets baked into
+    the source for a different printer. The CLI rejected the whole slice with
+    "filament preset ... (slot 1) is not compatible with printer ...", and the
+    modal disables unused rows so there was no way to correct it by hand.
+    """
+
+    @staticmethod
+    def _single_plate_using_only_slot_3() -> bytes:
+        """One plate, one object, painted with slot 3 — slots 1, 2 and 4 are
+        declared by the project but unused. Mirrors the reported file."""
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps({"filament_type": ["PLA", "PLA", "PLA", "TPU"]}),
+            )
+            zf.writestr(
+                "Metadata/model_settings.config",
+                "<?xml version='1.0'?>\n<config>"
+                '<object id="1"><metadata key="extruder" value="3"/></object>'
+                '<plate><metadata key="plater_id" value="1"/>'
+                '<model_instance><metadata key="object_id" value="1"/>'
+                '<metadata key="instance_id" value="0"/></model_instance>'
+                "</plate></config>",
+            )
+        return buf.getvalue()
+
+    @staticmethod
+    def _filament_names_sent(body: bytes) -> list[str]:
+        """Pull the ``name`` of each ``filamentProfile`` part, in slot order.
+
+        ``slice_model`` sends one repeated ``filamentProfile`` part per slot as
+        ``filament_N.json``; the parts stay in submission order, so a plain
+        scan preserves the slot mapping.
+        """
+        names: list[str] = []
+        marker = b'name="filamentProfile"; filename="filament_'
+        pos = body.find(marker)
+        while pos != -1:
+            start = body.find(b"{", pos)
+            end = body.find(b"\r\n", start)
+            names.append(json.loads(body[start:end].decode("utf-8"))["name"])
+            pos = body.find(marker, end)
+        return names
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unused_slots_are_substituted_when_the_body_omits_plate(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        tmp_path = slice_test_setup["tmp_path"]
+        src = tmp_path / "library" / "files" / "train.3mf"
+        src.write_bytes(self._single_plate_using_only_slot_3())
+        threemf = LibraryFile(
+            filename="train.3mf",
+            file_path=str(src.relative_to(tmp_path)),
+            file_type="3mf",
+            file_size=src.stat().st_size,
+        )
+        db_session.add(threemf)
+
+        # Four distinguishable filament presets, one per project slot. Only
+        # slot 3's is compatible with the target in the reported scenario.
+        slots = []
+        for i in range(1, 5):
+            p = LocalPreset(
+                name=f"slot{i}",
+                preset_type="filament",
+                source="orcaslicer",
+                setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
+            )
+            db_session.add(p)
+            slots.append(p)
+        await db_session.commit()
+        await db_session.refresh(threemf)
+        for p in slots:
+            await db_session.refresh(p)
+
+        captured: list[list[str]] = []
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured.append(self._filament_names_sent(request.content))
+            return httpx.Response(
+                status_code=200,
+                content=_make_3mf_with_settings(),
+                headers={
+                    "x-print-time-seconds": "100",
+                    "x-filament-used-g": "1.0",
+                    "x-filament-used-mm": "100",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{threemf.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
+                # No "plate" — exactly what the modal sends for a single-plate
+                # source. This is the whole point of the test.
+            },
+        )
+        assert response.status_code == 202, response.text
+
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        assert captured, "sidecar was never called"
+        # Every slot carries slot 3's profile: the array length stays intact
+        # (the source's per-slot references depend on it) while nothing the
+        # plate doesn't print with can fail the CLI's validators.
+        assert captured[0] == ["slot3", "slot3", "slot3", "slot3"], captured[0]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slice_all_keeps_every_slot(self, async_client: AsyncClient, db_session, slice_test_setup):
+        """``plate=0`` is the all-plates sentinel, so nothing is unused.
+
+        It reaches the same call site, and plate ids are 1-indexed — the
+        geometry lookup for plate 0 matches nothing. Without an explicit
+        exclusion the project's support-filament slot would be the only
+        member of the used set and would be copied over every colour.
+        """
+        tmp_path = slice_test_setup["tmp_path"]
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps(
+                    {
+                        "enable_support": "1",
+                        "support_filament": "4",
+                        "support_interface_filament": "4",
+                        "filament_type": ["PLA", "PLA", "PLA", "PVA"],
+                    }
+                ),
+            )
+            zf.writestr(
+                "Metadata/model_settings.config",
+                "<?xml version='1.0'?>\n<config>"
+                '<object id="1"><metadata key="extruder" value="1"/></object>'
+                '<object id="2"><metadata key="extruder" value="2"/></object>'
+                '<plate><metadata key="plater_id" value="1"/>'
+                '<model_instance><metadata key="object_id" value="1"/></model_instance></plate>'
+                '<plate><metadata key="plater_id" value="2"/>'
+                '<model_instance><metadata key="object_id" value="2"/></model_instance></plate>'
+                "</config>",
+            )
+        src = tmp_path / "library" / "files" / "multi.3mf"
+        src.write_bytes(buf.getvalue())
+        threemf = LibraryFile(
+            filename="multi.3mf",
+            file_path=str(src.relative_to(tmp_path)),
+            file_type="3mf",
+            file_size=src.stat().st_size,
+        )
+        db_session.add(threemf)
+
+        slots = []
+        for i in range(1, 5):
+            p = LocalPreset(
+                name=f"slot{i}",
+                preset_type="filament",
+                source="orcaslicer",
+                setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
+            )
+            db_session.add(p)
+            slots.append(p)
+        await db_session.commit()
+        await db_session.refresh(threemf)
+        for p in slots:
+            await db_session.refresh(p)
+
+        captured: list[list[str]] = []
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured.append(self._filament_names_sent(request.content))
+            return httpx.Response(
+                status_code=200,
+                content=_make_3mf_with_settings(),
+                headers={
+                    "x-print-time-seconds": "100",
+                    "x-filament-used-g": "1.0",
+                    "x-filament-used-mm": "100",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{threemf.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
+                "plate": 0,
+            },
+        )
+        assert response.status_code == 202, response.text
+
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        assert captured, "sidecar was never called"
+        assert captured[0] == ["slot1", "slot2", "slot3", "slot4"], captured[0]
+
+
+class TestFilamentRequirementsFullSlots:
+    """#2712: what the slice modal is handed must be positional.
+
+    The modal's filament list maps index 0 to slot 1, and the backend forwards
+    it in that order as ``filament_1.json``..``filament_N.json``. A MakerWorld
+    source that ships slice_info and paints with slot 4 alone therefore has to
+    present four rows — a one-row list binds the user's pick to slot 1, and
+    slot 4 slices with whatever the source had baked in. Picking PETG produced
+    a PLA print, and the print dialog then correctly refused to match PETG.
+
+    Print-time AMS matching shares this endpoint and needs the opposite: only
+    the slots the plate consumes, so it doesn't demand spools for slots the
+    G-code never touches. Hence the opt-in flag rather than a shape change.
+    """
+
+    @staticmethod
+    def _sliced_source_using_only_slot_4() -> bytes:
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps(
+                    {
+                        "filament_type": ["PLA", "PLA", "PLA", "PLA"],
+                        "filament_colour": ["#38CC0A", "#161616", "#898989", "#898989"],
+                    }
+                ),
+            )
+            # MakerWorld ships slice_info without plate G-code, which is what
+            # sends this file down the "already sliced" branch.
+            zf.writestr(
+                "Metadata/slice_info.config",
+                "<?xml version='1.0'?>\n<config><plate>"
+                "<metadata key='index' value='1'/>"
+                "<filament id='4' tray_info_idx='GFL99' type='PLA' color='#898989'"
+                " used_m='35.51' used_g='105.92'/>"
+                "</plate></config>",
+            )
+        return buf.getvalue()
+
+    async def _make_file(self, db_session, tmp_path) -> int:
+        src = tmp_path / "library" / "files" / "tunnel.3mf"
+        src.write_bytes(self._sliced_source_using_only_slot_4())
+        lib = LibraryFile(
+            filename="tunnel.3mf",
+            file_path=str(src.relative_to(tmp_path)),
+            file_type="3mf",
+            file_size=src.stat().st_size,
+        )
+        db_session.add(lib)
+        await db_session.commit()
+        await db_session.refresh(lib)
+        return lib.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_full_slots_returns_one_row_per_project_slot(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        file_id = await self._make_file(db_session, slice_test_setup["tmp_path"])
+
+        r = await async_client.get(f"/api/v1/library/files/{file_id}/filament-requirements?plate_id=1&full_slots=true")
+        assert r.status_code == 200, r.text
+        filaments = r.json()["filaments"]
+
+        assert [f["slot_id"] for f in filaments] == [1, 2, 3, 4]
+        # Only slot 4 prints, so only its row is selectable in the modal.
+        assert [f["used_in_plate"] for f in filaments] == [False, False, False, True]
+        # The used row keeps what the slice actually reported.
+        assert filaments[3]["used_grams"] == 105.9
+        assert filaments[3]["tray_info_idx"] == "GFL99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_print_path_still_gets_only_the_used_slot(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        """Without the flag the response must be byte-for-byte what it was.
+
+        PrintModal drives AMS matching off this; widening it would ask the
+        user to load three spools the print never touches.
+        """
+        file_id = await self._make_file(db_session, slice_test_setup["tmp_path"])
+
+        r = await async_client.get(f"/api/v1/library/files/{file_id}/filament-requirements?plate_id=1")
+        assert r.status_code == 200, r.text
+        filaments = r.json()["filaments"]
+
+        assert [f["slot_id"] for f in filaments] == [4]
+        assert filaments[0]["used_in_plate"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unsliced_sources_are_unaffected_by_the_flag(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        """Those already returned the full project list; the flag must not
+        double-handle them or change what the modal has been getting."""
+        tmp_path = slice_test_setup["tmp_path"]
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps({"filament_type": ["PLA", "PETG"], "filament_colour": ["#000000", "#FFFFFF"]}),
+            )
+        src = tmp_path / "library" / "files" / "raw.3mf"
+        src.write_bytes(buf.getvalue())
+        lib = LibraryFile(
+            filename="raw.3mf",
+            file_path=str(src.relative_to(tmp_path)),
+            file_type="3mf",
+            file_size=src.stat().st_size,
+        )
+        db_session.add(lib)
+        await db_session.commit()
+        await db_session.refresh(lib)
+
+        with_flag = await async_client.get(
+            f"/api/v1/library/files/{lib.id}/filament-requirements?plate_id=1&full_slots=true"
+        )
+        without = await async_client.get(f"/api/v1/library/files/{lib.id}/filament-requirements?plate_id=1")
+
+        assert with_flag.status_code == 200 and without.status_code == 200
+        assert with_flag.json()["filaments"] == without.json()["filaments"]
+        assert [f["slot_id"] for f in with_flag.json()["filaments"]] == [1, 2]

+ 288 - 0
backend/tests/unit/services/test_camera_capture_coalescing.py

@@ -0,0 +1,288 @@
+"""Single-flight coalescing of one-shot camera captures (#2705).
+
+Bambu firmware allows exactly one camera connection. The pre-existing guards
+(``is_stream_active`` / ``try_get_active_buffered_frame``, #1271 + #1348) only
+keep a one-shot capturer from competing with the fan-out broadcaster; nothing
+kept the capturers from competing with EACH OTHER when no viewer was attached,
+so an Obico poll and a ``/camera/snapshot`` 200 ms apart each opened their own
+RTSP socket and knocked the other over.
+
+These tests drive ``capture_camera_frame_bytes`` at the public boundary and
+count how many times the underlying capture ran, since "how many connections
+did we open" is the entire point of the fix.
+"""
+
+import asyncio
+
+import pytest
+
+from backend.app.services import camera as camera_module
+from backend.app.services.camera import capture_camera_frame_bytes, capture_in_flight
+
+FRAME_A = b"\xff\xd8" + b"a" * 200 + b"\xff\xd9"
+FRAME_B = b"\xff\xd8" + b"b" * 200 + b"\xff\xd9"
+
+
+@pytest.fixture(autouse=True)
+def _clear_inflight():
+    """The registry is module-global; don't leak tasks between tests."""
+    camera_module._inflight_captures.clear()
+    yield
+    camera_module._inflight_captures.clear()
+
+
+class RecordingCapture:
+    """Stand-in for the real capture, recording each call.
+
+    ``gate`` (when set) holds every capture open until released, which is how
+    these tests create the overlap window that used to produce two sockets.
+    """
+
+    def __init__(self, frames=(FRAME_A, FRAME_B), gate: asyncio.Event | None = None):
+        self.calls: list[tuple[str, int]] = []
+        self._frames = list(frames)
+        self._gate = gate
+        self.started = asyncio.Event()
+
+    async def __call__(self, ip_address, access_code, model, timeout=15):
+        self.calls.append((ip_address, timeout))
+        self.started.set()
+        if self._gate is not None:
+            await self._gate.wait()
+        return self._frames.pop(0) if self._frames else None
+
+    @property
+    def count(self) -> int:
+        return len(self.calls)
+
+
+@pytest.fixture
+def patch_capture(monkeypatch):
+    def _install(capture):
+        monkeypatch.setattr(camera_module, "_capture_camera_frame_bytes_uncoalesced", capture)
+        return capture
+
+    return _install
+
+
+async def _let_leader_start(capture: RecordingCapture) -> None:
+    """Wait until the leader is inside the capture, so the next caller joins it.
+
+    Without this the second caller can reach the registry before the first has
+    even been scheduled, which tests a different (and uninteresting) race.
+    """
+    await asyncio.wait_for(capture.started.wait(), timeout=1)
+
+
+@pytest.mark.asyncio
+async def test_simultaneous_callers_share_one_capture(patch_capture):
+    """The reported collision: two consumers, one connection, two frames."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=20))
+    await _let_leader_start(capture)
+    follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=15))
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert await leader == FRAME_A
+    assert await follower == FRAME_A
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_five_callers_one_capture(patch_capture):
+    """Verified on live hardware in the report: 5 callers, 1 connection."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    first = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await _let_leader_start(capture)
+    rest = [asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S")) for _ in range(4)]
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert await asyncio.gather(first, *rest) == [FRAME_A] * 5
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_different_printers_do_not_coalesce(patch_capture):
+    """The one-connection limit is per printer, so the key must be too."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    one = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await _let_leader_start(capture)
+    two = asyncio.create_task(capture_camera_frame_bytes("10.0.2.44", "code", "P2S"))
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert {await one, await two} == {FRAME_A, FRAME_B}
+    assert capture.count == 2
+    assert {ip for ip, _ in capture.calls} == {"10.0.2.43", "10.0.2.44"}
+
+
+@pytest.mark.asyncio
+async def test_coalescing_is_not_caching(patch_capture):
+    """Sequential callers each capture fresh.
+
+    Deliberate: plate detection and the finish-photo path decide things about a
+    running print from these frames, and #1397 was a finish photo a few seconds
+    stale showing the bed already lowered.
+    """
+    capture = patch_capture(RecordingCapture())
+
+    assert await capture_camera_frame_bytes("10.0.2.43", "code", "P2S") == FRAME_A
+    assert await capture_camera_frame_bytes("10.0.2.43", "code", "P2S") == FRAME_B
+    assert capture.count == 2
+
+
+@pytest.mark.asyncio
+async def test_registry_is_empty_after_a_capture_finishes(patch_capture):
+    """No leak, and nothing left behind for the next caller to join."""
+    patch_capture(RecordingCapture())
+
+    await capture_camera_frame_bytes("10.0.2.43", "code", "P2S")
+    await asyncio.sleep(0)  # let the done-callback run
+
+    assert camera_module._inflight_captures == {}
+    assert capture_in_flight("10.0.2.43") is False
+
+
+@pytest.mark.asyncio
+async def test_failed_leader_does_not_poison_its_followers(patch_capture):
+    """A follower that never got its own attempt gets one when the leader fails.
+
+    Safe by then: the leader has finished, so there is no socket to compete
+    with. This also covers the follower whose timeout is LONGER than the
+    leader's — it isn't cut short by someone else's deadline.
+    """
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(frames=(None, FRAME_B), gate=gate))
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=10))
+    await _let_leader_start(capture)
+    follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=20))
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert await leader is None
+    assert await follower == FRAME_B
+    assert capture.count == 2
+
+
+@pytest.mark.asyncio
+async def test_two_consecutive_failures_give_up(patch_capture):
+    """Bounded retry: a follower doesn't chase failing captures forever.
+
+    Two followers behind a failing leader. The first takes its own turn, the
+    second joins THAT capture, and when it fails too the second gives up rather
+    than opening a third connection.
+    """
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(frames=(None, None), gate=gate))
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await _let_leader_start(capture)
+    first = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await asyncio.sleep(0)
+    second = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await asyncio.sleep(0)
+    gate.set()
+
+    assert await leader is None
+    assert await first is None
+    assert await second is None
+    # The leader's capture plus one retry — not one per disappointed caller.
+    assert capture.count == 2
+
+
+@pytest.mark.asyncio
+async def test_follower_timeout_does_not_sabotage_the_capture(patch_capture):
+    """A follower giving up leaves the capture running for everyone else.
+
+    The call sites disagree about the timeout (10s plate detection, 20s Obico),
+    so a follower must be able to abandon a join without cancelling a capture
+    other callers are still waiting on.
+    """
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=30))
+    await _let_leader_start(capture)
+    impatient = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=0.01))
+    patient = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=30))
+
+    assert await impatient is None  # gave up on its own deadline
+    gate.set()
+
+    assert await leader == FRAME_A
+    assert await patient == FRAME_A  # unaffected by the one that walked away
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_cancelled_leader_still_delivers_to_followers(patch_capture):
+    """Snapshot requests get cancelled routinely (client navigates away).
+
+    The follower must not lose the frame because the caller that happened to
+    open the connection went away.
+    """
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await _let_leader_start(capture)
+    follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await asyncio.sleep(0)
+
+    leader.cancel()
+    with pytest.raises(asyncio.CancelledError):
+        await leader
+    gate.set()
+
+    assert await follower == FRAME_A
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_cancelling_a_follower_leaves_the_leader_alone(patch_capture):
+    """The mirror case: the follower's cancellation is its own business."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await _let_leader_start(capture)
+    follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await asyncio.sleep(0)
+
+    follower.cancel()
+    with pytest.raises(asyncio.CancelledError):
+        await follower
+    gate.set()
+
+    assert await leader == FRAME_A
+    assert capture.count == 1
+
+
+@pytest.mark.asyncio
+async def test_capture_in_flight_reports_the_window(patch_capture):
+    """The predicate the diagnose tool uses to know it will join, not measure."""
+    gate = asyncio.Event()
+    capture = patch_capture(RecordingCapture(gate=gate))
+
+    assert capture_in_flight("10.0.2.43") is False
+
+    leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S"))
+    await _let_leader_start(capture)
+
+    assert capture_in_flight("10.0.2.43") is True
+    assert capture_in_flight("10.0.2.44") is False  # per printer
+
+    gate.set()
+    await leader
+    await asyncio.sleep(0)
+
+    assert capture_in_flight("10.0.2.43") is False

+ 73 - 0
backend/tests/unit/services/test_camera_diagnose.py

@@ -221,6 +221,79 @@ class TestFirstFrameStage:
         assert result.overall_status == "ok"
         assert result.summary_code == "all_ok"
         assert all(s.status == "ok" for s in result.stages)
+        assert result.stages[1].code is None  # we opened our own connection
+
+    @pytest.mark.asyncio
+    async def test_pass_riding_on_an_inflight_capture_says_so(self):
+        """#2705: a capture already running means we get its frame, not our own.
+
+        The frame is real evidence the camera works, so the stage still passes —
+        but duration_ms is then mostly time spent queueing behind someone
+        else's capture, and claiming a connection we never opened is exactly
+        what a diagnostic must not do."""
+
+        async def _tcp_ok(*_a, **_kw):
+            writer = AsyncMock()
+            return AsyncMock(), writer
+
+        with (
+            patch(
+                "backend.app.services.camera_diagnose.asyncio.open_connection",
+                new=_tcp_ok,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_in_flight",
+                return_value=True,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_camera_frame_bytes",
+                new_callable=AsyncMock,
+                return_value=b"\xff\xd8\xff\xd9",
+            ),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.overall_status == "ok"
+        assert result.summary_code == "all_ok"
+        assert result.stages[1].status == "ok"
+        assert result.stages[1].code == "coalesced_capture"
+
+    @pytest.mark.asyncio
+    async def test_failure_is_not_annotated_as_coalesced(self):
+        """A follower whose leader fails goes on to capture on its own, so a
+        failure here was this stage's own attempt — no qualifier needed."""
+
+        async def _tcp_ok(*_a, **_kw):
+            writer = AsyncMock()
+            return AsyncMock(), writer
+
+        with (
+            patch(
+                "backend.app.services.camera_diagnose.asyncio.open_connection",
+                new=_tcp_ok,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_in_flight",
+                return_value=True,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_camera_frame_bytes",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.summary_code == "no_frame"
+        assert result.stages[1].code == "no_frame"
 
 
 class TestResultMetadata:

+ 39 - 16
backend/tests/unit/services/test_notification_service.py

@@ -1168,16 +1168,24 @@ class TestBarkProvider:
         mock_client.post.assert_not_called()
 
     @pytest.mark.asyncio
-    async def test_send_bark_error_in_200_body(self, service):
-        """bark-server can wrap a failure in HTTP 200; the body code must win."""
+    async def test_send_bark_error_in_200_body(self, service, caplog):
+        """bark-server can wrap a failure in HTTP 200; the body code must win.
+
+        Only the numeric code is returned — the server is caller-supplied
+        (bark is self-hostable), so its free-text message is the same read
+        channel the HTTP-failure path closes. The text goes to the debug log.
+        """
         mock_client = self._client_returning(200, {"code": 400, "message": "device token invalid"})
 
         with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
             mock_get_client.return_value = mock_client
-            success, message = await service._send_bark({"device_key": "bad"}, "Title", "Body")
+            with caplog.at_level("DEBUG", logger="backend.app.services.notification_service"):
+                success, message = await service._send_bark({"device_key": "bad"}, "Title", "Body")
 
         assert success is False
-        assert "device token invalid" in message
+        assert "Bark error 400" in message
+        assert "device token invalid" not in message
+        assert "device token invalid" in caplog.text
 
     @pytest.mark.asyncio
     async def test_send_bark_http_error(self, service):
@@ -2476,10 +2484,15 @@ class TestNtfyOutbound:
         assert "<!DOCTYPE" not in detail
 
     @pytest.mark.asyncio
-    async def test_ntfy_normal_403_still_surfaces_body(self, service):
-        """A non-Cloudflare 403 (e.g. ntfy auth fail) must keep showing
-        the original body so the user can debug the real error — we
-        only intercept the Cloudflare-challenge shape."""
+    async def test_ntfy_normal_403_is_not_misread_as_a_cloudflare_challenge(self, service, caplog):
+        """A non-Cloudflare 403 (e.g. ntfy auth fail) must report the real
+        status rather than the Cloudflare-challenge advice — we only intercept
+        the challenge shape.
+
+        The origin's body is no longer returned to the API caller: the ntfy
+        server URL is caller-supplied, so echoing it made this an SSRF read
+        primitive. It goes to the debug log instead.
+        """
         import httpx
 
         mock_response = httpx.Response(
@@ -2491,7 +2504,10 @@ class TestNtfyOutbound:
         mock_client = AsyncMock()
         mock_client.post = AsyncMock(return_value=mock_response)
 
-        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+        with (
+            patch.object(service, "_get_client", AsyncMock(return_value=mock_client)),
+            caplog.at_level("DEBUG", logger="backend.app.services.notification_service"),
+        ):
             ok, detail = await service._send_ntfy(
                 {"server": "https://ntfy.sh", "topic": "alerts", "auth_token": "bad"},
                 title="t",
@@ -2500,16 +2516,19 @@ class TestNtfyOutbound:
 
         assert ok is False
         assert "Cloudflare" not in detail
-        assert "invalid auth token" in detail
-        assert detail.startswith("HTTP 403:")
+        assert detail.startswith("HTTP 403")
+        assert "invalid auth token" not in detail
+        assert "invalid auth token" in caplog.text
 
     @pytest.mark.asyncio
-    async def test_ntfy_origin_error_through_cloudflare_is_not_misclassified(self, service):
+    async def test_ntfy_origin_error_through_cloudflare_is_not_misclassified(self, service, caplog):
         """Cloudflare adds Server: cloudflare to EVERY proxied response,
         including legitimate origin errors. A real 401 "wrong token"
         from an ntfy server that happens to sit behind Cloudflare must
-        still surface the origin's actual error body — we must not flip
+        still be reported as the origin's status — we must not flip
         every CF-fronted 4xx into a "your Cloudflare is blocking" message.
+
+        As above, the origin body reaches the debug log rather than the caller.
         """
         import httpx
 
@@ -2527,7 +2546,10 @@ class TestNtfyOutbound:
         mock_client = AsyncMock()
         mock_client.post = AsyncMock(return_value=mock_response)
 
-        with patch.object(service, "_get_client", AsyncMock(return_value=mock_client)):
+        with (
+            patch.object(service, "_get_client", AsyncMock(return_value=mock_client)),
+            caplog.at_level("DEBUG", logger="backend.app.services.notification_service"),
+        ):
             ok, detail = await service._send_ntfy(
                 {"server": "https://ntfy.example", "topic": "alerts", "auth_token": "wrong"},
                 title="t",
@@ -2536,8 +2558,9 @@ class TestNtfyOutbound:
 
         assert ok is False
         assert "Cloudflare" not in detail
-        assert "unauthorized" in detail
-        assert detail.startswith("HTTP 401:")
+        assert detail.startswith("HTTP 401")
+        assert "unauthorized" not in detail
+        assert "unauthorized" in caplog.text
 
     @pytest.mark.asyncio
     async def test_ntfy_cloudflare_cf_mitigated_header_alone_triggers(self, service):

+ 50 - 0
backend/tests/unit/services/test_slicer_3mf_convert.py

@@ -443,3 +443,53 @@ class TestSubstituteUnusedPlateFilaments:
         result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
 
         assert result == ["pla.json", "pla.json", "pva.json"]
+
+    # ---- #2711: plate 0 is the slice-all sentinel, not a plate ----------
+
+    def test_no_op_for_the_slice_all_sentinel(self):
+        """``plate=0`` means every plate, so every slot is used by something."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [2])])})
+        items = ["pla_white.json", "pla_red.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=0, items=items)
+
+        assert result == items
+
+    def test_slice_all_is_not_collapsed_onto_the_support_filament(self):
+        """The guard that makes the plate-0 no-op load-bearing.
+
+        Geometry lookup for plate 0 matches nothing (plates are 1-indexed),
+        but the support-filament union reads project settings and has no
+        plate scope — so it survives as the *only* member of the used set.
+        Anchored on it, a slice-all would rewrite every colour in the
+        project to the support material and print the whole thing in PVA.
+        """
+        model_settings = self._model_settings_xml([(1, [1]), (2, [2]), (3, [3])])
+        project_settings = json.dumps(
+            {
+                "enable_support": "1",
+                "support_filament": "4",
+                "support_interface_filament": "4",
+                "filament_type": ["PLA", "PLA", "PLA", "PVA"],
+            }
+        ).encode()
+        zip_bytes = _make_3mf(
+            {
+                "Metadata/model_settings.config": model_settings,
+                "Metadata/project_settings.config": project_settings,
+            }
+        )
+        items = ["white.json", "red.json", "blue.json", "pva.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=0, items=items)
+
+        assert result == items, "slice-all collapsed the project onto the support filament"
+
+    def test_negative_plate_id_is_a_no_op(self):
+        """Not reachable through the schema (``ge=0``), but the function is the
+        thing that must not guess — a caller resolving a plate wrongly should
+        get the user's picks back, not a rewrite anchored on nothing."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1])])})
+        items = ["a.json", "b.json"]
+
+        assert substitute_unused_plate_filaments(zip_bytes, plate_id=-1, items=items) == items

+ 255 - 0
backend/tests/unit/services/test_total_layers_print_start.py

@@ -0,0 +1,255 @@
+"""The layer total must survive the print-start reset (#2702).
+
+`_update_state` applies `total_layer_num` early and, further down, resets
+`total_layers` when it detects a new print (added by #1771 so the previous
+print's total can't bleed into the next one's usage split). Those two run in
+the same function on the same frame, so a frame that carried both the new
+print's total *and* the transition into RUNNING had its total applied and then
+zeroed.
+
+That is unrecoverable rather than merely late: Bambu firmware sends only
+changed fields, so the printer never re-sends a total it already published.
+The value reappears only in a full pushall — i.e. on reconnect or a manual
+Force Refresh — which is why the reporter saw `n/0` for nine minutes on a
+flawless connection, why it looked random, and why a *stable* link made it
+worse.
+
+Frames here are trimmed to the fields the code under test reads. No printer is
+needed: the fix is a property of how one function orders its own writes.
+"""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+
+@pytest.fixture
+def client():
+    """A client with a recording stand-in for the MQTT connection."""
+    from unittest.mock import MagicMock
+
+    from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+    c = BambuMQTTClient(
+        ip_address="192.168.1.100",
+        serial_number="TEST123",
+        access_code="12345678",
+    )
+    c._client = MagicMock()
+    # A new print is only detected once a previous state has been observed
+    # (#1304 guard), so give every test a plausible pre-print history.
+    c._previous_gcode_state = "IDLE"
+    c._previous_gcode_file = None
+    c._was_running = False
+    return c
+
+
+def pushalls(client) -> list[dict]:
+    """Every pushall published on this client, decoded."""
+    sent = []
+    for call in client._client.publish.call_args_list:
+        payload = json.loads(call.args[1])
+        if payload.get("pushing", {}).get("command") == "pushall":
+            sent.append(payload)
+    return sent
+
+
+def running_frame(**extra) -> dict:
+    """A frame that flips the printer into RUNNING with a file — a new print."""
+    return {"gcode_state": "RUNNING", "gcode_file": "widget.3mf", "subtask_name": "widget", **extra}
+
+
+# ---------------------------------------------------------------------------
+# The regression
+# ---------------------------------------------------------------------------
+
+
+def test_total_arriving_with_the_start_frame_survives(client):
+    """The reported bug: total and transition in one frame lost the total."""
+    client._update_state(running_frame(total_layer_num=33, layer_num=0))
+
+    assert client.state.total_layers == 33
+
+
+def test_total_arriving_with_the_start_frame_needs_no_pushall(client):
+    """We already have the denominator, so don't spend a round-trip on it."""
+    client._update_state(running_frame(total_layer_num=33))
+
+    assert pushalls(client) == []
+    assert client._total_layers_refresh_armed is False
+
+
+def test_previous_prints_total_still_cannot_bleed_through(client):
+    """#1771's reason for the reset — preserved exactly.
+
+    A start frame with no total of its own must land on 0, never on the
+    finished print's denominator.
+    """
+    client.state.total_layers = 120  # left over from the print that just ended
+
+    client._update_state(running_frame())
+
+    assert client.state.total_layers == 0
+
+
+def test_start_frame_without_a_total_asks_the_printer_for_one(client):
+    """Covers the ordering where the total was published a frame or two early.
+
+    Re-applying this frame's own value can't help there — the value was
+    already consumed and zeroed — so recovery has to come from a pushall,
+    the only message that re-sends unchanged fields.
+    """
+    client._update_state(running_frame())
+
+    assert len(pushalls(client)) == 1
+    assert client._total_layers_refresh_armed is True
+
+
+# ---------------------------------------------------------------------------
+# The one-shot re-request
+# ---------------------------------------------------------------------------
+
+
+def test_first_layer_advance_without_a_total_re_requests_once(client):
+    client._update_state(running_frame())
+    assert len(pushalls(client)) == 1  # from print start
+
+    client._update_state({"layer_num": 1})
+
+    assert len(pushalls(client)) == 2
+    assert client._total_layers_refresh_armed is False
+
+
+def test_later_layer_advances_do_not_keep_re_requesting(client):
+    """An unanswered pushall must not become a per-layer retry loop."""
+    client._update_state(running_frame())
+    client._update_state({"layer_num": 1})
+    before = len(pushalls(client))
+
+    for layer in range(2, 12):
+        client._update_state({"layer_num": layer})
+
+    assert len(pushalls(client)) == before
+
+
+def test_no_re_request_once_the_total_is_known(client):
+    """The pushall answered: layers advance without further traffic."""
+    client._update_state(running_frame())
+    client._update_state({"total_layer_num": 33})  # the pushall's answer
+    before = len(pushalls(client))
+
+    client._update_state({"layer_num": 1})
+    client._update_state({"layer_num": 2})
+
+    assert client.state.total_layers == 33
+    assert len(pushalls(client)) == before
+
+
+def test_the_recovered_total_is_what_downstream_reads(client):
+    """End-to-end on the reporter's sequence, minus the 9-minute wait.
+
+    Start with no total, layers advance at `n/0`, the pushall answers, and
+    from then on the UI, `{total_layers}` notifications and the usage-split
+    denominator all see 33 — they read this one field.
+    """
+    client._update_state(running_frame())
+    client._update_state({"layer_num": 1})
+    assert client.state.total_layers == 0  # the symptom in the screenshot
+
+    client._update_state({"layer_num": 2, "total_layer_num": 33})
+
+    assert (client.state.layer_num, client.state.total_layers) == (2, 33)
+
+
+def test_the_pushall_answer_does_not_re_trigger_the_reset(client):
+    """Loop safety: the answer is a *full* frame, gcode_state and file included.
+
+    If that re-tripped the new-print detection it would reset the total it just
+    delivered and request another pushall, once per round-trip, forever.
+    """
+    client._update_state(running_frame())
+    assert len(pushalls(client)) == 1
+
+    client._update_state(running_frame(total_layer_num=33, layer_num=1, mc_percent=3))
+
+    assert client.state.total_layers == 33
+    assert len(pushalls(client)) == 1
+
+
+# ---------------------------------------------------------------------------
+# Interaction with the pre-existing firmware-reset guard
+# ---------------------------------------------------------------------------
+
+
+def test_firmware_reset_to_zero_mid_print_is_still_ignored(client):
+    """P1S zeroes total_layer_num at print end; #1771's guard keeps the total."""
+    client._update_state(running_frame(total_layer_num=33))
+
+    client._update_state({"layer_num": 33, "total_layer_num": 0})
+
+    assert client.state.total_layers == 33
+
+
+@pytest.mark.parametrize("value", [None, "", 0, "0", -1, "abc", "33.7", [], {}, 3.9])
+def test_unusable_totals_do_not_break_ingest(client, value):
+    """A bad total must not escape `_update_state`.
+
+    The old parse did a bare ``int(data["total_layer_num"])``. `_on_message`
+    catches only `JSONDecodeError` and paho is left at
+    ``suppress_exceptions = False``, so anything this raised was re-raised on
+    the network thread and took the printer connection down over one field.
+    `None`, `[]` and `{}` all did exactly that.
+    """
+    client._update_state(running_frame(total_layer_num=value))
+
+    assert client.state.total_layers in (0, 3)  # 3.9 truncates; the rest are 0
+    assert client.state.gcode_file == "widget.3mf"  # the rest of the frame landed
+
+
+def test_an_unusable_total_does_not_stop_the_layer_counter(client):
+    """The read happens before the layer block, so it must not be able to raise.
+
+    Otherwise a firmware sending a malformed total would freeze `layer_num` for
+    the whole print — the frame would abort before reaching it.
+    """
+    client._update_state(running_frame())
+
+    client._update_state({"layer_num": 7, "total_layer_num": "not-a-number"})
+
+    assert client.state.layer_num == 7
+
+
+def test_a_string_total_is_accepted(client):
+    """Bambu ships numbers as strings in plenty of other fields."""
+    client._update_state(running_frame(total_layer_num="33"))
+
+    assert client.state.total_layers == 33
+
+
+def test_a_zero_total_on_the_start_frame_counts_as_no_total(client):
+    """`total_layer_num: 0` is the firmware's "don't know yet", not a value."""
+    client.state.total_layers = 120
+
+    client._update_state(running_frame(total_layer_num=0))
+
+    assert client.state.total_layers == 0
+    assert len(pushalls(client)) == 1
+
+
+# ---------------------------------------------------------------------------
+# A restarted print (file change while RUNNING) takes the same path
+# ---------------------------------------------------------------------------
+
+
+def test_file_change_while_running_also_keeps_its_own_total(client):
+    """`is_file_change` shares the reset, so it needs the same treatment."""
+    client._update_state(running_frame(total_layer_num=33))
+    client._was_running = True
+
+    client._update_state(
+        {"gcode_state": "RUNNING", "gcode_file": "other.3mf", "subtask_name": "other", "total_layer_num": 77}
+    )
+
+    assert client.state.total_layers == 77

+ 118 - 7
backend/tests/unit/test_camera_ffmpeg_termination.py

@@ -1,20 +1,33 @@
-"""Bounded post-kill ffmpeg cleanup (#2580, fix shape from PR #2581 by @ronaldheft).
+"""ffmpeg teardown: draining the pipes, and the bounded waits behind it.
 
-A SIGKILLed ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take
-arbitrarily long to be reaped. The cleanup paths used to ``await process.wait()``
-unbounded after ``kill()`` — on a P2S RTSP read timeout this parked the fan-out
-stream coroutine for 12 hours, leaving every viewer attached to a stalled
-broadcaster while snapshots/diagnostics (fresh connections) kept working.
+Originally #2580 (fix shape from PR #2581 by @ronaldheft): the cleanup paths
+``await process.wait()``-ed unbounded after ``kill()``, which on a P2S RTSP read
+timeout parked the fan-out stream coroutine for 12 hours, leaving every viewer
+attached to a stalled broadcaster while snapshots (fresh connections) kept
+working. Three places had it, all bounded now:
 
-The same unbounded wait existed in THREE places, all bounded now:
 1. ``_terminate_ffmpeg`` — the stream generator's cleanup (the reported hang).
 2. ``stop_camera`` — hung the very request a user makes to recover.
 3. ``cleanup_orphaned_streams`` — hung the janitor that is the safety net.
+
+That diagnosis — "a SIGKILLed ffmpeg stuck in uninterruptible I/O" — turned out
+to be wrong, and the bound was capping a deadlock of our own making. ffmpeg was
+blocked writing to a stdout pipe nobody was reading, which makes SIGTERM
+unactionable, and ``wait()`` cannot observe an exit while a pipe transport is
+still undrained. So the abandon path fired on every camera close, costing 4s of
+the printer's single camera connection each time. The pipes are drained now; the
+bounds remain as backstops, and the tests for them stay valid.
+
+The draining tests below drive a REAL subprocess, because the failure is in
+asyncio's pipe/transport bookkeeping — a fake process object cannot reproduce
+it and would happily pass against the broken code.
 """
 
 from __future__ import annotations
 
 import asyncio
+import logging
+import sys
 import time
 from contextlib import suppress
 
@@ -24,6 +37,46 @@ from backend.app.api.routes import camera
 
 pytestmark = pytest.mark.asyncio
 
+# Stands in for ffmpeg: floods stdout, and handles SIGTERM the way ffmpeg does
+# — a handler that sets a flag which only the main loop checks, so a process
+# blocked in write() never acts on it until something drains the pipe.
+_FFMPEG_LIKE = """
+import signal, sys
+stop = False
+def _handler(*_a):
+    global stop
+    stop = True
+signal.signal(signal.SIGTERM, _handler)
+sys.stderr.write("x" * 4096)
+sys.stderr.flush()
+while not stop:
+    sys.stdout.buffer.write(b"x" * 65536)
+    sys.stdout.buffer.flush()
+"""
+
+# Same, but SIGTERM is ignored outright — forces the SIGKILL branch.
+_SIGTERM_PROOF = """
+import signal, sys
+signal.signal(signal.SIGTERM, signal.SIG_IGN)
+while True:
+    sys.stdout.buffer.write(b"x" * 65536)
+    sys.stdout.buffer.flush()
+"""
+
+
+async def _spawn(program: str) -> asyncio.subprocess.Process:
+    """Start the stand-in and let it fill its stdout pipe, as the cancel path
+    leaves a real ffmpeg."""
+    process = await asyncio.create_subprocess_exec(
+        sys.executable,
+        "-c",
+        program,
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.PIPE,
+    )
+    await asyncio.sleep(0.4)
+    return process
+
 
 class _FakeServer:
     def close(self) -> None:
@@ -106,6 +159,64 @@ class _FrameProcess:
         return self.returncode
 
 
+# ---------------------------------------------------------------------------
+# 0. _terminate_ffmpeg drains the pipes — against a real subprocess
+# ---------------------------------------------------------------------------
+
+
+async def test_terminate_drains_stdout_so_sigterm_works(caplog):
+    """A process blocked writing to a full pipe still shuts down on SIGTERM.
+
+    Undrained, this took the full grace period plus the SIGKILL bound (4s
+    measured) and ended in the abandon error. Drained, SIGTERM lands.
+    """
+    process = await _spawn(_FFMPEG_LIKE)
+    camera._spawned_ffmpeg_pids[process.pid] = time.time()
+
+    with caplog.at_level(logging.WARNING, logger=camera.logger.name):
+        started = time.monotonic()
+        await asyncio.wait_for(camera._terminate_ffmpeg(process, "test-drain"), timeout=5.0)
+        elapsed = time.monotonic() - started
+
+    assert process.returncode is not None, "wait() must observe the exit"
+    # Comfortably under the 2.0s grace period: proves SIGTERM was acted on
+    # rather than timing out into the kill branch.
+    assert elapsed < 1.5, f"teardown took {elapsed:.2f}s — pipes likely not drained"
+    assert "didn't terminate gracefully" not in caplog.text
+    assert "abandoning wait" not in caplog.text
+    assert process.pid not in camera._spawned_ffmpeg_pids
+
+
+async def test_terminate_observes_kill_of_a_sigterm_proof_process(monkeypatch, caplog):
+    """Even when SIGTERM is genuinely ignored, wait() must see the SIGKILL.
+
+    This is the case the abandon error was invented for. With the pipes drained
+    the exit is observable, so it must not fire.
+    """
+    monkeypatch.setattr(camera, "_FFMPEG_TERM_TIMEOUT", 0.3)
+    process = await _spawn(_SIGTERM_PROOF)
+    camera._spawned_ffmpeg_pids[process.pid] = time.time()
+
+    with caplog.at_level(logging.WARNING, logger=camera.logger.name):
+        await asyncio.wait_for(camera._terminate_ffmpeg(process, "test-kill"), timeout=5.0)
+
+    assert process.returncode == -9, "SIGKILLed exit must be observed, not abandoned"
+    assert "didn't terminate gracefully" in caplog.text  # SIGTERM really was ignored
+    assert "abandoning wait" not in caplog.text
+    assert process.pid not in camera._spawned_ffmpeg_pids
+
+
+async def test_terminate_is_a_noop_for_an_already_dead_process():
+    """The early return must still drop the pid from the tracking dict."""
+    process = await asyncio.create_subprocess_exec(sys.executable, "-c", "pass")
+    await process.wait()
+    camera._spawned_ffmpeg_pids[process.pid] = time.time()
+
+    await asyncio.wait_for(camera._terminate_ffmpeg(process, "test-dead"), timeout=2.0)
+
+    assert process.pid not in camera._spawned_ffmpeg_pids
+
+
 # ---------------------------------------------------------------------------
 # 1. _terminate_ffmpeg — the helper itself is bounded
 # ---------------------------------------------------------------------------

+ 287 - 0
backend/tests/unit/test_camera_stderr_tail.py

@@ -0,0 +1,287 @@
+"""Continuous stderr draining for streaming ffmpeg (_FfmpegStderrTail).
+
+ffmpeg is spawned with stderr=PIPE, and stderr used to be read only when
+something had already gone wrong — so for the life of a stream nobody read that
+pipe. ffmpeg writes its banner, the input analysis and then a progress line at a
+steady rate, so a 64 KiB pipe fills eventually, ffmpeg blocks writing to it,
+frames stop, and the stream's own read timeout fires with nothing in the log
+explaining that we starved it.
+
+How long that takes is unmeasured and evidently long — one H2D upstream ran
+21m36s without stalling — so this is a bounded resource being treated as
+unbounded rather than an observed failure. These tests pin the four properties
+that matter: the pipe is always drained, the retained tail is bounded, the tail
+is what the error paths report, and it goes through the same redaction funnel as
+every other stderr log in this module.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from backend.app.api.routes import camera
+
+pytestmark = pytest.mark.asyncio
+
+
+class _Reader:
+    """Feeds queued chunks, then blocks like a live-but-quiet ffmpeg."""
+
+    def __init__(self, chunks: list[bytes], then_block: bool = True) -> None:
+        self._chunks = list(chunks)
+        self._then_block = then_block
+        self.reads = 0
+
+    async def read(self, _size: int = -1) -> bytes:
+        self.reads += 1
+        if self._chunks:
+            return self._chunks.pop(0)
+        if self._then_block:
+            await asyncio.Event().wait()  # never returns, never EOFs
+        return b""
+
+
+class _Proc:
+    def __init__(self, reader, pid: int = 88010) -> None:
+        self.pid = pid
+        self.returncode = None
+        self.stdout = None
+        self.stderr = reader
+
+
+@pytest.fixture(autouse=True)
+def _no_leaked_tails():
+    yield
+    # Last-resort teardown only — this fixture is sync, so it cancels without
+    # awaiting. Tests are expected to aclose() their own tails.
+    for tail in list(camera._stderr_tails.values()):
+        if tail._task is not None:
+            tail._task.cancel()
+    camera._stderr_tails.clear()
+
+
+async def _settle() -> None:
+    """Let the pump task run."""
+    for _ in range(5):
+        await asyncio.sleep(0)
+
+
+async def test_it_keeps_draining_a_stream_that_never_closes_stderr():
+    """The whole point: the pipe is read continuously, not on demand."""
+    reader = _Reader([b"first\n", b"second\n"])
+    tail = camera._FfmpegStderrTail(_Proc(reader))
+
+    await _settle()
+
+    assert reader.reads >= 3, "pump stopped reading instead of following the pipe"
+    assert "second" in (tail.text() or "")
+    await tail.aclose()
+
+
+async def test_the_retained_tail_is_bounded():
+    """A long-running stream must not turn the pipe into unbounded memory."""
+    oversized = b"x" * (camera._FFMPEG_STDERR_TAIL_BYTES * 3)
+    tail = camera._FfmpegStderrTail(_Proc(_Reader([oversized])))
+
+    await _settle()
+
+    assert len(tail._buffer) == camera._FFMPEG_STDERR_TAIL_BYTES
+    await tail.aclose()
+
+
+async def test_the_tail_keeps_the_newest_output():
+    """Recent output is what explains a failure; the banner gets stripped anyway."""
+    filler = b"stale-line\n" * 4000
+    tail = camera._FfmpegStderrTail(_Proc(_Reader([filler, b"Connection timed out\n"])))
+
+    await _settle()
+
+    text = tail.text() or ""
+    assert "Connection timed out" in text
+    assert len(tail._buffer) <= camera._FFMPEG_STDERR_TAIL_BYTES
+    await tail.aclose()
+
+
+async def test_read_ffmpeg_stderr_defers_to_the_collector():
+    """Two readers on one StreamReader raise, so the on-demand read must not
+    touch a pipe the collector owns."""
+    reader = _Reader([b"Server returned 401 Unauthorized\n"])
+    process = _Proc(reader)
+    tail = camera._FfmpegStderrTail(process)
+    await _settle()
+    reads_before = reader.reads
+
+    result = await camera._read_ffmpeg_stderr(process)
+
+    assert "401 Unauthorized" in (result or "")
+    assert reader.reads == reads_before, "on-demand read raced the collector"
+    await tail.aclose()
+
+
+async def test_read_ffmpeg_stderr_still_reads_the_pipe_without_a_collector():
+    """An immediately-failed ffmpeg has no collector; that path must still work."""
+    process = _Proc(_Reader([b"Server returned 404 Not Found\n"], then_block=False))
+
+    result = await camera._read_ffmpeg_stderr(process)
+
+    assert "404 Not Found" in (result or "")
+
+
+async def test_the_tail_redacts_the_access_code():
+    """ffmpeg echoes its input URL, which carries the printer's access code.
+
+    This is a new stderr-to-log path, so it gets the same guarantee as the rest:
+    everything goes through _summarize_ffmpeg_stderr.
+    """
+    secret = "12345678"
+    leaky = f"[rtsp @ 0x55] Failed to resolve rtsp://bblp:{secret}@127.0.0.1:8554/streaming/live/1\n"
+    tail = camera._FfmpegStderrTail(_Proc(_Reader([leaky.encode()])))
+
+    await _settle()
+    text = tail.text() or ""
+
+    assert secret not in text, "access code leaked into a log line"
+    # Assert the line SURVIVED with the credential masked, not that it was
+    # dropped — otherwise this passes whenever the summariser happens to filter
+    # the line out, and proves nothing about redaction.
+    assert "Failed to resolve" in text, "line was filtered, so redaction is untested"
+    assert "[REDACTED]" in text
+    await tail.aclose()
+
+
+async def test_close_releases_ownership_and_is_idempotent():
+    process = _Proc(_Reader([b"line\n"]))
+    tail = camera._FfmpegStderrTail(process)
+    await _settle()
+    assert camera._stderr_tails.get(process.pid) is tail
+
+    await tail.aclose()
+    await tail.aclose()  # must not raise
+
+    assert process.pid not in camera._stderr_tails
+
+
+async def test_a_process_without_stderr_is_handled():
+    """Fakes and some spawn paths pass stderr=None; must not register or crash."""
+    process = _Proc(None, pid=88099)
+
+    tail = camera._FfmpegStderrTail(process)
+
+    assert tail.text() is None
+    assert process.pid not in camera._stderr_tails
+    await tail.aclose()
+
+
+async def test_the_stream_generator_owns_then_releases_the_collector(monkeypatch):
+    """Lifecycle inside the real generator: registered while streaming, gone after.
+
+    The other generator tests use fakes with stderr=None, so they never build a
+    collector at all — this is the one that would catch a missing close() or a
+    reader race between the collector and teardown.
+    """
+    printer_id = 8842
+    stream_id = f"{printer_id}-fanout-stderrtail"
+
+    class _FrameThenBlock:
+        def __init__(self) -> None:
+            self._sent = False
+
+        async def read(self, _size: int = -1) -> bytes:
+            if self._sent:
+                await asyncio.Event().wait()  # stay alive, don't trigger reconnect
+            self._sent = True
+            return b"\xff\xd8frame\xff\xd9"
+
+    class _Proc2:
+        def __init__(self) -> None:
+            self.pid = 88042
+            self.returncode = None
+            self.stdout = _FrameThenBlock()
+            self.stderr = _Reader([b"Stream #0:0: Video: h264\n"])
+
+        def terminate(self) -> None:
+            self.returncode = 0
+
+        def kill(self) -> None:
+            self.returncode = -9
+
+        async def wait(self) -> int:
+            if self.returncode is None:
+                self.returncode = 0
+            return self.returncode
+
+    process = _Proc2()
+
+    class _FakeServer:
+        def close(self) -> None:
+            pass
+
+        async def wait_closed(self) -> None:
+            pass
+
+    async def _fake_exec(*_a, **_kw):
+        return process
+
+    async def _fake_proxy(_ip, _port):
+        return 48777, _FakeServer()
+
+    monkeypatch.setattr(camera, "get_ffmpeg_path", lambda: "/fake/ffmpeg")
+    monkeypatch.setattr(camera, "create_tls_proxy", _fake_proxy)
+    monkeypatch.setattr(camera.asyncio, "create_subprocess_exec", _fake_exec)
+
+    stream = camera.generate_rtsp_mjpeg_stream(
+        ip_address="192.0.2.44",
+        access_code="c",
+        model="P2S",
+        fps=15,
+        stream_id=stream_id,
+        disconnect_event=asyncio.Event(),
+        printer_id=printer_id,
+    )
+    try:
+        chunk = await asyncio.wait_for(anext(stream), timeout=5.0)
+        assert b"frame" in chunk
+        assert process.pid in camera._stderr_tails, "generator did not take stderr ownership"
+
+        await asyncio.wait_for(stream.aclose(), timeout=5.0)
+
+        assert process.pid not in camera._stderr_tails, "collector outlived its stream"
+    finally:
+        camera._active_streams.pop(stream_id, None)
+        camera._disconnect_events.pop(stream_id, None)
+        camera._stream_last_frame_times.pop(stream_id, None)
+        camera._last_frames.pop(printer_id, None)
+        camera._last_frame_times.pop(printer_id, None)
+        camera._stream_start_times.pop(printer_id, None)
+        camera._spawned_ffmpeg_pids.pop(process.pid, None)
+
+
+async def test_terminate_skips_stderr_while_a_collector_owns_it():
+    """_terminate_ffmpeg must not add a second reader to an owned pipe."""
+    reader = _Reader([b"tearing down\n"])
+
+    class _Killable(_Proc):
+        def terminate(self):
+            self.returncode = 0
+
+        def kill(self):
+            self.returncode = -9
+
+        async def wait(self):
+            if self.returncode is None:
+                self.returncode = 0
+            return self.returncode
+
+    process = _Killable(reader, pid=88020)
+    tail = camera._FfmpegStderrTail(process)
+    await _settle()
+    reads_before = reader.reads
+
+    await asyncio.wait_for(camera._terminate_ffmpeg(process, "88020-fanout-abcd"), timeout=2.0)
+
+    # The collector, not _terminate_ffmpeg, is the only reader that advanced.
+    assert reader.reads >= reads_before
+    assert tail.text() is not None
+    await tail.aclose()

+ 238 - 0
backend/tests/unit/test_camera_stream_registry_isolation.py

@@ -0,0 +1,238 @@
+"""A departing camera stream must not clean up its successor's state.
+
+The fan-out stream id used to be ``f"{printer_id}-fanout"`` — constant per
+printer, so every successive stream shared one registry key — and the
+generator's ``finally`` popped the per-printer frame buffer unconditionally.
+Teardown taking ~4s (the undrained-pipe deadlock, fixed separately) made the
+overlap wide enough to hit by closing and reopening the camera:
+
+    12.221  stream A cancelled, begins teardown
+    12.324  new viewer attaches
+    16.223  A finishes killing
+    16.224  new generator registers _active_streams["1-fanout"]
+            ...then A's finally pops that very entry
+
+The damage is not cosmetic. ``is_stream_active()`` is what the #1348 / #1271
+guards consult before deciding whether opening a second camera connection is
+safe, so a printer with a viewer attached looked idle; the janitor's /proc scan
+reaps any ffmpeg missing from ``_active_streams``, so it killed the live stream;
+and ``/camera/stop`` reported ``Stopped 0``.
+
+The external-camera path already solved this with a per-instance id (#2675).
+These tests pin the same property for the fan-out path.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from contextlib import suppress
+
+import pytest
+
+from backend.app.api.routes import camera
+
+pytestmark = pytest.mark.asyncio
+
+PRINTER_ID = 7701
+
+
+@pytest.fixture(autouse=True)
+def _clean_registries():
+    """These registries are module-global; leave them as we found them."""
+
+    def _purge():
+        for sid in [k for k in camera._active_streams if k.startswith(f"{PRINTER_ID}-")]:
+            camera._active_streams.pop(sid, None)
+        for sid in [k for k in camera._active_chamber_streams if k.startswith(f"{PRINTER_ID}-")]:
+            camera._active_chamber_streams.pop(sid, None)
+        for sid in [k for k in camera._stream_last_frame_times if k.startswith(f"{PRINTER_ID}-")]:
+            camera._stream_last_frame_times.pop(sid, None)
+        for sid in [k for k in camera._disconnect_events if k.startswith(f"{PRINTER_ID}-")]:
+            camera._disconnect_events.pop(sid, None)
+        camera._last_frames.pop(PRINTER_ID, None)
+        camera._last_frame_times.pop(PRINTER_ID, None)
+        camera._stream_start_times.pop(PRINTER_ID, None)
+
+    _purge()
+    yield
+    _purge()
+
+
+def _seed_frame_state() -> None:
+    camera._last_frames[PRINTER_ID] = b"\xff\xd8live\xff\xd9"
+    camera._last_frame_times[PRINTER_ID] = time.time()
+    camera._stream_start_times[PRINTER_ID] = time.time()
+
+
+# ---------------------------------------------------------------------------
+# _new_fanout_stream_id — one key per stream, not per printer
+# ---------------------------------------------------------------------------
+
+
+async def test_fanout_stream_ids_are_unique_per_stream():
+    """Two streams for one printer must never collide in the registries."""
+    ids = {camera._new_fanout_stream_id(PRINTER_ID) for _ in range(50)}
+
+    assert len(ids) == 50, "ids collide, so one stream can clean up another's entry"
+
+
+async def test_camera_stream_has_no_function_local_module_imports():
+    """A local ``import x`` anywhere in camera_stream shadows x for the WHOLE
+    function, including branches that never reach the import.
+
+    This is not hypothetical: an ``import uuid`` inside the external-camera
+    branch meant building the fan-out id on the RTSP path raised
+    UnboundLocalError, so the camera would not start on any printer without an
+    external camera configured. ``time`` and ``uuid`` are module-level now;
+    keep them that way.
+    """
+    import ast
+    import inspect
+
+    tree = ast.parse(inspect.getsource(camera.camera_stream))
+    local_imports = [alias.name for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names]
+
+    assert local_imports == [], f"function-local imports shadow the whole function: {local_imports}"
+
+
+async def test_fanout_stream_id_keeps_the_printer_prefix():
+    """is_stream_active / stop_camera_stream / camera-status all scan for it."""
+    stream_id = camera._new_fanout_stream_id(PRINTER_ID)
+
+    assert stream_id.startswith(f"{PRINTER_ID}-")
+    camera._active_streams[stream_id] = object()
+    assert camera.is_stream_active(PRINTER_ID) is True
+
+
+# ---------------------------------------------------------------------------
+# _release_printer_frame_state — the ownership check itself
+# ---------------------------------------------------------------------------
+
+
+async def test_frame_state_survives_when_another_rtsp_stream_is_live():
+    _seed_frame_state()
+    camera._active_streams[f"{PRINTER_ID}-fanout-successor"] = object()
+
+    camera._release_printer_frame_state(PRINTER_ID)
+
+    assert PRINTER_ID in camera._last_frames, "successor's buffered frame was wiped"
+    assert PRINTER_ID in camera._last_frame_times
+    assert PRINTER_ID in camera._stream_start_times
+
+
+async def test_frame_state_survives_when_a_chamber_stream_is_live():
+    """A1/P1 models register in a different dict; ownership spans both."""
+    _seed_frame_state()
+    camera._active_chamber_streams[f"{PRINTER_ID}-fanout-successor"] = (None, None)
+
+    camera._release_printer_frame_state(PRINTER_ID)
+
+    assert PRINTER_ID in camera._last_frames
+
+
+async def test_last_stream_out_releases_the_frame_state():
+    """The other half: with nothing left running, stale state must not linger."""
+    _seed_frame_state()
+
+    camera._release_printer_frame_state(PRINTER_ID)
+
+    assert PRINTER_ID not in camera._last_frames
+    assert PRINTER_ID not in camera._last_frame_times
+    assert PRINTER_ID not in camera._stream_start_times
+
+
+async def test_release_is_a_noop_without_a_printer_id():
+    _seed_frame_state()
+
+    camera._release_printer_frame_state(None)
+
+    assert PRINTER_ID in camera._last_frames
+
+
+# ---------------------------------------------------------------------------
+# The whole generator cleanup path, with a successor already registered
+# ---------------------------------------------------------------------------
+
+
+class _FakeServer:
+    def close(self) -> None:
+        pass
+
+    async def wait_closed(self) -> None:
+        pass
+
+
+class _OneFrameThenEOF:
+    def __init__(self) -> None:
+        self._sent = False
+
+    async def read(self, _size: int = -1) -> bytes:
+        if self._sent:
+            return b""
+        self._sent = True
+        return b"\xff\xd8predecessor\xff\xd9"
+
+
+class _Proc:
+    def __init__(self, pid: int = 77010) -> None:
+        self.pid = pid
+        self.returncode = None
+        self.stdout = _OneFrameThenEOF()
+        self.stderr = None
+
+    def terminate(self) -> None:
+        self.returncode = 0
+
+    def kill(self) -> None:
+        self.returncode = -9
+
+    async def wait(self) -> int:
+        if self.returncode is None:
+            self.returncode = 0
+        return self.returncode
+
+
+async def test_departing_generator_leaves_its_successors_registry_entry_alone(monkeypatch):
+    """End of the real cleanup path, with a second stream already registered."""
+
+    async def _fake_exec(*_args, **_kwargs):
+        return _Proc()
+
+    async def _fake_proxy(_ip: str, _port: int):
+        return 48999, _FakeServer()
+
+    monkeypatch.setattr(camera, "get_ffmpeg_path", lambda: "/fake/ffmpeg")
+    monkeypatch.setattr(camera, "create_tls_proxy", _fake_proxy)
+    monkeypatch.setattr(camera.asyncio, "create_subprocess_exec", _fake_exec)
+
+    predecessor_id = f"{PRINTER_ID}-fanout-aaaaaaaa"
+    successor_id = f"{PRINTER_ID}-fanout-bbbbbbbb"
+
+    stream = camera.generate_rtsp_mjpeg_stream(
+        ip_address="192.0.2.31",
+        access_code="test-code",
+        model="P2S",
+        fps=15,
+        stream_id=predecessor_id,
+        disconnect_event=asyncio.Event(),
+        printer_id=PRINTER_ID,
+    )
+
+    # Drive it far enough to buffer a frame, as a real viewer would.
+    chunk = await asyncio.wait_for(anext(stream), timeout=5.0)
+    assert b"predecessor" in chunk
+    assert camera._last_frames[PRINTER_ID].endswith(b"predecessor\xff\xd9")
+
+    # A viewer reopens the camera mid-teardown: a fresh stream registers under
+    # its own id and republishes the buffered frame.
+    camera._active_streams[successor_id] = object()
+    camera._last_frames[PRINTER_ID] = b"\xff\xd8successor\xff\xd9"
+
+    with suppress(Exception):
+        await asyncio.wait_for(stream.aclose(), timeout=5.0)
+
+    assert successor_id in camera._active_streams, "predecessor removed its successor's entry"
+    assert camera.is_stream_active(PRINTER_ID) is True, "a viewer is attached; guards must see it"
+    assert camera._last_frames[PRINTER_ID].endswith(b"successor\xff\xd9"), "successor's frame was wiped"
+    assert predecessor_id not in camera._active_streams, "predecessor must still clean up after itself"

+ 4 - 0
backend/tests/unit/test_camera_usb_stream_cleanup.py

@@ -33,6 +33,10 @@ class _CleanProc:
     def __init__(self, pid: int) -> None:
         self.pid = pid
         self.returncode = None
+        # Real Process objects always expose these (None when not piped), and
+        # _terminate_ffmpeg drains them so a full pipe can't wedge the exit.
+        self.stdout = None
+        self.stderr = None
 
     def terminate(self) -> None:
         self.returncode = 0

+ 29 - 0
backend/tests/unit/test_config_env_warnings.py

@@ -9,6 +9,35 @@ import logging
 import pytest
 
 
+@pytest.fixture(autouse=True)
+def _restore_config_module():
+    """Undo the ``importlib.reload`` these tests depend on.
+
+    Reloading ``backend.app.core.config`` re-executes it, so ``settings`` becomes
+    a *new* object built from the environment as it stands mid-test. Nothing put
+    the old one back. ``monkeypatch`` unwinds the env vars, not the reload.
+
+    The result is two live ``Settings`` instances in one process: every module
+    that did ``from ... config import settings`` at import time keeps the
+    original, while anything resolving ``config.settings`` afterwards gets the
+    replacement — and under xdist that split persisted for every later test in
+    the same worker. It surfaced as unrelated path assertions failing with a
+    ``base_dir`` from *this* module's tmp_path (``TestLibraryPathHelpers``,
+    ``TestUploadSourceThreeMF``, ``TestArchivePlatesDesignOverrides``,
+    ``TestSystemHealthAPI``), which is why it looked like a random flake and
+    moved between runs as the work distribution changed.
+
+    Snapshotting the whole module dict rather than just ``settings`` restores
+    object *identity*, which is what the two views have to agree on.
+    """
+    import backend.app.core.config as cfg_mod
+
+    saved = dict(cfg_mod.__dict__)
+    yield
+    cfg_mod.__dict__.clear()
+    cfg_mod.__dict__.update(saved)
+
+
 @pytest.mark.unit
 def test_unknown_mfa_env_var_logs_info(monkeypatch, caplog):
     """A typo'd MFA_* env var must be logged at INFO so operators see it."""

+ 144 - 0
backend/tests/unit/test_dispatch_claim_recovery.py

@@ -0,0 +1,144 @@
+"""A dispatch claim must not survive the dispatch that held it (#2615, #2702).
+
+``dispatching_at`` holds a queue row out of the selection query for the duration
+of an upload. Clearing it is best-effort, and the observed failure was narrow:
+PostgreSQL refused a connection for a second or two at exactly the moment
+dispatch ended, the single clear attempt failed, and the row stayed invisible to
+the scheduler until the process restarted.
+
+Two independent recoveries, tested here: the clear retries, and a later tick
+releases any claim with no dispatch behind it.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+@pytest.fixture
+def scheduler():
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    return PrintScheduler()
+
+
+def _session(fail_times: int) -> MagicMock:
+    """A session whose execute() fails `fail_times` times, then succeeds."""
+    db = MagicMock()
+    calls = {"n": 0}
+
+    async def execute(*_a, **_k):
+        calls["n"] += 1
+        if calls["n"] <= fail_times:
+            raise RuntimeError("remaining connection slots are reserved for roles with the SUPERUSER attribute")
+        return MagicMock(rowcount=1)
+
+    db.execute = AsyncMock(side_effect=execute)
+    db.commit = AsyncMock()
+    db.rollback = AsyncMock()
+    db._calls = calls
+    return db
+
+
+# ---------------------------------------------------------------------------
+# The retry
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_a_transient_failure_is_retried_and_the_claim_clears(scheduler):
+    """The reported case: one failed attempt used to wedge the row."""
+    db = _session(fail_times=1)
+
+    with patch("backend.app.services.print_scheduler.asyncio.sleep", new=AsyncMock()):
+        await scheduler._clear_dispatch_claim(db, 597)
+
+    assert db._calls["n"] == 2
+    assert db.commit.await_count == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_the_session_is_rolled_back_between_attempts(scheduler):
+    """A failed write leaves the session needing a rollback before reuse."""
+    db = _session(fail_times=1)
+
+    with patch("backend.app.services.print_scheduler.asyncio.sleep", new=AsyncMock()):
+        await scheduler._clear_dispatch_claim(db, 597)
+
+    assert db.rollback.await_count == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_retries_are_bounded_and_never_raise(scheduler):
+    """Dispatch's outcome must not be masked by this cleanup failing."""
+    db = _session(fail_times=99)
+
+    with patch("backend.app.services.print_scheduler.asyncio.sleep", new=AsyncMock()):
+        await scheduler._clear_dispatch_claim(db, 597)  # must not raise
+
+    assert db._calls["n"] == 3
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_no_retry_when_the_first_attempt_works(scheduler):
+    """The happy path must not pay for the retry."""
+    db = _session(fail_times=0)
+
+    await scheduler._clear_dispatch_claim(db, 597)
+
+    assert db._calls["n"] == 1
+
+
+# ---------------------------------------------------------------------------
+# The quiet-tick sweep
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_the_sweep_does_nothing_while_an_upload_is_in_flight(scheduler):
+    """An in-flight dispatch owns its claim — clearing it would let a second
+    dispatch pick up the same row mid-upload, which is what #2615 prevents."""
+    scheduler._inflight[597] = (MagicMock(), 1)
+
+    with patch("backend.app.services.print_scheduler.async_session") as sess:
+        await scheduler._clear_stale_dispatch_claims()
+
+    sess.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_the_sweep_releases_a_claim_with_nothing_in_flight(scheduler):
+    """`_inflight` is populated before the coroutine claims its row, and pruned
+    after its `finally` — so "claim present, nothing in flight" is orphaned."""
+    db = MagicMock()
+    db.execute = AsyncMock(return_value=MagicMock(rowcount=1))
+    db.commit = AsyncMock()
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(return_value=db)
+    ctx.__aexit__ = AsyncMock(return_value=False)
+
+    with patch("backend.app.services.print_scheduler.async_session", return_value=ctx):
+        await scheduler._clear_stale_dispatch_claims()
+
+    assert db.execute.await_count == 1
+    assert db.commit.await_count == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_the_sweep_survives_a_database_that_is_still_down(scheduler):
+    """It runs every tick; a failure must not break the scheduler loop."""
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(side_effect=RuntimeError("still refusing connections"))
+    ctx.__aexit__ = AsyncMock(return_value=False)
+
+    with patch("backend.app.services.print_scheduler.async_session", return_value=ctx):
+        await scheduler._clear_stale_dispatch_claims()  # must not raise

+ 263 - 0
backend/tests/unit/test_expected_print_rollback.py

@@ -0,0 +1,263 @@
+"""A dispatch that never sends the print command must leave no expectation.
+
+``register_expected_print`` has to run *before* the MQTT command, because the
+printer can report the print before the send returns. So any path that
+registers and then fails to send leaves Bambuddy expecting a print that will
+never arrive: a cancel winning the #1853 CAS race, ``start_print()`` returning
+False, or an exception in between — a PostgreSQL connection failure mid-dispatch
+is the case that surfaced this (#2702 follow-up).
+
+The two-hour TTL sweep does eventually evict such an entry, but two hours is far
+longer than it takes someone to react to a failed dispatch by pressing print
+again. That reprint would be folded into the *old* archive and inherit its
+``ams_mapping`` and ``plate_id`` instead of creating a fresh one.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.fixture
+def expected_print_tables():
+    """The module-level registries, emptied around each test."""
+    from backend.app import main
+
+    names = (
+        "_expected_prints",
+        "_expected_print_creators",
+        "_expected_print_registered_at",
+        "_print_ams_mappings",
+        "_print_plate_ids",
+    )
+    saved = {n: dict(getattr(main, n)) for n in names}
+    for n in names:
+        getattr(main, n).clear()
+    yield main
+    for n in names:
+        getattr(main, n).clear()
+        getattr(main, n).update(saved[n])
+
+
+# ---------------------------------------------------------------------------
+# unregister_expected_print is the exact inverse of register_expected_print
+# ---------------------------------------------------------------------------
+
+
+def test_unregister_leaves_every_registry_as_it_found_them(expected_print_tables):
+    """The strongest form: register then unregister is a round trip to empty."""
+    main = expected_print_tables
+
+    main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], created_by_id=7, plate_id=1)
+    assert main._expected_prints, "nothing registered — the test proves nothing"
+
+    main.unregister_expected_print(1, "widget.3mf", 298)
+
+    assert main._expected_prints == {}
+    assert main._expected_print_creators == {}
+    assert main._expected_print_registered_at == {}
+    assert main._print_ams_mappings == {}
+    assert main._print_plate_ids == {}
+
+
+def test_unregister_clears_the_filename_variants_too(expected_print_tables):
+    """Registration stores the name three ways; a partial undo still matches."""
+    main = expected_print_tables
+
+    main.register_expected_print(1, "widget.3mf", 298)
+    main.unregister_expected_print(1, "widget.3mf", 298)
+
+    for key in ((1, "widget.3mf"), (1, "widget"), (1, "widget.gcode")):
+        assert key not in main._expected_prints, f"{key} survived"
+
+
+def test_unregister_does_not_touch_another_printers_expectation(expected_print_tables):
+    main = expected_print_tables
+
+    main.register_expected_print(1, "widget.3mf", 298)
+    main.register_expected_print(2, "widget.3mf", 299)
+
+    main.unregister_expected_print(1, "widget.3mf", 298)
+
+    assert main._expected_prints[(2, "widget.3mf")] == 299
+
+
+def test_archive_keyed_tables_survive_while_another_file_still_points_at_them(
+    expected_print_tables,
+):
+    """Mirrors the TTL sweep's rule, which is the easy thing to get wrong.
+
+    ``_print_ams_mappings`` and ``_print_plate_ids`` are keyed by archive, not
+    by file. Two files can be registered against one archive, so dropping them
+    on the first unregister would strip usage-tracking data from a print that is
+    still expected.
+    """
+    main = expected_print_tables
+
+    main.register_expected_print(1, "plate1.3mf", 298, ams_mapping=[3], plate_id=1)
+    main.register_expected_print(1, "plate2.3mf", 298, ams_mapping=[3], plate_id=2)
+
+    main.unregister_expected_print(1, "plate1.3mf", 298)
+
+    assert main._print_ams_mappings.get(298) == [3]
+    assert 298 in main._print_plate_ids
+
+
+def test_unregistering_an_unknown_print_is_a_no_op(expected_print_tables):
+    """Runs from a ``finally``, so it must tolerate having nothing to do."""
+    main = expected_print_tables
+
+    main.unregister_expected_print(99, "never-registered.3mf", 1234)
+
+    assert main._expected_prints == {}
+
+
+# ---------------------------------------------------------------------------
+# The scheduler's rollback hook
+# ---------------------------------------------------------------------------
+
+
+def test_scheduler_rollback_undoes_a_recorded_registration(expected_print_tables):
+    main = expected_print_tables
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    sched = PrintScheduler()
+    main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
+    sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
+
+    sched._rollback_unconfirmed_expected_print(597)
+
+    assert main._expected_prints == {}
+    assert sched._unconfirmed_expected_print == {}
+
+
+def test_scheduler_rollback_is_a_no_op_after_a_confirmed_send(expected_print_tables):
+    """A sent print's expectation must survive — the callback needs it."""
+    main = expected_print_tables
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    sched = PrintScheduler()
+    main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
+    sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
+    # What `_start_print` does once start_print() returns True.
+    sched._unconfirmed_expected_print.pop(597, None)
+
+    sched._rollback_unconfirmed_expected_print(597)
+
+    assert main._expected_prints[(1, "widget.3mf")] == 298
+    assert main._print_ams_mappings[298] == [3, 6]
+
+
+def test_scheduler_rollback_never_raises(expected_print_tables, monkeypatch):
+    """It runs in the ``finally`` of dispatch, usually with an exception already
+    propagating — it must not replace it with one of its own."""
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    sched = PrintScheduler()
+    sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
+    monkeypatch.setattr(
+        expected_print_tables,
+        "unregister_expected_print",
+        lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
+    )
+
+    sched._rollback_unconfirmed_expected_print(597)  # must not raise
+
+    assert sched._unconfirmed_expected_print == {}, "entry must be dropped even on failure"
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_dispatch_withdraws_the_expectation_when_start_print_raises(expected_print_tables):
+    """End to end through `_dispatch_one`, on the reported failure.
+
+    A database error inside `_start_print` must leave no expectation behind, must
+    still release the claim, and must not be swallowed — the background-task
+    runner logs it, and hiding it here would turn a loud failure into a silent
+    one.
+    """
+    from unittest.mock import AsyncMock, MagicMock, patch
+
+    main = expected_print_tables
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    sched = PrintScheduler()
+
+    async def fake_start_print(db, item):
+        # What `_start_print` does before the point the real one died.
+        main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
+        sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
+        raise RuntimeError("remaining connection slots are reserved for roles with the SUPERUSER attribute")
+
+    db = MagicMock()
+    db.get = AsyncMock(return_value=MagicMock(id=597))
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(return_value=db)
+    ctx.__aexit__ = AsyncMock(return_value=False)
+
+    with (
+        patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
+        patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
+        patch.object(sched, "_start_print", side_effect=fake_start_print),
+        patch.object(sched, "_clear_dispatch_claim", AsyncMock()) as clear,
+        pytest.raises(RuntimeError),
+    ):
+        await sched._dispatch_one(597)
+
+    assert main._expected_prints == {}, "expectation survived a dispatch that never sent a print"
+    assert main._print_ams_mappings == {}
+    assert main._print_plate_ids == {}
+    assert sched._unconfirmed_expected_print == {}
+    clear.assert_awaited_once_with(db, 597)
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_dispatch_keeps_the_expectation_when_the_print_was_sent(expected_print_tables):
+    """The mirror image: a confirmed send must survive dispatch teardown, or the
+    print-complete callback would create a duplicate archive."""
+    from unittest.mock import AsyncMock, MagicMock, patch
+
+    main = expected_print_tables
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    sched = PrintScheduler()
+
+    async def fake_start_print(db, item):
+        main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
+        sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
+        sched._unconfirmed_expected_print.pop(597, None)  # start_print() returned True
+
+    db = MagicMock()
+    db.get = AsyncMock(return_value=MagicMock(id=597))
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(return_value=db)
+    ctx.__aexit__ = AsyncMock(return_value=False)
+
+    with (
+        patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
+        patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
+        patch.object(sched, "_start_print", side_effect=fake_start_print),
+        patch.object(sched, "_clear_dispatch_claim", AsyncMock()),
+    ):
+        await sched._dispatch_one(597)
+
+    assert main._expected_prints[(1, "widget.3mf")] == 298
+    assert main._print_ams_mappings[298] == [3, 6]
+
+
+def test_rollback_entries_are_per_item(expected_print_tables):
+    """Two dispatches in flight must not roll back each other's registration."""
+    main = expected_print_tables
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    sched = PrintScheduler()
+    main.register_expected_print(1, "a.3mf", 1)
+    main.register_expected_print(2, "b.3mf", 2)
+    sched._unconfirmed_expected_print[10] = (1, "a.3mf", 1)
+    sched._unconfirmed_expected_print[11] = (2, "b.3mf", 2)
+
+    sched._rollback_unconfirmed_expected_print(10)
+
+    assert (1, "a.3mf") not in main._expected_prints
+    assert main._expected_prints[(2, "b.3mf")] == 2

+ 297 - 0
backend/tests/unit/test_external_camera_live_frame_reuse.py

@@ -0,0 +1,297 @@
+"""External-camera captures must reuse the live view's frame (#2707).
+
+A USB camera allows exactly one V4L2 handle, so a one-shot capture taken while
+somebody is watching the live view doesn't degrade — it fails. The reporter
+measured 0 of 87 and 0 of 105 layer-timelapse captures on prints watched from
+start to finish, and finish-photo notifications going out with no image.
+
+The guards for the built-in camera (#1348, #1271) were never extended to the
+external paths, and the deeper reason they couldn't be: ``_last_frames`` was
+only ever populated by the built-in paths. ``generate_mjpeg_stream`` yields
+multipart-wrapped chunks, so the route layer had no way to recover the JPEG —
+hence the ``on_frame`` callback, and hence a guard alone would have found an
+empty buffer and skipped every time.
+
+These tests cover the plumbing (raw frames reach the callback) and each consumer
+that used to compete: layer timelapse, Obico polling, and plate detection.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.api.routes import camera
+from backend.app.services import external_camera, layer_timelapse
+from backend.app.services.obico_detection import ObicoDetectionService
+
+pytestmark = pytest.mark.asyncio
+
+LIVE_FRAME = b"\xff\xd8live-viewer-frame\xff\xd9"
+FRESH_FRAME = b"\xff\xd8fresh-capture\xff\xd9"
+PRINTER_ID = 9310
+
+
+@pytest.fixture(autouse=True)
+def _clean_registries():
+    def _purge():
+        for sid in [k for k in camera._active_streams if k.startswith(f"{PRINTER_ID}-")]:
+            camera._active_streams.pop(sid, None)
+        camera._last_frames.pop(PRINTER_ID, None)
+        camera._last_frame_times.pop(PRINTER_ID, None)
+        camera._stream_start_times.pop(PRINTER_ID, None)
+
+    _purge()
+    yield
+    _purge()
+
+
+def _attach_viewer(frame: bytes | None = LIVE_FRAME) -> None:
+    """Register a live external stream, as the stream route does."""
+    camera._active_streams[f"{PRINTER_ID}-ext-deadbeef"] = object()
+    if frame is not None:
+        camera._last_frames[PRINTER_ID] = frame
+
+
+# ---------------------------------------------------------------------------
+# live_frame_for_capture — the shared decision
+# ---------------------------------------------------------------------------
+
+
+async def test_no_viewer_means_capture_normally():
+    defer, frame = camera.live_frame_for_capture(PRINTER_ID)
+
+    assert defer is False
+    assert frame is None
+
+
+async def test_viewer_with_a_buffered_frame_is_reused():
+    _attach_viewer()
+
+    defer, frame = camera.live_frame_for_capture(PRINTER_ID)
+
+    assert defer is True
+    assert frame == LIVE_FRAME
+
+
+async def test_viewer_with_an_empty_buffer_means_skip_not_capture():
+    """#1348: competing for the device is worse than missing one frame."""
+    _attach_viewer(frame=None)
+
+    defer, frame = camera.live_frame_for_capture(PRINTER_ID)
+
+    assert defer is True
+    assert frame is None
+
+
+# ---------------------------------------------------------------------------
+# on_frame plumbing — without this the buffer is always empty
+# ---------------------------------------------------------------------------
+
+
+async def test_on_frame_receives_the_raw_jpeg_not_the_multipart_chunk():
+    """The consumers want a JPEG; the stream yields multipart. Hence a callback."""
+    captured: list[bytes] = []
+
+    async def _fake_usb(_url, _fps, on_process=None):
+        yield FRESH_FRAME
+
+    with patch.object(external_camera, "_stream_usb", _fake_usb):
+        chunks = [
+            chunk
+            async for chunk in external_camera.generate_mjpeg_stream(
+                "/dev/video0", "usb", fps=15, on_frame=captured.append
+            )
+        ]
+
+    assert captured == [FRESH_FRAME], "callback did not get the raw frame"
+    assert b"--frame" in chunks[0], "wire format should still be multipart"
+    assert b"--frame" not in captured[0]
+
+
+async def test_a_raising_on_frame_callback_cannot_break_the_stream():
+    """Buffering is a side effect; it must never take the live view down."""
+
+    async def _fake_usb(_url, _fps, on_process=None):
+        yield FRESH_FRAME
+        yield FRESH_FRAME
+
+    def _boom(_frame: bytes) -> None:
+        raise RuntimeError("buffering blew up")
+
+    with patch.object(external_camera, "_stream_usb", _fake_usb):
+        chunks = [
+            chunk async for chunk in external_camera.generate_mjpeg_stream("/dev/video0", "usb", fps=15, on_frame=_boom)
+        ]
+
+    assert len(chunks) == 2, "stream stopped because the callback raised"
+
+
+# ---------------------------------------------------------------------------
+# Layer timelapse — the 0-of-87 case
+# ---------------------------------------------------------------------------
+
+
+def _session(tmp_path) -> layer_timelapse.TimelapseSession:
+    with patch.object(layer_timelapse.settings, "base_dir", tmp_path):
+        return layer_timelapse.TimelapseSession(
+            printer_id=PRINTER_ID,
+            archive_id=None,
+            camera_url="/dev/video0",
+            camera_type="usb",
+        )
+
+
+async def test_timelapse_uses_the_live_frame_instead_of_competing(tmp_path):
+    session = _session(tmp_path)
+    _attach_viewer()
+
+    with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
+        captured = await session.capture_layer(1)
+
+    assert captured is True, "layer capture failed with a viewer attached"
+    # Would have opened a competing handle on a single-reader device.
+    mock_capture.assert_not_called()
+    written = sorted(session.frames_dir.glob("layer_*.jpg"))
+    assert len(written) == 1
+    assert written[0].read_bytes() == LIVE_FRAME
+
+
+async def test_timelapse_skips_a_layer_rather_than_competing_on_an_empty_buffer(tmp_path):
+    session = _session(tmp_path)
+    _attach_viewer(frame=None)
+
+    with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
+        captured = await session.capture_layer(1)
+
+    assert captured is False
+    mock_capture.assert_not_called()
+    assert sorted(session.frames_dir.glob("layer_*.jpg")) == []
+
+
+async def test_timelapse_captures_normally_with_no_viewer(tmp_path):
+    """The unwatched path must be untouched — this is the common case."""
+    session = _session(tmp_path)
+
+    with patch.object(layer_timelapse, "capture_frame", new=AsyncMock(return_value=FRESH_FRAME)) as mock_capture:
+        captured = await session.capture_layer(1)
+
+    assert captured is True
+    mock_capture.assert_awaited_once()
+    written = sorted(session.frames_dir.glob("layer_*.jpg"))
+    assert written[0].read_bytes() == FRESH_FRAME
+
+
+# ---------------------------------------------------------------------------
+# Obico polling — external branch, mirroring the built-in one
+# ---------------------------------------------------------------------------
+
+
+def _external_printer() -> MagicMock:
+    return MagicMock(
+        external_camera_enabled=True,
+        external_camera_url="/dev/video0",
+        external_camera_type="usb",
+        external_camera_snapshot_url=None,
+        ip_address="192.168.1.10",
+        access_code="12345678",
+        model="A1",
+    )
+
+
+def _db_returning(printer) -> MagicMock:
+    session = MagicMock()
+    session.get = AsyncMock(return_value=printer)
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(return_value=session)
+    ctx.__aexit__ = AsyncMock(return_value=None)
+    return ctx
+
+
+async def test_obico_reuses_the_live_external_frame():
+    _attach_viewer()
+    svc = ObicoDetectionService()
+
+    with (
+        patch(
+            "backend.app.services.obico_detection.async_session",
+            return_value=_db_returning(_external_printer()),
+        ),
+        patch(
+            "backend.app.services.external_camera.capture_frame",
+            new=AsyncMock(return_value=FRESH_FRAME),
+        ) as mock_capture,
+    ):
+        result = await svc._capture_frame(printer_id=PRINTER_ID)
+
+    assert result == LIVE_FRAME
+    mock_capture.assert_not_called()
+
+
+async def test_obico_skips_the_poll_when_the_external_buffer_is_empty():
+    _attach_viewer(frame=None)
+    svc = ObicoDetectionService()
+
+    with (
+        patch(
+            "backend.app.services.obico_detection.async_session",
+            return_value=_db_returning(_external_printer()),
+        ),
+        patch(
+            "backend.app.services.external_camera.capture_frame",
+            new=AsyncMock(return_value=FRESH_FRAME),
+        ) as mock_capture,
+    ):
+        result = await svc._capture_frame(printer_id=PRINTER_ID)
+
+    assert result is None
+    mock_capture.assert_not_called()
+
+
+async def test_obico_still_captures_when_nobody_is_watching():
+    svc = ObicoDetectionService()
+
+    with (
+        patch(
+            "backend.app.services.obico_detection.async_session",
+            return_value=_db_returning(_external_printer()),
+        ),
+        patch(
+            "backend.app.services.external_camera.capture_frame",
+            new=AsyncMock(return_value=FRESH_FRAME),
+        ) as mock_capture,
+    ):
+        result = await svc._capture_frame(printer_id=PRINTER_ID)
+
+    assert result == FRESH_FRAME
+    mock_capture.assert_awaited_once()
+
+
+# ---------------------------------------------------------------------------
+# Plate detection — its docstring already promised this
+# ---------------------------------------------------------------------------
+
+
+async def test_plate_detection_reuses_the_live_external_frame():
+    from backend.app.services import plate_detection
+
+    _attach_viewer()
+
+    with patch(
+        "backend.app.services.external_camera.capture_frame",
+        new=AsyncMock(return_value=FRESH_FRAME),
+    ) as mock_capture:
+        image, source = await plate_detection.capture_camera_image(
+            printer_id=PRINTER_ID,
+            ip_address="192.168.1.10",
+            access_code="12345678",
+            model="A1",
+            external_camera_url="/dev/video0",
+            external_camera_type="usb",
+            use_external=True,
+        )
+
+    assert image == LIVE_FRAME
+    assert source == "external (buffered)"
+    mock_capture.assert_not_called()

+ 376 - 0
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -0,0 +1,376 @@
+"""Outbound-URL SSRF policy: two tiers, applied consistently.
+
+Bambuddy makes outbound HTTP requests to hosts the operator configures. Which
+policy applies is a property of the *service*, not the caller:
+
+- LAN-service (Spoolman, ntfy, Bark, webhooks, Home Assistant, Obico ML, the
+  slicer sidecars) — loopback and RFC-1918 MUST stay reachable, because
+  self-hosting those next to Bambuddy is the normal topology. Blocking them
+  would break most installs, which is why a blanket private-IP blocklist is
+  the wrong fix here.
+- Public-internet (OIDC issuer and icon URLs) — a private address cannot be a
+  real IdP, so it is a probe.
+
+Both tiers reject what is dangerous under any topology: non-HTTP schemes,
+numeric-encoded IPs, cloud-metadata endpoints, multicast/unspecified, and
+IPv4-mapped IPv6 encodings of the above.
+
+The separate concern covered here is *response-body echo*. Notification
+provider URLs are writable by anyone holding ``NOTIFICATIONS_CREATE`` — which
+the default Operators group carries and which does NOT imply
+``SETTINGS_UPDATE`` — and ``POST /notifications/test-config`` takes the URL
+from the request body without persisting it. Returning the upstream body there
+made an intended reachability check into an authenticated read primitive
+against anything the process can reach. Providers whose host Bambuddy pins
+(Pushover, Telegram, CallMeBot, Discord) may still echo, since the caller
+cannot influence the destination.
+"""
+
+from __future__ import annotations
+
+import inspect
+import re
+
+import httpx
+import pytest
+
+from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
+from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
+from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+from backend.app.schemas.auth import OIDCProviderCreate, OIDCProviderUpdate
+from backend.app.schemas.settings import LAN_SERVICE_URL_SETTINGS, AppSettingsUpdate
+from backend.app.services import notification_service as ns
+
+# Dangerous under any topology — both tiers must reject all of these.
+UNIVERSALLY_BLOCKED = [
+    "file:///etc/passwd",
+    "gopher://127.0.0.1:6379/_INFO",
+    "ftp://internal.example.com/",
+    "http://169.254.169.254/latest/meta-data/",
+    "http://100.100.100.200/",
+    "http://[fd00:ec2::254]/",
+    "http://2130706433/",
+    "http://0x7f000001/",
+    "http://[::ffff:169.254.169.254]/",
+    "http://0.0.0.0/",
+    "http://239.255.255.250/",
+]
+
+# The normal self-hosted topology — the LAN tier must permit all of these.
+LAN_ALLOWED = [
+    "http://127.0.0.1:7912/",
+    "http://localhost:3003",
+    "http://192.168.1.50:8123",
+    "http://10.0.0.7:3333",
+    "http://172.16.4.9:8080",
+    "https://ntfy.example.com/",
+    "http://spoolman.lan:7912",
+]
+
+
+# ---------------------------------------------------------------------------
+# The LAN-service tier
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_lan_tier_rejects_universally_dangerous_targets(url: str):
+    with pytest.raises(ValueError):
+        assert_safe_lan_service_url(url, label="Test URL")
+
+
+@pytest.mark.parametrize("url", LAN_ALLOWED)
+def test_lan_tier_permits_the_normal_self_hosted_topology(url: str):
+    """A blanket private-IP block here would break most real installs."""
+    assert_safe_lan_service_url(url, label="Test URL")
+
+
+def test_lan_tier_names_the_field_in_its_error():
+    with pytest.raises(ValueError, match="ntfy server URL"):
+        assert_safe_lan_service_url("file:///etc/passwd", label="ntfy server URL")
+
+
+def test_spoolman_wrapper_keeps_its_user_facing_wording():
+    """The wording is asserted by pre-existing tests; delegation must not change it."""
+    with pytest.raises(ValueError, match="^Spoolman URL must use http or https$"):
+        assert_safe_spoolman_url("file:///etc/passwd")
+    with pytest.raises(ValueError, match="^Spoolman URL must not point to a cloud metadata endpoint$"):
+        assert_safe_spoolman_url("http://169.254.169.254/")
+
+
+# ---------------------------------------------------------------------------
+# The public-internet tier
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_public_tier_rejects_universally_dangerous_targets(url: str):
+    with pytest.raises(ValueError):
+        assert_safe_public_https_url(url)
+
+
+@pytest.mark.parametrize(
+    "url",
+    [
+        "https://127.0.0.1/",
+        "https://192.168.1.5/",
+        "https://10.1.2.3/",
+        "https://[fe80::1]/",
+        "https://[::ffff:127.0.0.1]/",
+        "http://accounts.google.com/",  # scheme must be https
+    ],
+)
+def test_public_tier_additionally_rejects_private_and_plain_http(url: str):
+    with pytest.raises(ValueError):
+        assert_safe_public_https_url(url)
+
+
+# ---------------------------------------------------------------------------
+# OIDC issuer_url — the encoding bypasses the hand-rolled validator missed
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+    "url",
+    [
+        "https://2130706433/",  # decimal-encoded 127.0.0.1
+        "https://0x7f000001/",  # hex-encoded 127.0.0.1
+        "https://[::ffff:127.0.0.1]/",  # IPv4-mapped loopback
+        "https://[::ffff:169.254.169.254]/",  # IPv4-mapped IMDS
+        "https://0.0.0.0/",
+        "https://239.255.255.250/",
+        "https://169.254.169.254/",
+        "https://127.0.0.1/",
+        "https://192.168.1.5/",
+        "http://idp.example.com/",
+    ],
+)
+def test_issuer_url_rejects_encoded_and_private_targets(url: str):
+    with pytest.raises(ValueError):
+        OIDCProviderCreate(
+            name="SSO",
+            issuer_url=url,
+            client_id="cid",
+            client_secret="secret",
+        )
+
+
+def test_issuer_url_update_is_guarded_too():
+    """The update path matters most: it can change the issuer while the stored
+    client_secret stays, which is the shape that would exfiltrate a real secret."""
+    with pytest.raises(ValueError):
+        OIDCProviderUpdate(issuer_url="https://[::ffff:127.0.0.1]/")
+
+
+def test_issuer_url_error_names_the_field_not_the_icon():
+    with pytest.raises(ValueError, match="issuer_url"):
+        OIDCProviderUpdate(issuer_url="https://127.0.0.1/")
+
+
+def test_a_real_idp_still_validates():
+    provider = OIDCProviderCreate(
+        name="SSO",
+        issuer_url="https://accounts.google.com",
+        client_id="cid",
+        client_secret="secret",
+    )
+    assert provider.issuer_url == "https://accounts.google.com"
+
+
+# ---------------------------------------------------------------------------
+# Settings URLs
+# ---------------------------------------------------------------------------
+
+# Imported from the schema rather than duplicated, so the backstop below cannot
+# silently disagree with what is actually validated.
+LAN_SERVICE_SETTINGS = LAN_SERVICE_URL_SETTINGS
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_settings_urls_reject_dangerous_targets(field: str, url: str):
+    with pytest.raises(ValueError):
+        AppSettingsUpdate(**{field: url})
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+@pytest.mark.parametrize("url", LAN_ALLOWED)
+def test_settings_urls_permit_lan_hosts(field: str, url: str):
+    assert AppSettingsUpdate(**{field: url})
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+@pytest.mark.parametrize("empty", ["", "   "])
+def test_settings_urls_accept_empty_meaning_not_configured(field: str, empty: str):
+    """Empty is the documented "fall back to the env var" value for all four."""
+    assert AppSettingsUpdate(**{field: empty})
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+@pytest.mark.parametrize(
+    "legacy",
+    [
+        "192.168.1.10:3333",  # urlparse: scheme='', netloc='', hostname=None
+        "localhost:3003",  # urlparse: scheme='localhost' (!), hostname=None
+        "obico.local:3333",  # same trap, with dots
+        "192.168.1.10",
+    ],
+)
+def test_settings_urls_do_not_newly_reject_scheme_less_legacy_values(field: str, legacy: str):
+    """Compatibility guard, not an endorsement.
+
+    The settings inputs are plain text with no scheme enforcement, so values
+    like these are already in the wild. They are inert — httpx raises
+    UnsupportedProtocol, so no request is issued — and they were storable
+    before the validator existed. Rejecting them now would block saves of
+    unrelated fields bundled in the same request (the Obico panel auto-saves
+    obico_ml_url alongside every other Obico setting).
+    """
+    assert AppSettingsUpdate(**{field: legacy})
+
+
+@pytest.mark.parametrize("field", LAN_SERVICE_SETTINGS)
+def test_settings_urls_still_reject_a_real_non_http_scheme(field: str):
+    """The leniency above is scoped to strings that are not URLs at all."""
+    with pytest.raises(ValueError):
+        AppSettingsUpdate(**{field: "file:///etc/passwd"})
+
+
+def test_every_url_setting_is_either_guarded_or_explicitly_exempt():
+    """CI backstop: a new outbound-URL setting can't land unvalidated.
+
+    Any new ``*_url`` field on AppSettingsUpdate must be added to the
+    validator's field tuple or listed as exempt here with a reason. This
+    catches the failure mode the original report correctly identified — guards
+    added per-incident rather than to the whole class of fields.
+    """
+    exempt = {
+        # Bambuddy's own public address, not a destination it requests. It is
+        # rendered into notification bodies and OIDC redirect URIs, and handed
+        # to Obico's ML server as the `img` parameter for that server to fetch
+        # (obico_detection.py builds `{external_url}/api/v1/obico/cached-frame/
+        # {nonce}`). Pointing it at a private address only breaks Bambuddy's own
+        # links; it cannot make Bambuddy request anything it otherwise wouldn't.
+        "external_url",
+        # Guarded by assert_safe_spoolman_url at each consumer (spoolman.py,
+        # location_service.py, inventory.py, spoolbuddy.py,
+        # spoolman_inventory.py) rather than in the schema, keeping its
+        # established user-facing "Spoolman URL ..." error wording.
+        "spoolman_url",
+        # Not an HTTP URL: ldap:// or ldaps://, handed to an LDAP client, never
+        # to httpx. The LAN-service guard requires http/https and would reject
+        # every valid value. It also cannot reach a cloud-metadata endpoint,
+        # since IMDS only speaks HTTP.
+        "ldap_server_url",
+    }
+    url_fields = {name for name in AppSettingsUpdate.model_fields if name.endswith("_url")}
+    unguarded = url_fields - set(LAN_SERVICE_SETTINGS) - exempt
+    assert not unguarded, (
+        f"New outbound URL setting(s) {sorted(unguarded)} are not covered by a "
+        f"SSRF guard. Add them to AppSettingsUpdate._LAN_SERVICE_URL_FIELDS (or "
+        f"the public-internet guard), or add them to `exempt` above with a reason."
+    )
+
+
+# ---------------------------------------------------------------------------
+# Notification providers: URL guard + no response-body echo
+# ---------------------------------------------------------------------------
+
+
+def _response(status: int = 500, body: str = "root:x:0:0:root:/root:/bin/bash") -> httpx.Response:
+    return httpx.Response(status_code=status, text=body, request=httpx.Request("POST", "http://10.0.0.1/"))
+
+
+SECRET_BODY = "root:x:0:0:root:/root:/bin/bash"
+
+
+def test_opaque_failure_does_not_return_the_response_body():
+    message = ns._opaque_http_failure(_response(), label="webhook endpoint")
+
+    assert SECRET_BODY not in message
+    assert "500" in message, "the status code is still useful and is not sensitive"
+    assert "webhook endpoint" in message
+
+
+def test_opaque_failure_logs_the_body_for_the_operator(caplog):
+    """The body stays available to whoever administers the host — via logs,
+    not via the API response."""
+    with caplog.at_level("DEBUG", logger=ns.__name__):
+        ns._opaque_http_failure(_response(), label="ntfy server")
+
+    assert SECRET_BODY in caplog.text
+
+
+@pytest.mark.parametrize(
+    "provider_label",
+    ["ntfy server", "Bark server", "webhook endpoint", "Home Assistant endpoint"],
+)
+def test_user_supplied_host_providers_use_the_opaque_path(provider_label: str):
+    """Guards the mapping itself: each user-supplied-host provider must route
+    its HTTP failure through _opaque_http_failure rather than formatting the
+    body inline."""
+    src = inspect.getsource(ns)
+    assert f'_opaque_http_failure(response, label="{provider_label}")' in src
+
+
+def test_no_user_supplied_host_provider_formats_the_body_inline():
+    """Any remaining ``response.text[:200]`` must belong to a host-pinned provider.
+
+    Pushover/Telegram/CallMeBot/Discord all target hardcoded hosts (Discord via
+    a webhook-prefix allowlist), so there is no trust boundary to cross.
+    """
+    src = inspect.getsource(ns).split("\n")
+    host_pinned = {"_send_callmebot", "_send_pushover", "_send_telegram", "_send_discord"}
+
+    current = None
+    offenders = []
+    for line in src:
+        match = re.match(r"\s+async def (_send_\w+)", line)
+        if match:
+            current = match.group(1)
+        if "response.text[:200]" in line and current not in host_pinned:
+            offenders.append(current)
+
+    assert not offenders, (
+        f"{offenders} echo the upstream response body but do not target a "
+        f"hardcoded host. Route the failure through _opaque_http_failure."
+    )
+
+
+@pytest.mark.parametrize("url", UNIVERSALLY_BLOCKED)
+def test_provider_url_guard_rejects_dangerous_targets(url: str):
+    assert ns._assert_safe_provider_url(url, label="Webhook URL") is not None
+
+
+@pytest.mark.parametrize("url", LAN_ALLOWED)
+def test_provider_url_guard_permits_self_hosted_servers(url: str):
+    assert ns._assert_safe_provider_url(url, label="ntfy server URL") is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("provider_type", "config"),
+    [
+        ("ntfy", {"server": "http://169.254.169.254", "topic": "t"}),
+        ("bark", {"server": "http://169.254.169.254", "device_key": "k"}),
+        ("webhook", {"webhook_url": "http://169.254.169.254/latest/meta-data/"}),
+    ],
+)
+async def test_test_config_refuses_metadata_targets_without_a_request(provider_type: str, config: dict, monkeypatch):
+    """The end-to-end shape of the reported attack: an unsaved config aimed at
+    IMDS via the test endpoint. It must be refused before any HTTP call."""
+    called = False
+
+    async def _fail_if_called(*_a, **_kw):
+        nonlocal called
+        called = True
+        raise AssertionError("outbound request should not have been attempted")
+
+    service = ns.NotificationService()
+    monkeypatch.setattr(service, "_get_client", _fail_if_called)
+
+    success, message = await service.send_test_notification(provider_type, config)
+
+    assert success is False
+    assert called is False
+    assert "cloud metadata" in message

+ 166 - 0
backend/tests/unit/test_pool_fits_server.py

@@ -0,0 +1,166 @@
+"""The pool must not silently be allowed to outgrow the PostgreSQL server.
+
+``pool_size + max_overflow`` is the most connections one worker will open. When
+that exceeds what the server permits, the pool never hits its own limit and so
+never queues — it asks the server, which refuses with
+``TooManyConnectionsError`` at whatever happened to need a connection next. In
+the report behind this, that was the middle of a queue dispatch.
+
+The check is diagnostic, not corrective: pool sizes are fixed at engine creation
+(import time, before any connection exists to ask with), and the right ceiling
+depends on the worker count and on other clients sharing the server. So the
+contract under test is "says something accurate and loud, and never breaks
+startup".
+"""
+
+from __future__ import annotations
+
+import logging
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+def _engine_reporting(max_conn: int, reserved: int, in_use: int | None = 0) -> MagicMock:
+    """An engine whose connection answers the three probe queries in order.
+
+    ``in_use=None`` makes the third query fail, standing in for PostgreSQL < 10
+    where ``pg_stat_activity.backend_type`` does not exist.
+    """
+    conn = MagicMock()
+    conn.execute = AsyncMock(
+        side_effect=[
+            MagicMock(scalar_one=MagicMock(return_value=max_conn)),
+            MagicMock(scalar_one=MagicMock(return_value=reserved)),
+            (
+                MagicMock(scalar_one=MagicMock(return_value=in_use))
+                if in_use is not None
+                else RuntimeError('column "backend_type" does not exist')
+            ),
+        ]
+    )
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(return_value=conn)
+    ctx.__aexit__ = AsyncMock(return_value=False)
+    engine = MagicMock()
+    engine.connect = MagicMock(return_value=ctx)
+    return engine
+
+
+async def _run_check(*, pool_size, max_overflow, max_conn, reserved, in_use=0, sqlite=False):
+    from backend.app.core import database
+
+    with (
+        patch.object(database, "is_sqlite", return_value=sqlite),
+        patch.object(database, "_pool_config", {"pool_size": pool_size, "max_overflow": max_overflow}),
+        patch.object(database, "engine", _engine_reporting(max_conn, reserved, in_use)),
+        patch.object(database, "_server_connection_limits", None),
+    ):
+        await database.check_pool_fits_server()
+        return database._server_connection_limits
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_warns_when_the_ceiling_exceeds_what_the_server_allows(caplog):
+    """Bambuddy's own PostgreSQL default against a stock server: 100 vs 100-3."""
+    with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
+        await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3)
+
+    assert any(r.levelno == logging.WARNING for r in caplog.records)
+    msg = caplog.text
+    # The numbers an operator needs, and the knobs to change.
+    for expected in ("100", "97", "DB_POOL_SIZE", "DB_MAX_OVERFLOW", "max_connections"):
+        assert expected in msg, f"warning omits {expected!r}"
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_silent_when_the_pool_fits(caplog):
+    with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
+        await _run_check(pool_size=20, max_overflow=80, max_conn=500, reserved=3)
+
+    assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_the_reserved_slots_count_against_the_budget(caplog):
+    """Exactly at max_connections is still too many — reserved slots are not ours."""
+    with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
+        await _run_check(pool_size=10, max_overflow=90, max_conn=100, reserved=3)
+
+    assert [r for r in caplog.records if r.levelno == logging.WARNING]
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_both_sides_are_recorded_for_the_support_bundle():
+    limits = await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3, in_use=41)
+
+    assert limits == {
+        "max_connections": 100,
+        "superuser_reserved_connections": 3,
+        "available_to_bambuddy": 97,
+        "client_backends_at_startup": 41,
+        "pool_ceiling_per_worker": 100,
+    }
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_sqlite_is_skipped_entirely():
+    """No such concept, and the probe SQL is PostgreSQL-only."""
+    limits = await _run_check(pool_size=20, max_overflow=200, max_conn=0, reserved=0, sqlite=True)
+
+    assert limits is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_a_probe_failure_cannot_break_startup(caplog):
+    """A restricted role or an older server may refuse these queries."""
+    from backend.app.core import database
+
+    engine = MagicMock()
+    ctx = MagicMock()
+    ctx.__aenter__ = AsyncMock(side_effect=RuntimeError("permission denied"))
+    ctx.__aexit__ = AsyncMock(return_value=False)
+    engine.connect = MagicMock(return_value=ctx)
+
+    with (
+        patch.object(database, "is_sqlite", return_value=False),
+        patch.object(database, "_pool_config", {"pool_size": 20, "max_overflow": 80}),
+        patch.object(database, "engine", engine),
+        patch.object(database, "_server_connection_limits", None),
+        caplog.at_level(logging.WARNING, logger="backend.app.core.database"),
+    ):
+        await database.check_pool_fits_server()  # must not raise
+
+        assert database._server_connection_limits is None
+    assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_an_old_server_without_backend_type_still_gets_the_warning(caplog):
+    """`pg_stat_activity.backend_type` is PostgreSQL 10+; the docs recommend 14+
+    but asyncpg reaches back to 9.5. Losing that count must not cost the
+    warning, which only needs the two settings."""
+    with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
+        limits = await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3, in_use=None)
+
+    assert [r for r in caplog.records if r.levelno == logging.WARNING], "warning was lost with the count"
+    assert "100" in caplog.text and "97" in caplog.text
+    # The sentence about other clients is dropped rather than rendered as None.
+    assert "None client" not in caplog.text
+    assert limits["client_backends_at_startup"] is None
+    assert limits["max_connections"] == 100
+
+
+@pytest.mark.unit
+def test_get_pool_status_exposes_the_server_limits_key():
+    """The support bundle reads this; the key must exist even on SQLite."""
+    from backend.app.core.database import get_pool_status
+
+    assert "server_limits" in get_pool_status()

+ 163 - 0
backend/tests/unit/test_spoolman_settings_value_coercion.py

@@ -0,0 +1,163 @@
+"""PUT /settings/spoolman must not 500 on a JSON boolean.
+
+The endpoint takes a free-form ``dict`` body, and settings are persisted in a
+VARCHAR column that every reader compares as a string. Sending the natural JSON
+form — ``{"spoolman_enabled": true}`` — used to fail twice over:
+
+- ``bool.lower()`` raised AttributeError while deciding whether the mode had
+  changed, surfacing as an opaque 500;
+- the raw bool was handed to ``upsert_setting``, which SQLite silently coerces
+  to 1/0 while asyncpg rejects it — so the stored representation depended on
+  the deployment's database.
+
+The shipped UI sends strings, so this was reachable only through the REST API
+(scripts, Home Assistant ``rest_command``) — which is exactly where a JSON
+boolean is the obvious thing to send.
+
+These tests cover the normalisers directly. They are pure functions, so the
+matrix stays readable and the endpoint keeps a single code path per field.
+"""
+
+from __future__ import annotations
+
+import pytest
+from fastapi import HTTPException
+
+from backend.app.api.routes.settings import (
+    normalize_bool_setting,
+    normalize_str_setting,
+    setting_is_true,
+)
+
+# ---------------------------------------------------------------------------
+# The reported crash
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(("value", "expected"), [(True, "true"), (False, "false")])
+def test_json_booleans_are_accepted_and_canonicalised(value: bool, expected: str):
+    """The exact input that used to 500."""
+    assert normalize_bool_setting("spoolman_enabled", value) == expected
+
+
+@pytest.mark.parametrize(("value", "expected"), [(1, "true"), (0, "false")])
+def test_json_numbers_one_and_zero_are_accepted(value: int, expected: str):
+    assert normalize_bool_setting("spoolman_enabled", value) == expected
+
+
+# ---------------------------------------------------------------------------
+# String spellings — generous on purpose, this is a documented REST surface
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("value", ["true", "TRUE", "True", " true ", "1", "yes", "on", "ON"])
+def test_truthy_spellings(value: str):
+    assert normalize_bool_setting("auto_add_unknown_rfid", value) == "true"
+
+
+@pytest.mark.parametrize("value", ["false", "FALSE", "False", " false ", "0", "no", "off"])
+def test_falsy_spellings(value: str):
+    assert normalize_bool_setting("auto_add_unknown_rfid", value) == "false"
+
+
+def test_python_style_capitalised_true_is_normalised_lowercase():
+    """The frontend compares with a case-sensitive ``=== 'true'``.
+
+    A client sending "True" previously had it stored verbatim, so the UI
+    rendered the setting as OFF while every backend reader (which all use
+    ``.lower()``) treated it as ON.
+    """
+    assert normalize_bool_setting("spoolman_enabled", "True") == "true"
+
+
+# ---------------------------------------------------------------------------
+# Empty means "use the default" — deliberately NOT normalised to "false"
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("value", ["", "   "])
+def test_empty_is_preserved_not_turned_into_false(value: str):
+    """get_spoolman_settings reads these with ``or "<default>"``.
+
+    spoolman_report_partial_usage and auto_add_unknown_rfid default to ON, so
+    coercing a blank submission to "false" would silently switch them off.
+    Whitespace-only collapses to "" so it takes the same path rather than
+    being stored as a truthy-but-meaningless "   ".
+    """
+    assert normalize_bool_setting("spoolman_report_partial_usage", value) == ""
+
+
+# ---------------------------------------------------------------------------
+# Values with no sensible reading get a 400 naming the field, not a 500
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("value", ["banana", "maybe", "2", "-1", None, [], {}, 3.5, 7])
+def test_uninterpretable_values_raise_400_naming_the_field(value: object):
+    with pytest.raises(HTTPException) as exc:
+        normalize_bool_setting("spoolman_enabled", value)
+
+    assert exc.value.status_code == 400
+    assert "spoolman_enabled" in str(exc.value.detail)
+
+
+# ---------------------------------------------------------------------------
+# String settings
+# ---------------------------------------------------------------------------
+
+
+def test_str_setting_passes_strings_through_untouched():
+    assert normalize_str_setting("spoolman_url", "http://192.168.1.5:7912/") == "http://192.168.1.5:7912/"
+
+
+def test_str_setting_stringifies_numbers():
+    """An unquoted host or port is a plausible client slip, not a hard error."""
+    assert normalize_str_setting("spoolman_url", 7912) == "7912"
+
+
+def test_str_setting_maps_null_to_empty():
+    assert normalize_str_setting("spoolman_url", None) == ""
+
+
+@pytest.mark.parametrize("value", [{"a": 1}, ["x"]])
+def test_str_setting_refuses_containers_rather_than_storing_a_repr(value: object):
+    with pytest.raises(HTTPException) as exc:
+        normalize_str_setting("spoolman_url", value)
+
+    assert exc.value.status_code == 400
+
+
+# ---------------------------------------------------------------------------
+# setting_is_true — used for the mode-switch comparison
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+    ("stored", "expected"),
+    [
+        ("true", True),
+        ("True", True),
+        ("TRUE", True),
+        (" true ", True),
+        ("false", False),
+        ("", False),
+        ("banana", False),
+        (None, False),  # setting absent from the table
+        (True, True),  # legacy row: SQLite coerced a raw bool into the column
+        (False, False),
+    ],
+)
+def test_setting_is_true(stored: object, expected: bool):
+    assert setting_is_true(stored) is expected
+
+
+@pytest.mark.parametrize("stored", ["1", "on", "yes"])
+def test_setting_is_true_stays_narrower_than_the_write_path(stored: str):
+    """Reading must agree with the rest of the codebase, which only accepts "true".
+
+    normalize_bool_setting is generous about what clients may *send*; every
+    reader (spoolman_tracking, filament_deficit, inventory, spoolbuddy, labels,
+    main) compares ``.lower() == "true"``. Accepting more here would make the
+    mode-switch check disagree with them about a legacy row.
+    """
+    assert setting_is_true(stored) is False

+ 166 - 0
backend/tests/unit/test_support_helpers.py

@@ -1285,3 +1285,169 @@ class TestRedactRawPushStatus:
         assert _redact_raw_push_status(None) == {}  # type: ignore[arg-type]
         assert _redact_raw_push_status([]) == {}  # type: ignore[arg-type]
         assert _redact_raw_push_status("") == {}  # type: ignore[arg-type]
+
+
+class TestSanitizePushStatusValues:
+    """The bundled push_status snapshot must stay parseable JSON.
+
+    Sanitization used to run over the *serialised* snapshot. The generic
+    Bambu-serial regex in ``log_reader`` (``0[0-3][A-Z0-9][A-Z0-9]{9,13}``)
+    matches the decimal expansion of a float as readily as a serial, so an AMS
+    ``k`` flow factor came out as ``0.[SERIAL]`` and the whole file stopped
+    parsing — found in a real bundle while diagnosing #2702, which is exactly
+    the case the snapshot was added to serve.
+    """
+
+    def test_float_that_matches_the_serial_regex_survives(self):
+        """The observed reproducer, verbatim."""
+        import json
+
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {"ams": [{"tray": [{"k": 0.0199999995529652}]}]}
+
+        out = _sanitize_push_status_values(raw, {})
+
+        assert json.loads(json.dumps(out)) == raw
+
+    def test_output_always_parses(self):
+        """Whatever it does to values, the result must be valid JSON."""
+        import json
+
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {
+            "k_values": [0.0199999995529652, 0.019999999552965164, 0.02],
+            "home_flag": 7554487,
+            "sdcard": True,
+            "resolution": "",
+            "nozzle": None,
+        }
+
+        json.loads(json.dumps(_sanitize_push_status_values(raw, {})))
+
+    def test_still_redacts_strings(self):
+        """The point of the pass is not lost — string values are sanitized."""
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {"tag_uid": "0123456789ABCDEF", "name": "Martin's P1S", "ip": "192.168.1.50"}
+
+        out = _sanitize_push_status_values(raw, {"Martin's P1S": "[PRINTER]"})
+
+        assert out["tag_uid"] == "[SERIAL]"
+        assert out["name"] == "[PRINTER]"
+        assert out["ip"] == "[IP]"
+
+    def test_walks_nested_containers(self):
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {"ams": [{"tray": [{"tray_uuid": "0123456789ABCDEF"}]}]}
+
+        out = _sanitize_push_status_values(raw, {})
+
+        assert out["ams"][0]["tray"][0]["tray_uuid"] == "[SERIAL]"
+
+    def test_keys_are_left_alone(self):
+        """Keys are structural — renaming one would break the schema."""
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {"0123456789ABCDEF": 1}
+
+        assert list(_sanitize_push_status_values(raw, {})) == ["0123456789ABCDEF"]
+
+    def test_non_json_scalars_are_sanitized_not_smuggled(self):
+        """``json.dumps(default=str)`` runs after this pass, so do it here."""
+        from datetime import datetime, timezone
+
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {"seen_at": datetime(2026, 7, 29, 23, 12, 40, tzinfo=timezone.utc), "who": object()}
+
+        out = _sanitize_push_status_values(raw, {"2026-07-29": "[WHEN]"})
+
+        assert out["seen_at"].startswith("[WHEN]")
+        assert isinstance(out["who"], str)
+
+    def test_bools_stay_bools(self):
+        """`isinstance(True, int)` — a bool must not fall through to str()."""
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        out = _sanitize_push_status_values({"sdcard": True, "force_upgrade": False}, {})
+
+        assert out["sdcard"] is True
+        assert out["force_upgrade"] is False
+
+    def test_the_full_bundle_chain_on_a_real_p1s_payload(self):
+        """The route's transform, end to end, on the shape from the #2702 bundle.
+
+        `_redact_raw_push_status` then `_sanitize_push_status_values` then
+        `json.dumps(default=str)` — the composition the bundle writer applies.
+        The bundle that exposed this had five `k` values corrupted, so the
+        snapshot could not be read at all; the field the report was about
+        (`total_layer_num`) was sitting in it, intact and unreachable.
+        """
+        import json
+
+        from backend.app.api.routes.support import (
+            _redact_raw_push_status,
+            _sanitize_push_status_values,
+        )
+
+        raw = {
+            "gcode_file": "AMS_Filament_Clip_3MF.3mf",
+            "layer_num": 2,
+            "total_layer_num": 33,
+            "home_flag": 7554487,
+            "sdcard": True,
+            "net": {"info": [{"ip": "192.168.1.50", "mask": 0}]},
+            "ams": {
+                "ams": [
+                    {
+                        "id": "0",
+                        "humidity": "5",
+                        "tray": [
+                            {"id": "0", "k": 0.0199999995529652, "tag_uid": "0123456789ABCDEF"},
+                            {"id": "1", "k": 0.0209999997168779, "tag_uid": "44F782D000000100"},
+                        ],
+                    }
+                ]
+            },
+        }
+
+        snapshot = {
+            "model": "P1S",
+            "firmware_version": "01.10.00.00",
+            "raw_data": _redact_raw_push_status(raw),
+        }
+        text = json.dumps(_sanitize_push_status_values(snapshot, {}), indent=2, default=str)
+
+        parsed = json.loads(text)  # used to raise "Expecting ',' delimiter"
+        trays = parsed["raw_data"]["ams"]["ams"][0]["tray"]
+        assert [t["k"] for t in trays] == [0.0199999995529652, 0.0209999997168779]
+        assert parsed["raw_data"]["total_layer_num"] == 33
+        # Redaction still did its job on both fronts.
+        assert "gcode_file" not in parsed["raw_data"]
+        assert trays[0]["tag_uid"] == "[SERIAL]"
+        # The structural pass replaces the printer's LAN address with the
+        # sentinel 0.0.0.0, which is itself an IPv4 literal, so the value pass
+        # then masks it to [IP]. Harmless — the real address is already gone —
+        # and matches what shipped in the bundle behind #2702.
+        assert parsed["raw_data"]["net"]["info"][0]["ip"] == "[IP]"
+
+    def test_does_not_mutate_the_live_snapshot(self):
+        """`state.raw_data` is read by the dispatcher on every tick.
+
+        The bundle writer passes a redacted copy, but a walker that mutated in
+        place would still be one refactor away from redacting the live state.
+        """
+        import copy
+
+        from backend.app.api.routes.support import _sanitize_push_status_values
+
+        raw = {"tag_uid": "0123456789ABCDEF", "ams": [{"tray": [{"k": 0.02, "n": "0123456789ABCDEF"}]}]}
+        before = copy.deepcopy(raw)
+
+        out = _sanitize_push_status_values(raw, {})
+
+        assert raw == before, "input was mutated"
+        assert out["tag_uid"] == "[SERIAL]"  # and the copy really was redacted

+ 107 - 0
backend/tests/unit/test_telegram_forum_topic.py

@@ -0,0 +1,107 @@
+"""Tests for optional Telegram forum-topic delivery via message_thread_id (#1518).
+
+Telegram forum groups route messages to a topic by ``message_thread_id``.  The
+field is optional: when it is absent, Telegram posts to the group's General
+topic, which is the behaviour every existing install already relies on.
+
+The subtlety worth pinning is the type.  ``sendMessage`` is posted as JSON, and
+Telegram rejects a *string* thread id there, while the multipart ``sendPhoto``
+call would accept one.  A string passed straight through would therefore work
+for notifications carrying a thumbnail and 400 for plain-text ones — so these
+tests assert an ``int`` reaches both call sites.
+"""
+
+import httpx
+import pytest
+
+from backend.app.services.notification_service import NotificationService
+
+
+class _CaptureClient:
+    """Stand-in for httpx.AsyncClient recording the JSON body and form data."""
+
+    def __init__(self):
+        self.is_closed = False
+        self.calls: list[dict] = []
+
+    async def post(self, url, data=None, files=None, json=None):
+        self.calls.append({"url": url, "data": data, "files": files, "json": json})
+        return httpx.Response(200, json={"ok": True, "result": {}})
+
+
+@pytest.fixture
+def service_with_capture():
+    service = NotificationService()
+    client = _CaptureClient()
+    service._http_client = client  # bypass real HTTP
+    return service, client
+
+
+BASE_CONFIG = {"bot_token": "123456:AAbbCC", "chat_id": "-1002520100736"}
+PNG = b"\x89PNG\r\n\x1a\n"
+
+
+@pytest.mark.asyncio
+async def test_thread_id_omitted_when_unset(service_with_capture):
+    """Default config must produce exactly the pre-#1518 payload."""
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram(BASE_CONFIG, "*T*\nbody")
+    assert ok
+    assert "message_thread_id" not in client.calls[0]["json"]
+
+
+@pytest.mark.asyncio
+async def test_blank_thread_id_is_treated_as_unset(service_with_capture):
+    """An emptied-out form field must not turn into a bogus topic."""
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "   "}, "*T*\nbody")
+    assert ok
+    assert "message_thread_id" not in client.calls[0]["json"]
+
+
+@pytest.mark.asyncio
+async def test_sendmessage_carries_thread_id_as_int(service_with_capture):
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "25"}, "*T*\nbody")
+    assert ok
+    body = client.calls[0]["json"]
+    assert body["message_thread_id"] == 25
+    assert isinstance(body["message_thread_id"], int), "Telegram 400s on a string thread id in JSON"
+
+
+@pytest.mark.asyncio
+async def test_sendphoto_carries_thread_id(service_with_capture):
+    """Thumbnail notifications take the multipart path and must route too."""
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "25"}, "*T*\nbody", image_data=PNG)
+    assert ok
+    call = client.calls[0]
+    assert call["url"].endswith("/sendPhoto")
+    assert call["data"]["message_thread_id"] == 25
+
+
+@pytest.mark.asyncio
+async def test_thread_id_accepts_native_int(service_with_capture):
+    """config is a JSON blob — the value may already deserialise as an int."""
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": 25}, "*T*\nbody")
+    assert ok
+    assert client.calls[0]["json"]["message_thread_id"] == 25
+
+
+@pytest.mark.asyncio
+async def test_non_numeric_thread_id_fails_without_sending(service_with_capture):
+    """Reject locally rather than let Telegram answer with an opaque 400."""
+    service, client = service_with_capture
+    ok, error = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "General"}, "*T*\nbody")
+    assert not ok
+    assert "not a number" in error
+    assert client.calls == []
+
+
+@pytest.mark.asyncio
+async def test_error_message_does_not_leak_bot_token(service_with_capture):
+    service, _ = service_with_capture
+    ok, error = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "oops"}, "*T*\nbody")
+    assert not ok
+    assert "AAbbCC" not in error

+ 106 - 0
backend/tests/unit/test_threemf_tools.py

@@ -10,6 +10,7 @@ import math
 import zipfile
 
 from backend.app.utils.threemf_tools import (
+    expand_to_project_slots,
     extract_bed_type_from_3mf,
     extract_embedded_presets_from_3mf,
     extract_filament_usage_from_3mf,
@@ -480,6 +481,111 @@ class TestExtractProjectFilamentsFrom3mf:
             assert extract_project_filaments_from_3mf(zf) == []
 
 
+# ---------------------------------------------------------------------------
+# Tests for expand_to_project_slots — #2712
+# ---------------------------------------------------------------------------
+
+
+class TestExpandToProjectSlots:
+    """The slice modal's filament list is positional: index 0 is slot 1, all
+    the way through to the ``filament_N.json`` parts handed to the slicer.
+
+    A MakerWorld source that carries slice_info but paints with slot 4 alone
+    used to yield a one-row list, so the user's single pick was bound to slot
+    1 and slot 4 — the slot that actually prints — kept the source's embedded
+    default. Picking PETG produced a PLA print.
+    """
+
+    PROJECT = json.dumps(
+        {
+            "filament_type": ["PLA", "PLA", "PLA", "PLA"],
+            "filament_colour": ["#38CC0A", "#161616", "#898989", "#898989"],
+        }
+    )
+
+    def test_a_single_used_slot_still_produces_a_full_positional_list(self):
+        """The reported file: four project slots, only slot 4 printed."""
+        used = [
+            {
+                "slot_id": 4,
+                "type": "PLA",
+                "color": "#898989",
+                "used_grams": 105.9,
+                "used_meters": 35.51,
+                "tray_info_idx": "GFL99",
+                "used_in_plate": True,
+            }
+        ]
+        with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
+            out = expand_to_project_slots(zf, used)
+
+        assert [f["slot_id"] for f in out] == [1, 2, 3, 4]
+        assert [f["used_in_plate"] for f in out] == [False, False, False, True]
+
+    def test_the_used_row_keeps_its_usage_figures(self):
+        """The modal shows the real weight and colour, and the print path
+        downstream reads ``tray_info_idx`` — none of it may be flattened into
+        a zeroed project row."""
+        used = [
+            {
+                "slot_id": 4,
+                "type": "PETG",
+                "color": "#FF0000",
+                "used_grams": 105.9,
+                "used_meters": 35.51,
+                "tray_info_idx": "GFL99",
+                "used_in_plate": True,
+            }
+        ]
+        with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
+            out = expand_to_project_slots(zf, used)
+
+        slot4 = out[3]
+        assert slot4["used_grams"] == 105.9
+        assert slot4["tray_info_idx"] == "GFL99"
+        # Resolved from the slice, not the project's stale PLA/#898989.
+        assert (slot4["type"], slot4["color"]) == ("PETG", "#FF0000")
+
+    def test_padding_rows_carry_the_project_type_and_colour(self):
+        """They drive the modal's pre-pick for the disabled rows."""
+        used = [{"slot_id": 4, "type": "PLA", "color": "#898989", "used_grams": 1.0, "used_meters": 1.0}]
+        with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
+            out = expand_to_project_slots(zf, used)
+
+        assert (out[0]["type"], out[0]["color"]) == ("PLA", "#38CC0A")
+        assert out[0]["used_grams"] == 0
+
+    def test_every_slot_used_is_a_shape_change_only(self):
+        used = [
+            {"slot_id": i, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0, "used_in_plate": True}
+            for i in (1, 2, 3, 4)
+        ]
+        with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
+            out = expand_to_project_slots(zf, used)
+
+        assert len(out) == 4
+        assert all(f["used_in_plate"] for f in out)
+        assert all(f["used_grams"] == 5.0 for f in out)
+
+    def test_a_used_slot_beyond_the_project_list_is_kept(self):
+        """Dropping it would recreate the original bug on a file whose
+        project settings and slice_info disagree — the one slot that prints
+        would vanish from the list entirely."""
+        used = [{"slot_id": 9, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0}]
+        with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
+            out = expand_to_project_slots(zf, used)
+
+        assert [f["slot_id"] for f in out] == [1, 2, 3, 4, 9]
+        assert out[-1]["used_in_plate"] is True
+
+    def test_returns_the_input_unchanged_without_project_settings(self):
+        """Nothing to widen against — a narrow list still prints correctly,
+        an invented one might not."""
+        used = [{"slot_id": 4, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0}]
+        with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
+            assert expand_to_project_slots(zf, used) == used
+
+
 # ---------------------------------------------------------------------------
 # Tests for extract_plate_extruder_set_from_3mf — three sources unioned:
 # object top-level extruder, per-part extruder, painted-face quadtree leaves.

+ 2 - 0
frontend/src/App.tsx

@@ -26,6 +26,7 @@ import { SetupPage } from './pages/SetupPage';
 import { NotificationsPage } from './pages/NotificationsPage';
 import { GCodeViewerPage } from './pages/GCodeViewerPage';
 import { useWebSocket } from './hooks/useWebSocket';
+import { usePrintProgressTitle } from './hooks/usePrintProgressTitle';
 import { useStreamTokenSync } from './hooks/useCameraStreamToken';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -89,6 +90,7 @@ function StreamTokenSync() {
 
 function WebSocketProvider({ children }: { children: React.ReactNode }) {
   useWebSocket();
+  usePrintProgressTitle();
   return <>{children}</>;
 }
 

+ 90 - 0
frontend/src/__tests__/components/AddNotificationModal.test.tsx

@@ -571,3 +571,93 @@ describe('AddNotificationModal — Bark provider (#1495)', () => {
     });
   });
 });
+
+describe('AddNotificationModal — Telegram forum topic (#1518)', () => {
+  const telegramProvider = (config: Record<string, unknown> = { bot_token: 'x', chat_id: '-100123' }) =>
+    buildProvider({ provider_type: 'telegram', config });
+
+  it('offers the Forum Topic ID field as optional for telegram', async () => {
+    render(<AddNotificationModal provider={telegramProvider()} onClose={() => undefined} />);
+
+    const label = await screen.findByText(/forum topic id/i);
+    // Required fields are marked with a trailing asterisk — this one must not be.
+    expect(label.textContent).not.toContain('*');
+    expect(screen.getByText(/leave empty for the general topic/i)).toBeInTheDocument();
+  });
+
+  it('does not offer the field for other providers', async () => {
+    render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
+
+    await screen.findByDisplayValue('My ntfy');
+    expect(screen.queryByText(/forum topic id/i)).not.toBeInTheDocument();
+  });
+
+  it('round-trips the topic id into config on save', async () => {
+    let captured: { config: Record<string, unknown> } | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as { config: Record<string, unknown> };
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={telegramProvider()} onClose={onClose} />);
+
+    await user.type(await screen.findByPlaceholderText('123'), '25');
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).not.toBeNull();
+    expect(captured!.config).toMatchObject({ chat_id: '-100123', message_thread_id: '25' });
+  });
+
+  it('keeps the config free of the key when the field is left empty', async () => {
+    let captured: { config: Record<string, unknown> } | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as { config: Record<string, unknown> };
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={telegramProvider()} onClose={onClose} />);
+
+    await screen.findByPlaceholderText('123');
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured!.config).not.toHaveProperty('message_thread_id');
+  });
+
+  it('blocks save on a non-numeric topic id', async () => {
+    // Reaches the form via a config written by the API rather than the picker —
+    // the number input itself already filters most junk out.
+    let patched = false;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async () => {
+        patched = true;
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(
+      <AddNotificationModal
+        provider={telegramProvider({ bot_token: 'x', chat_id: '-100123', message_thread_id: 'General' })}
+        onClose={onClose}
+      />,
+    );
+
+    await screen.findByText(/forum topic id/i);
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    expect(await screen.findByText(/forum topic id must be a number/i)).toBeInTheDocument();
+    expect(patched).toBe(false);
+    expect(onClose).not.toHaveBeenCalled();
+  });
+});

+ 96 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -1242,6 +1242,102 @@ describe('SliceModal', () => {
     });
   });
 
+  // #2712: the filament list is positional the whole way down — index 0 is
+  // slot 1, and the backend forwards it as filament_1.json..filament_N.json.
+  // A MakerWorld source that ships slice_info and paints with slot 4 alone
+  // used to yield a one-row list, so the user's only pick was bound to slot 1
+  // and slot 4 sliced with the source's embedded default: picking PETG gave a
+  // PLA print. The modal now asks for every project slot so the positions
+  // line up.
+  it('requests every project slot, not just the ones the plate prints with', async () => {
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Tunnel.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(mockApi.getLibraryFileFilamentRequirements).toHaveBeenCalled());
+    const [, plateArg, , fullSlots] = mockApi.getLibraryFileFilamentRequirements.mock.calls[0];
+    expect(plateArg).toBe(1);
+    expect(fullSlots).toBe(true);
+  });
+
+  it('sends the pick for a high-numbered slot at its own index', async () => {
+    // Four project slots, only slot 4 printed. The pick must arrive as the
+    // FOURTH entry; anywhere else and the slicer binds it to the wrong slot.
+    mockApi.getLibraryFilePlates.mockResolvedValue({
+      file_id: 100,
+      filename: 'Tunnel.3mf',
+      is_multi_plate: false,
+      plates: [
+        {
+          index: 1,
+          name: 'Plate 1',
+          objects: ['Tunnel'],
+          has_thumbnail: false,
+          thumbnail_url: null,
+          print_time_seconds: 1200,
+          filament_used_grams: 105,
+          filaments: [],
+        },
+      ],
+    });
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
+      file_id: 100,
+      filename: 'Tunnel.3mf',
+      plate_id: 1,
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#38CC0A', used_grams: 0, used_meters: 0, used_in_plate: false },
+        { slot_id: 2, type: 'PLA', color: '#161616', used_grams: 0, used_meters: 0, used_in_plate: false },
+        { slot_id: 3, type: 'PLA', color: '#898989', used_grams: 0, used_meters: 0, used_in_plate: false },
+        { slot_id: 4, type: 'PLA', color: '#898989', used_grams: 105, used_meters: 35, used_in_plate: true },
+      ],
+    });
+    mockApi.getSlicerPresets.mockResolvedValue({
+      cloud: {
+        printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }],
+        process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }],
+        filament: [
+          { id: 'F-PLA', name: 'Cloud PLA Grey', source: 'cloud', filament_type: 'PLA', filament_colour: '#898989' },
+          { id: 'F-PETG', name: 'Cloud PETG', source: 'cloud', filament_type: 'PETG', filament_colour: '#00FF00' },
+        ],
+      },
+      local: { printer: [], process: [], filament: [] },
+      standard: { printer: [], process: [], filament: [] },
+      cloud_status: 'ok',
+      orca_cloud: { printer: [], process: [], filament: [] },
+      orca_cloud_status: 'ok',
+    });
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 51,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/51',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Tunnel.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
+
+    // 1 printer + 1 process + 1 bed-type + 4 filament rows.
+    const selects = presetSelects();
+    expect(selects).toHaveLength(7);
+    // Only slot 4 is selectable — the other three are the padding.
+    expect([selects[3].disabled, selects[4].disabled, selects[5].disabled]).toEqual([true, true, true]);
+    expect(selects[6].disabled).toBe(false);
+
+    const user = userEvent.setup();
+    await user.selectOptions(selects[6], 'cloud:F-PETG');
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
+      expect(body.filament_presets).toHaveLength(4);
+      expect(body.filament_presets[3]).toEqual({ source: 'cloud', id: 'F-PETG' });
+    });
+  });
+
   // ------------------------------------------------------------------
   // Slicer Pipelines (#1425) — Apply / Save integration in SliceModal
   // ------------------------------------------------------------------

+ 185 - 0
frontend/src/__tests__/contexts/SliceJobTrackerContext.test.tsx

@@ -482,3 +482,188 @@ describe('SliceJobTrackerProvider — persistent progress toast', () => {
     expect(screen.queryByText(/%/)).toBeNull();
   });
 });
+
+describe('SliceJobTrackerProvider — one completion per job', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.clearAllMocks();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  /** A QueryClient whose invalidateQueries is counted. completeJob calls it
+   * twice (library-files + archives), so the counter divided by two is the
+   * number of times the terminal state was handled — a direct count that
+   * doesn't depend on how long a transient toast happens to stay on screen. */
+  function countingWrapper(counter: { n: number }) {
+    return function Counting({ children }: { children: ReactNode }) {
+      const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+      const original = queryClient.invalidateQueries.bind(queryClient);
+      queryClient.invalidateQueries = ((...args: Parameters<typeof original>) => {
+        counter.n += 1;
+        return original(...args);
+      }) as typeof queryClient.invalidateQueries;
+      return (
+        <QueryClientProvider client={queryClient}>
+          <ToastProvider>
+            <SliceJobTrackerProvider>{children}</SliceJobTrackerProvider>
+          </ToastProvider>
+        </QueryClientProvider>
+      );
+    };
+  }
+
+  it('handles completion once when the backend stalls for many poll intervals', async () => {
+    // The reported symptom: a single slice produced a stream of "Sliced X"
+    // toasts, more than ten of them, arriving one poll interval apart.
+    //
+    // setInterval does not await an async callback. Slicing a large project
+    // blocks the backend for seconds (zip parsing and output assembly are
+    // synchronous), so poll ticks piled up behind one stalled request —
+    // each holding a snapshot taken while the job was still active. When
+    // the backend recovered they all resolved 'completed' at once and each
+    // one ran the completion path. A 20s stall against the 1.5s interval
+    // stacks ~14 of them, which is the observed magnitude.
+    const STALL_MS = 20_000;
+    let polls = 0;
+    mockApi.getSliceJob.mockImplementation(async () => {
+      polls += 1;
+      await new Promise((resolve) => setTimeout(resolve, STALL_MS));
+      return {
+        job_id: 30,
+        status: 'completed',
+        kind: 'library_file',
+        source_id: 300,
+        source_name: 'Stalled.3mf',
+        created_at: new Date().toISOString(),
+        started_at: new Date().toISOString(),
+        completed_at: new Date().toISOString(),
+      };
+    });
+
+    const counter = { n: 0 };
+    const Counting = countingWrapper(counter);
+    render(
+      <Counting>
+        <TrackTrigger id={30} name="Stalled.3mf" />
+      </Counting>,
+    );
+
+    act(() => {
+      screen.getByText('track-30').click();
+    });
+
+    // Well past the stall, so every tick that could have piled up has had
+    // its chance to resolve.
+    for (let i = 0; i < 200; i += 1) {
+      await act(async () => {
+        vi.advanceTimersByTime(200);
+      });
+      await act(async () => {
+        await Promise.resolve();
+        await Promise.resolve();
+      });
+    }
+
+    expect(counter.n / 2).toBe(1);
+    // The in-flight guard also has to stop the pile-up itself, not just its
+    // visible consequence: a stalled backend must not be handed a fresh
+    // request every 1.5s. One poll starts, one more can start after it
+    // resolves and the job is already gone.
+    expect(polls).toBeLessThanOrEqual(2);
+  });
+
+  it('handles the slower of two jobs once when the first one restarts the poller', async () => {
+    // The in-flight guard alone does not cover this. Two jobs are tracked;
+    // the first completes while the second's request is still open. That
+    // completion changes the tracked count, so the polling effect tears
+    // down and starts a fresh interval — with its own in-flight flag —
+    // while the previous round is still parked on the second job's await.
+    // Both rounds then see 'completed' for it, and the job reports itself
+    // twice unless the abandoned round notices it was cancelled or the
+    // completion path refuses the repeat. Either guard alone closes this;
+    // both are kept, so removing one still passes and removing both fails.
+    mockApi.getSliceJob.mockImplementation(async (id: number) => {
+      const base = {
+        kind: 'library_file' as const,
+        created_at: new Date().toISOString(),
+        started_at: new Date().toISOString(),
+        completed_at: new Date().toISOString(),
+      };
+      if (id === 40) {
+        return { ...base, job_id: 40, status: 'completed', source_id: 400, source_name: 'Fast.3mf' };
+      }
+      await new Promise((resolve) => setTimeout(resolve, 6000));
+      return { ...base, job_id: 41, status: 'completed', source_id: 401, source_name: 'Slow.3mf' };
+    });
+
+    const counter = { n: 0 };
+    const Counting = countingWrapper(counter);
+    render(
+      <Counting>
+        <TrackTrigger id={40} name="Fast.3mf" />
+        <TrackTrigger id={41} name="Slow.3mf" />
+      </Counting>,
+    );
+
+    act(() => {
+      screen.getByText('track-40').click();
+      screen.getByText('track-41').click();
+    });
+
+    for (let i = 0; i < 100; i += 1) {
+      await act(async () => {
+        vi.advanceTimersByTime(200);
+      });
+      await act(async () => {
+        await Promise.resolve();
+        await Promise.resolve();
+      });
+    }
+
+    // Exactly two completions: one per job, neither repeated.
+    expect(counter.n / 2).toBe(2);
+  });
+
+  it('still completes a job tracked again under the same id', async () => {
+    // The finished-id set must not turn into a permanent block list: a
+    // re-tracked id has to reach the completion path again.
+    mockApi.getSliceJob.mockResolvedValue({
+      job_id: 32,
+      status: 'completed',
+      kind: 'library_file',
+      source_id: 302,
+      source_name: 'Again.3mf',
+      created_at: new Date().toISOString(),
+      started_at: new Date().toISOString(),
+      completed_at: new Date().toISOString(),
+    });
+
+    const counter = { n: 0 };
+    const Counting = countingWrapper(counter);
+    render(
+      <Counting>
+        <TrackTrigger id={32} name="Again.3mf" />
+      </Counting>,
+    );
+
+    for (let round = 0; round < 2; round += 1) {
+      act(() => {
+        screen.getByText('track-32').click();
+      });
+      for (let i = 0; i < 3; i += 1) {
+        await act(async () => {
+          vi.advanceTimersByTime(1500);
+        });
+        await act(async () => {
+          await Promise.resolve();
+          await Promise.resolve();
+        });
+      }
+    }
+
+    expect(counter.n / 2).toBe(2);
+  });
+});

+ 110 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -1287,3 +1287,113 @@ describe('useFilamentMapping — no [-1] mapping during a status-load race (#258
     expect(result.current.hasTypeMismatch).toBe(true);
   });
 });
+
+describe('colour verdict is independent of how the tray was found (#2687)', () => {
+  // tray_info_idx names the filament *variant*, not an individual spool:
+  // GFA00 = PLA Basic, GFA01 = PLA Matte, GFA17 = PLA Translucent. A user with
+  // exactly one Matte spool loaded therefore idx-matches every Matte
+  // requirement no matter what colour it is.
+  const MATTE_DARK_GREEN = createPrinterStatus([
+    { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA01' }] },
+  ]);
+  const wantRedMatte = {
+    filaments: [
+      { slot_id: 1, type: 'PLA', color: '#9D432C', used_grams: 31, tray_info_idx: 'GFA01' },
+    ],
+  };
+
+  it('reports a unique-idx tray of the wrong colour as type_only, not match', () => {
+    const [item] = buildFilamentComparison(
+      wantRedMatte,
+      buildLoadedFilaments(MATTE_DARK_GREEN),
+      {},
+    );
+
+    // The tray is still selected — it is the right variant (#2650) ...
+    expect(item.loaded?.globalTrayId).toBe(0);
+    // ... but red-on-dark-green is not a colour match.
+    expect(item.colorMatch).toBe(false);
+    expect(item.status).toBe('type_only');
+  });
+
+  it('auto and manual agree on the same tray', () => {
+    const loaded = buildLoadedFilaments(MATTE_DARK_GREEN);
+    const auto = buildFilamentComparison(wantRedMatte, loaded, {})[0];
+    const manual = buildFilamentComparison(wantRedMatte, loaded, { 1: 0 })[0];
+
+    // The original report: auto said "match", manually picking that very tray
+    // said "mismatch". Both paths must now reach the same verdict.
+    expect(manual.isManual).toBe(true);
+    expect(auto.status).toBe(manual.status);
+    expect(auto.colorMatch).toBe(manual.colorMatch);
+  });
+
+  it('surfaces the mismatch through the hook so the panel stops saying Ready', () => {
+    const { result } = renderHook(() => useFilamentMapping(wantRedMatte, MATTE_DARK_GREEN, {}));
+    // hasColorMismatch drives the yellow "(Color mismatch)" header; the tray is
+    // still mapped, so this is not a type mismatch.
+    expect(result.current.hasColorMismatch).toBe(true);
+    expect(result.current.hasTypeMismatch).toBe(false);
+    expect(result.current.amsMapping).toEqual([0]);
+  });
+
+  it('still reports a match when the unique-idx tray does carry the right colour', () => {
+    const [item] = buildFilamentComparison(
+      { filaments: [{ slot_id: 1, type: 'PLA', color: '#004225', used_grams: 31, tray_info_idx: 'GFA01' }] },
+      buildLoadedFilaments(MATTE_DARK_GREEN),
+      {},
+    );
+    expect(item.status).toBe('match');
+    expect(item.colorMatch).toBe(true);
+  });
+
+  it('accepts a near-enough shade on the idx path', () => {
+    // Within colorsAreSimilar's per-channel tolerance — the printer reporting a
+    // spool a shade off must not become a mismatch.
+    const [item] = buildFilamentComparison(
+      { filaments: [{ slot_id: 1, type: 'PLA', color: '#0A4A2A', used_grams: 31, tray_info_idx: 'GFA01' }] },
+      buildLoadedFilaments(MATTE_DARK_GREEN),
+      {},
+    );
+    expect(item.status).toBe('match');
+  });
+
+  it('treats a colourless requirement as satisfied by any colour', () => {
+    // 3MFs that omit the colour parse to "" (filament_requirements.py); there is
+    // nothing to disagree with, so this must not read as a colour mismatch.
+    const [item] = buildFilamentComparison(
+      { filaments: [{ slot_id: 1, type: 'PLA', color: '', used_grams: 31, tray_info_idx: 'GFA01' }] },
+      buildLoadedFilaments(MATTE_DARK_GREEN),
+      {},
+    );
+    expect(item.status).toBe('match');
+    expect(item.colorMatch).toBe(true);
+  });
+
+  it('keeps the multi-idx path intact — same idx, several colours picks the right one', () => {
+    // Two Matte spools: the branch that already compared colours must be
+    // unaffected, and the exact-colour tray still wins.
+    const twoMatte = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA01' },
+          { id: 1, tray_type: 'PLA', tray_color: '9D432C', tray_info_idx: 'GFA01' },
+        ],
+      },
+    ]);
+    const [item] = buildFilamentComparison(wantRedMatte, buildLoadedFilaments(twoMatte), {});
+    expect(item.loaded?.globalTrayId).toBe(1);
+    expect(item.status).toBe('match');
+  });
+
+  it('a type-only fallback with no idx candidate is still type_only', () => {
+    // Regression guard: the pre-existing "type matches, colour does not" path.
+    const basicOnly = createPrinterStatus([
+      { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA00' }] },
+    ]);
+    const [item] = buildFilamentComparison(wantRedMatte, buildLoadedFilaments(basicOnly), {});
+    expect(item.status).toBe('type_only');
+    expect(item.colorMatch).toBe(false);
+  });
+});

+ 157 - 0
frontend/src/__tests__/hooks/usePrintProgressTitle.test.tsx

@@ -0,0 +1,157 @@
+import type { ReactNode } from 'react';
+import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
+import { renderHook, waitFor, cleanup } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+// Mock the theme pref and the API the hook reads, so the effect can be exercised
+// without the real providers. `theme.value` is swapped per test.
+const h = vi.hoisted(() => ({
+  theme: {
+    value: {
+      progressInTitle: false,
+      resolvedMode: 'dark',
+      darkAccent: 'green',
+      lightAccent: 'green',
+    } as { progressInTitle: boolean; resolvedMode: string; darkAccent: string; lightAccent: string },
+  },
+  getPrinters: vi.fn(),
+  getPrinterStatus: vi.fn(),
+}));
+
+vi.mock('../../contexts/ThemeContext', () => ({ useTheme: () => h.theme.value }));
+vi.mock('../../api/client', () => ({
+  api: { getPrinters: h.getPrinters, getPrinterStatus: h.getPrinterStatus },
+}));
+
+import { pickActivePrint, usePrintProgressTitle, type ProgressStatus } from '../../hooks/usePrintProgressTitle';
+
+const running = (progress: number, remaining_time: number | null): ProgressStatus => ({
+  state: 'RUNNING',
+  progress,
+  remaining_time,
+});
+
+describe('pickActivePrint', () => {
+  it('returns null when nothing is printing', () => {
+    expect(pickActivePrint([])).toBeNull();
+    expect(pickActivePrint([undefined])).toBeNull();
+    expect(pickActivePrint([{ state: 'IDLE', progress: 0, remaining_time: null }])).toBeNull();
+    // The real paused state is 'PAUSE', not 'PAUSED'.
+    expect(pickActivePrint([{ state: 'PAUSE', progress: 40, remaining_time: 10 }])).toBeNull();
+  });
+
+  it('ignores RUNNING prints with no progress value', () => {
+    expect(pickActivePrint([{ state: 'RUNNING', progress: null, remaining_time: 5 }])).toBeNull();
+  });
+
+  it('picks the soonest-finishing print among several running', () => {
+    const soonest = running(20, 12);
+    expect(pickActivePrint([running(80, 45), soonest, running(50, 30)])).toBe(soonest);
+  });
+
+  it('tie-breaks equal ETAs by highest progress', () => {
+    const further = running(70, 15);
+    expect(pickActivePrint([running(30, 15), further])).toBe(further);
+  });
+
+  it('treats a null remaining_time as furthest away', () => {
+    const withEta = running(10, 60);
+    expect(pickActivePrint([running(90, null), withEta])).toBe(withEta);
+  });
+
+  it('treats remaining_time <= 0 as unknown — a just-started print must not win', () => {
+    // The backend serialises "ETA not known yet" as 0 (not null). A printer that
+    // just started (0) must not steal the tab from one that is nearly done.
+    const almostDone = running(95, 180);
+    expect(pickActivePrint([running(2, 0), almostDone])).toBe(almostDone);
+    expect(pickActivePrint([running(2, -1), almostDone])).toBe(almostDone);
+  });
+});
+
+function wrapper() {
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return ({ children }: { children: ReactNode }) => (
+    <QueryClientProvider client={qc}>{children}</QueryClientProvider>
+  );
+}
+
+// jsdom has no canvas backend — calling getContext('2d') logs a "Not implemented"
+// jsdomError (with a full React stack) into the suite output on every run, and
+// leaves the favicon path untested because it bails on the null context. Stub
+// both canvas calls so the ring code actually executes and the swap is assertable.
+const RING_URL = 'data:image/png;base64,ring';
+const fakeCtx = {
+  beginPath: vi.fn(),
+  arc: vi.fn(),
+  stroke: vi.fn(),
+  lineWidth: 0,
+  strokeStyle: '',
+  lineCap: 'butt',
+} as unknown as CanvasRenderingContext2D;
+
+const realGetContext = HTMLCanvasElement.prototype.getContext;
+const realToDataURL = HTMLCanvasElement.prototype.toDataURL;
+
+function faviconHref(): string {
+  return document.querySelector<HTMLLinkElement>('link[rel~="icon"]')!.href;
+}
+
+describe('usePrintProgressTitle effect', () => {
+  beforeEach(() => {
+    h.getPrinters.mockReset();
+    h.getPrinterStatus.mockReset();
+
+    HTMLCanvasElement.prototype.getContext = (() =>
+      fakeCtx) as typeof HTMLCanvasElement.prototype.getContext;
+    HTMLCanvasElement.prototype.toDataURL = (() =>
+      RING_URL) as typeof HTMLCanvasElement.prototype.toDataURL;
+
+    // Replaces <title> too, so set the title after wiring the head up — the hook
+    // captures document.title at mount.
+    document.head.innerHTML = '<link rel="icon" href="/favicon.svg">';
+    document.title = 'Bambuddy';
+  });
+  afterEach(() => {
+    cleanup();
+    HTMLCanvasElement.prototype.getContext = realGetContext;
+    HTMLCanvasElement.prototype.toDataURL = realToDataURL;
+  });
+
+  it('is inert while the pref is off — never touches the tab title', async () => {
+    h.theme.value = { progressInTitle: false, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
+    document.title = 'Something Else';
+
+    renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
+
+    await new Promise((r) => setTimeout(r, 20));
+    expect(document.title).toBe('Something Else');
+    expect(faviconHref()).toContain('/favicon.svg');
+    expect(h.getPrinters).not.toHaveBeenCalled();
+  });
+
+  it('shows the active print percentage in the title and swaps the favicon', async () => {
+    h.theme.value = { progressInTitle: true, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
+    h.getPrinters.mockResolvedValue([{ id: 1 }]);
+    h.getPrinterStatus.mockResolvedValue({ state: 'RUNNING', progress: 42, remaining_time: 600 });
+
+    renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
+
+    await waitFor(() => expect(document.title).toBe('42% · Bambuddy'));
+    expect(faviconHref()).toBe(RING_URL);
+  });
+
+  it('restores the original title and favicon when the pref is switched off', async () => {
+    h.theme.value = { progressInTitle: true, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
+    h.getPrinters.mockResolvedValue([{ id: 1 }]);
+    h.getPrinterStatus.mockResolvedValue({ state: 'RUNNING', progress: 42, remaining_time: 600 });
+
+    const { rerender } = renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
+    await waitFor(() => expect(document.title).toBe('42% · Bambuddy'));
+
+    h.theme.value = { ...h.theme.value, progressInTitle: false };
+    rerender();
+
+    await waitFor(() => expect(document.title).toBe('Bambuddy'));
+    expect(faviconHref()).toContain('/favicon.svg');
+  });
+});

+ 4 - 1
frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx

@@ -71,7 +71,10 @@ function mockAuthUser(permissions: string[]) {
 }
 
 async function openFolderMenu(user: ReturnType<typeof userEvent.setup>, folderName: string) {
-  const row = screen.getByText(folderName).parentElement!;
+  // Walk up to the row itself rather than assuming the name is its direct
+  // child — the name sits in a wrapper that also holds the optional
+  // last-activity line (#2680).
+  const row = screen.getByText(folderName).closest('div.group')!;
   const buttons = within(row).getAllByRole('button');
   // The kebab (MoreVertical) menu toggle is the last button in the row
   await user.click(buttons[buttons.length - 1]);

+ 44 - 1
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -2,7 +2,7 @@
  * Tests for the FileManagerPage component.
  */
 
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
 import { screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
@@ -21,6 +21,9 @@ const mockFolders = [
     archive_id: null,
     project_name: null,
     archive_name: null,
+    // #2680: distinctive year so the folder-pane display test can assert on it
+    // without colliding with the file mtimes below.
+    latest_activity_at: '2031-04-05T10:00:00Z',
     children: [
       {
         id: 2,
@@ -31,6 +34,7 @@ const mockFolders = [
         archive_id: null,
         project_name: null,
         archive_name: null,
+        latest_activity_at: '2032-06-07T10:00:00Z',
         children: [],
       },
     ],
@@ -44,6 +48,9 @@ const mockFolders = [
     archive_id: null,
     project_name: 'My Art Project',
     archive_name: null,
+    // No activity timestamp — must render no date line rather than an
+    // "Invalid Date" placeholder.
+    latest_activity_at: null,
     children: [],
   },
 ];
@@ -880,6 +887,13 @@ describe('FileManagerPage', () => {
       setItemMock.mockReset();
     });
 
+    // The mock is module-global, so an implementation left behind here would
+    // silently change every later describe (e.g. collapsing the folder tree).
+    afterEach(() => {
+      getItemMock.mockReset();
+      setItemMock.mockReset();
+    });
+
     it('defaults to expanded (nested folders visible) when library-collapse-folders is unset', async () => {
       getItemMock.mockReturnValue(null);
       render(<FileManagerPage />);
@@ -1111,5 +1125,34 @@ describe('FileManagerPage', () => {
         expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
       });
     });
+
+    it('the same toggle reveals latest activity on folder rows, including nested ones', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Functional Parts')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByText(/2031/)).not.toBeInTheDocument();
+
+      await user.click(screen.getByTitle('Show modified dates'));
+
+      await waitFor(() => {
+        expect(screen.getByText(/2031/)).toBeInTheDocument();
+      });
+      // Nested folders get it too — the prop must survive the recursion.
+      expect(screen.getByText(/2032/)).toBeInTheDocument();
+
+      // A folder with no activity timestamp renders nothing rather than an
+      // "Invalid Date" string.
+      const artRow = screen.getByText('Art Projects').closest('div.group')!;
+      expect(artRow.textContent).not.toMatch(/Invalid/);
+
+      await user.click(screen.getByTitle('Hide modified dates'));
+      await waitFor(() => {
+        expect(screen.queryByText(/2031/)).not.toBeInTheDocument();
+      });
+    });
   });
 });

+ 14 - 0
frontend/src/api/client.ts

@@ -4693,10 +4693,17 @@ export const api = {
     archiveId: number,
     plateId?: number,
     requestId?: string,
+    /** Ask for one entry per project slot instead of only the slots this
+     * plate consumes. The slice modal needs it: its filament list is
+     * positional, so a source whose only used slot is 4 must still present
+     * four rows or the user's pick is bound to slot 1 (#2712). Print-time
+     * AMS matching must NOT set this — it wants the used-only list. */
+    fullSlots?: boolean,
   ) => {
     const qs = new URLSearchParams();
     if (plateId !== undefined) qs.set('plate_id', String(plateId));
     if (requestId) qs.set('request_id', requestId);
+    if (fullSlots) qs.set('full_slots', 'true');
     return request<{
       archive_id: number;
       filename: string;
@@ -6397,10 +6404,17 @@ export const api = {
     fileId: number,
     plateId?: number,
     requestId?: string,
+    /** Ask for one entry per project slot instead of only the slots this
+     * plate consumes. The slice modal needs it: its filament list is
+     * positional, so a source whose only used slot is 4 must still present
+     * four rows or the user's pick is bound to slot 1 (#2712). Print-time
+     * AMS matching must NOT set this — it wants the used-only list. */
+    fullSlots?: boolean,
   ) => {
     const qs = new URLSearchParams();
     if (plateId !== undefined) qs.set('plate_id', String(plateId));
     if (requestId) qs.set('request_id', requestId);
+    if (fullSlots) qs.set('full_slots', 'true');
     return request<{
       file_id: number;
       filename: string;

+ 22 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -160,6 +160,15 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       }
     }
 
+    // Telegram forum topic must be a plain integer (#1518) — type="number"
+    // still lets "1e5" and "-" through, and Telegram would 400 on those.
+    if (providerType === 'telegram' && config.message_thread_id?.trim()) {
+      if (!/^\d+$/.test(config.message_thread_id.trim())) {
+        setError(t('notifications.telegramThreadIdInvalid'));
+        return;
+      }
+    }
+
     const finalConfig: Record<string, unknown> =
       providerType === 'ntfy' && Object.keys(eventPriorities).length > 0
         ? { ...config, event_priorities: eventPriorities }
@@ -245,6 +254,16 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
         return [
           { key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },
           { key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true },
+          // Optional forum topic (#1518). Left empty, Telegram posts to the
+          // group's General topic exactly as before.
+          {
+            key: 'message_thread_id',
+            label: t('notifications.telegramThreadId'),
+            placeholder: '123',
+            type: 'number',
+            required: false,
+            help: t('notifications.telegramThreadIdHelp'),
+          },
         ];
       case 'email':
         return [
@@ -423,6 +442,9 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
                 )}
+                {'help' in field && (field as { help?: string }).help && (
+                  <p className="text-xs text-bambu-gray mt-1">{(field as { help?: string }).help}</p>
+                )}
               </div>
             ))}
           </div>

+ 8 - 2
frontend/src/components/SliceModal.tsx

@@ -316,10 +316,16 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   const filamentReqsQuery = useQuery({
     queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
     queryFn: async () => {
+      // `fullSlots`: one row per project slot, not only the ones this plate
+      // prints with. The list below is positional all the way to the CLI's
+      // filament_N.json parts, so a source whose only used slot is 4 has to
+      // present four rows — otherwise the single pick binds to slot 1 and
+      // slot 4 slices with whatever the source had baked in (#2712). The
+      // unused rows stay disabled exactly as before.
       if (source.kind === 'libraryFile') {
-        return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId);
+        return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
       }
-      return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId);
+      return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
     },
     enabled: !needsPlatePicker,
     staleTime: 60_000,

+ 47 - 15
frontend/src/contexts/SliceJobTrackerContext.tsx

@@ -85,6 +85,12 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
   const phaseRef = useRef<Map<number, SliceJobStatus>>(new Map());
   const progressRef = useRef<Map<number, SliceJobProgress | null>>(new Map());
 
+  // Job ids whose terminal state has already been handled. `completeJob`
+  // shows a toast and invalidates two query keys, so it has to be exactly
+  // once per job no matter how many callers reach it — see the poll loop
+  // below for how more than one used to.
+  const finishedRef = useRef<Set<number>>(new Set());
+
   const renderProgressToast = useCallback(
     (job: TrackedJob) => {
       const startedAt = startedAtRef.current.get(job.id);
@@ -151,6 +157,10 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
   const trackJob = useCallback(
     (id: number, kind: 'libraryFile' | 'archive', sourceName: string) => {
       setActiveJobs((prev) => (prev.some((j) => j.id === id) ? prev : [...prev, { id, kind, sourceName }]));
+      // Re-tracking an id re-arms it. Ids come from a database sequence so
+      // this can't collide in practice; clearing here is what keeps the set
+      // from being a permanent record of every job the session ever saw.
+      finishedRef.current.delete(id);
       startedAtRef.current.set(id, Date.now());
       phaseRef.current.set(id, 'pending');
       progressRef.current.set(id, null);
@@ -163,6 +173,11 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
 
   const completeJob = useCallback(
     (job: TrackedJob, state: SliceJobState) => {
+      // Guard, not an optimisation: everything below is a side effect the
+      // user sees, and a second call would repeat all of it.
+      if (finishedRef.current.has(job.id)) return;
+      finishedRef.current.add(job.id);
+
       setActiveJobs((prev) => prev.filter((j) => j.id !== job.id));
       startedAtRef.current.delete(job.id);
       phaseRef.current.delete(job.id);
@@ -201,24 +216,41 @@ export function SliceJobTrackerProvider({ children }: { children: ReactNode }) {
   useEffect(() => {
     if (activeJobs.length === 0) return;
     let cancelled = false;
+    // setInterval does not await an async callback, so a tick fires whether
+    // or not the previous one came back. Slicing a large project blocks the
+    // backend for seconds at a time (zip parsing and output assembly are
+    // synchronous), and every tick that piled up during the stall had
+    // already captured a snapshot naming the job as active. They all
+    // resolved `completed` together and each called completeJob, which is
+    // how one slice produced a stream of a dozen "Sliced X" toasts. Letting
+    // only one poll round be in flight fixes that at the source, and stops
+    // queueing requests against a backend that is already saturated.
+    let polling = false;
     const interval = setInterval(async () => {
-      if (cancelled) return;
-      const snapshot = [...activeJobsRef.current];
-      for (const job of snapshot) {
-        try {
-          const state = await api.getSliceJob(job.id);
-          phaseRef.current.set(job.id, state.status);
-          // Capture the latest progress snapshot if the sidecar fed
-          // one through. The 1s tick re-renders the toast off this ref.
-          if (state.progress) {
-            progressRef.current.set(job.id, state.progress);
-          }
-          if (state.status === 'completed' || state.status === 'failed') {
-            completeJob(job, state);
+      if (cancelled || polling) return;
+      polling = true;
+      try {
+        const snapshot = [...activeJobsRef.current];
+        for (const job of snapshot) {
+          try {
+            const state = await api.getSliceJob(job.id);
+            // The tracker may have been torn down while this was in flight.
+            if (cancelled) return;
+            phaseRef.current.set(job.id, state.status);
+            // Capture the latest progress snapshot if the sidecar fed
+            // one through. The 1s tick re-renders the toast off this ref.
+            if (state.progress) {
+              progressRef.current.set(job.id, state.progress);
+            }
+            if (state.status === 'completed' || state.status === 'failed') {
+              completeJob(job, state);
+            }
+          } catch {
+            // Transient poll failure — stay tracked, retry next tick.
           }
-        } catch {
-          // Transient poll failure — stay tracked, retry next tick.
         }
+      } finally {
+        polling = false;
       }
     }, POLL_INTERVAL_MS);
     return () => {

+ 15 - 0
frontend/src/contexts/ThemeContext.tsx

@@ -19,6 +19,9 @@ interface ThemeContextType {
   lightStyle: ThemeStyle;
   lightBackground: LightBackground;
   lightAccent: ThemeAccent;
+  // Show live print progress (% + accent-coloured ring favicon) in the browser tab
+  progressInTitle: boolean;
+  setProgressInTitle: (v: boolean) => void;
   // Actions
   toggleMode: () => void;
   setMode: (mode: ThemeMode) => void;
@@ -87,6 +90,17 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
     return (localStorage.getItem('light-accent') as ThemeAccent) || 'green';
   });
 
+  // Client-only pref (localStorage), no api.updateSettings sync — the tab
+  // title/favicon is per-browser behaviour. Move to server settings if it
+  // ever needs to follow the user across devices. Default off.
+  const [progressInTitle, setProgressInTitleState] = useState<boolean>(() => {
+    return localStorage.getItem('progress-in-title') === 'true';
+  });
+  const setProgressInTitle = (v: boolean) => {
+    setProgressInTitleState(v);
+    localStorage.setItem('progress-in-title', String(v));
+  };
+
   // Sync from API once auth state is known. Same gate shape as
   // useStreamTokenSync / ColorCatalogProvider: wait for AuthContext to
   // settle, then only fetch when we can actually expect a 200 (auth
@@ -202,6 +216,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
       resolvedMode,
       darkStyle, darkBackground, darkAccent,
       lightStyle, lightBackground, lightAccent,
+      progressInTitle, setProgressInTitle,
       toggleMode, setMode,
       setDarkStyle, setDarkBackground, setDarkAccent,
       setLightStyle, setLightBackground, setLightAccent,

+ 38 - 11
frontend/src/hooks/useFilamentMapping.ts

@@ -203,6 +203,27 @@ export function useLoadedFilaments(
   }, [printerStatus]);
 }
 
+/**
+ * Does the tray we picked actually carry the colour the slice asked for?
+ *
+ * Shared by the manual and auto branches below so the two can never disagree
+ * about the same tray again (#2687). Exact hex first, then the perceptual
+ * tolerance, so a spool the printer reports one shade off still reads as a
+ * match.
+ *
+ * A requirement with no colour at all is not a mismatch — the 3MF simply
+ * didn't ask for one (`filament_requirements.py` defaults it to `""`), so any
+ * loaded colour satisfies it. Loaded trays always have a colour: buildLoaded-
+ * Filaments falls back to grey when MQTT reports none.
+ */
+function coloursMatch(loadedColor: string | undefined, requiredColor: string | undefined): boolean {
+  const required = normalizeColorForCompare(requiredColor);
+  if (!required) return true;
+  return (
+    normalizeColorForCompare(loadedColor) === required || colorsAreSimilar(loadedColor, requiredColor)
+  );
+}
+
 /**
  * Compare required filaments with loaded filaments (non-hook version).
  *
@@ -236,9 +257,7 @@ export function buildFilamentComparison(
 
       if (manualLoaded) {
         const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
-        const colorMatch =
-          normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
-          colorsAreSimilar(manualLoaded.color, req.color);
+        const colorMatch = coloursMatch(manualLoaded.color, req.color);
 
         let status: FilamentStatus;
         if (typeMatch && colorMatch) {
@@ -358,17 +377,25 @@ export function buildFilamentComparison(
 
     const hasFilament = !!loaded;
     const typeMatch = hasFilament;
-    // idxMatch is always considered a color match (same spool = same color)
-    const colorMatch = !!idxMatch || !!exactMatch || !!similarMatch;
-
-    // Status: match (tray_info_idx, type+color, or similar color), type_only (type ok, color very different), mismatch (type not found)
+    // #2687: judge the colour on the tray we actually picked, never on which
+    // branch found it. tray_info_idx identifies the filament *variant* — GFA00
+    // is PLA Basic, GFA01 PLA Matte, GFA17 PLA Translucent — not an individual
+    // spool, so one Matte spool idx-matches every Matte requirement whatever
+    // colour it is. The old rule ("same spool = same color") therefore reported
+    // red-required-on-green-loaded as a match, while manually picking that same
+    // tray reported the mismatch honestly. Variant still decides *selection*
+    // (#2650: Basic is not Matte) — it just no longer decides the verdict.
+    const colorMatch = hasFilament && coloursMatch(loaded.color, req.color);
+
+    // No tray of the required type at all is a type mismatch; otherwise the
+    // colour decides between a full match and type-only.
     let status: FilamentStatus;
-    if (idxMatch || exactMatch || similarMatch) {
+    if (!hasFilament) {
+      status = 'mismatch';
+    } else if (colorMatch) {
       status = 'match';
-    } else if (typeOnlyMatch) {
-      status = 'type_only';
     } else {
-      status = 'mismatch';
+      status = 'type_only';
     }
 
     return {

+ 162 - 0
frontend/src/hooks/usePrintProgressTitle.ts

@@ -0,0 +1,162 @@
+import { useQueries, useQuery } from '@tanstack/react-query';
+import { useEffect, useRef } from 'react';
+import { api } from '../api/client';
+import { useTheme } from '../contexts/ThemeContext';
+
+const FALLBACK_ACCENT = '#00ae42'; // Bambuddy green, if --accent can't be read (e.g. jsdom)
+
+// A remaining_time <= 0 means "ETA not known yet" (the backend defaults it to 0,
+// not null), so treat it as unknown rather than "finishes now".
+const eta = (t: number | null): number => (t != null && t > 0 ? t : Infinity);
+
+// Only the fields we need — keeps pickActivePrint decoupled from the full
+// PrinterStatus type so the test can pass plain objects.
+export interface ProgressStatus {
+  state: string | null;
+  progress: number | null;
+  remaining_time: number | null;
+}
+
+/**
+ * Of all connected printers, pick the RUNNING print to surface in the tab:
+ * the one finishing soonest (smallest remaining_time), tie-broken by highest
+ * progress. Returns null when nothing is actively printing.
+ */
+export function pickActivePrint<T extends ProgressStatus>(statuses: (T | undefined)[]): T | null {
+  let best: T | null = null;
+  for (const s of statuses) {
+    if (!s || s.state !== 'RUNNING' || s.progress == null) continue;
+    if (best === null) {
+      best = s;
+      continue;
+    }
+    const sr = eta(s.remaining_time);
+    const br = eta(best.remaining_time);
+    if (sr < br || (sr === br && (s.progress ?? 0) > (best.progress ?? 0))) {
+      best = s;
+    }
+  }
+  return best;
+}
+
+// Draw a 32x32 progress ring in the current theme accent colour, return a PNG
+// data URL (or null if the browser has no 2d canvas, e.g. under jsdom — the
+// caller then falls back to updating the title only).
+function drawProgressFavicon(pct: number): string | null {
+  const canvas = document.createElement('canvas');
+  canvas.width = 32;
+  canvas.height = 32;
+  const ctx = canvas.getContext('2d');
+  if (!ctx) return null;
+
+  const accent =
+    getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() ||
+    FALLBACK_ACCENT;
+
+  const cx = 16;
+  const cy = 16;
+  const r = 13;
+  const frac = Math.max(0, Math.min(100, pct)) / 100;
+  const start = -Math.PI / 2; // 12 o'clock
+
+  ctx.lineWidth = 4;
+  // Track
+  ctx.beginPath();
+  ctx.arc(cx, cy, r, 0, Math.PI * 2);
+  ctx.strokeStyle = 'rgba(128,128,128,0.3)';
+  ctx.stroke();
+  // Progress arc, clockwise from the top
+  ctx.beginPath();
+  ctx.arc(cx, cy, r, start, start + frac * Math.PI * 2);
+  ctx.strokeStyle = accent;
+  ctx.lineCap = 'round';
+  ctx.stroke();
+
+  return canvas.toDataURL('image/png');
+}
+
+// Point the <link rel="icon"> tags at the ring (remembering originals), or
+// restore them when dataUrl is null. apple-touch-icon is a different rel token
+// so the `rel~="icon"` selector leaves it alone.
+function setFavicon(dataUrl: string | null, originals: Map<HTMLLinkElement, string>) {
+  const links = document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]');
+  links.forEach((link) => {
+    if (dataUrl) {
+      if (!originals.has(link)) originals.set(link, link.href);
+      link.href = dataUrl;
+    } else {
+      const orig = originals.get(link);
+      if (orig !== undefined) link.href = orig;
+    }
+  });
+  if (!dataUrl) originals.clear();
+}
+
+/**
+ * When the "progress in tab" preference is on, reflect the soonest-finishing
+ * print's percentage in document.title and draw a progress ring favicon in the
+ * theme accent colour. Stays fully inert until enabled, and hands the tab back
+ * to its defaults once disabled, idle, or unmounted.
+ * Mounted once, globally, inside WebSocketProvider.
+ */
+export function usePrintProgressTitle() {
+  const { progressInTitle, resolvedMode, darkAccent, lightAccent } = useTheme();
+  // Re-draw the ring when the active accent changes.
+  const accent = resolvedMode === 'dark' ? darkAccent : lightAccent;
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+    enabled: progressInTitle,
+  });
+
+  // No refetchInterval here on purpose. This hook is mounted globally, so a
+  // poll would add one request per printer every interval on every page — the
+  // Printers page already runs its own 30s fallback on this exact key. The
+  // WebSocket writes ['printerStatus', id] directly (useWebSocket), which keeps
+  // the tab live; a cosmetic title going stale during a WS outage is fine.
+  const statusQueries = useQueries({
+    queries: (progressInTitle ? printers ?? [] : []).map((p) => ({
+      queryKey: ['printerStatus', p.id],
+      queryFn: () => api.getPrinterStatus(p.id),
+    })),
+  });
+
+  const originalsRef = useRef<Map<HTMLLinkElement, string>>(new Map());
+  // The tab's own title, captured before we ever touch it, so restoring doesn't
+  // depend on a constant matching index.html.
+  const defaultTitleRef = useRef(document.title);
+  // Whether we currently own the tab title/favicon. Lets us stay inert while
+  // off (never touch the tab) yet still restore once if we ever took it over.
+  const ownsRef = useRef(false);
+
+  const active = progressInTitle ? pickActivePrint(statusQueries.map((q) => q.data)) : null;
+  const pct = active && active.progress != null ? Math.round(active.progress) : null;
+
+  useEffect(() => {
+    if (progressInTitle && pct != null) {
+      document.title = `${pct}% · ${defaultTitleRef.current}`;
+      setFavicon(drawProgressFavicon(pct), originalsRef.current);
+      ownsRef.current = true;
+    } else if (ownsRef.current) {
+      // Disabled or idle after having taken over — hand the tab back.
+      document.title = defaultTitleRef.current;
+      setFavicon(null, originalsRef.current);
+      ownsRef.current = false;
+    }
+    // else: never owned the tab → leave it entirely alone.
+  }, [progressInTitle, pct, accent]);
+
+  // Restore the tab to defaults on unmount, but only if we own it.
+  useEffect(() => {
+    const originals = originalsRef.current;
+    const owns = ownsRef;
+    const defaultTitle = defaultTitleRef.current;
+    return () => {
+      if (owns.current) {
+        document.title = defaultTitle;
+        setFavicon(null, originals);
+      }
+    };
+  }, []);
+}

+ 3 - 3
frontend/src/i18n/index.ts

@@ -14,8 +14,8 @@ import ptBR from './locales/pt-BR';
 import zhCN from './locales/zh-CN';
 import zhTW from './locales/zh-TW';
 import tr from './locales/tr';
-import uk from './locales/uk';
 import ru from './locales/ru';
+import uk from './locales/uk';
 
 const resources = {
   en: { translation: en },
@@ -29,8 +29,8 @@ const resources = {
   'zh-CN': { translation: zhCN },
   'zh-TW': { translation: zhTW },
   tr: { translation: tr },
-  uk: { translation: uk },
   ru: { translation: ru },
+  uk: { translation: uk },
 };
 
 const SUPPORTED_LNGS = ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'pt-BR', 'ru', 'tr', 'uk', 'zh-CN', 'zh-TW'];
@@ -111,6 +111,6 @@ export const availableLanguages = [
   { code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
   { code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文' },
   { code: 'tr', name: 'Turkish', nativeName: 'Türkçe' },
-  { code: 'uk', name: 'Ukrainian', nativeName: 'Українська' },
   { code: 'ru', name: 'Russian', nativeName: 'Русский' },
+  { code: 'uk', name: 'Ukrainian', nativeName: 'Українська' },
 ];

+ 6 - 0
frontend/src/i18n/locales/de.ts

@@ -2438,6 +2438,8 @@ export default {
     styleGlow: 'Leuchtend',
     styleVibrant: 'Lebendig',
     themeToggleHint: 'Zwischen Dunkel-, Hell- und Systemmodus mit dem Symbol in der Seitenleiste wechseln.',
+    progressInTitle: 'Druckfortschritt im Tab',
+    progressInTitleDescription: 'Zeigt den Prozentsatz des aktiven Drucks und einen Fortschrittsring im Browser-Tab an.',
     // Archive
     autoArchivePrints: 'Drucke automatisch archivieren',
     autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind',
@@ -3670,6 +3672,7 @@ export default {
     showModified: 'Änderungsdatum anzeigen',
     hideModified: 'Änderungsdatum ausblenden',
     lastModified: 'Zuletzt geändert',
+    lastActivity: 'Letzte Aktivität',
     resultsCount: '{{showing}} von {{total}} Dateien',
     selectAll: 'Alle auswählen',
     deselectAll: 'Auswahl aufheben',
@@ -5617,6 +5620,9 @@ export default {
     pushoverExpire: 'Notfall-Ablauf (s)',
     botToken: 'Bot-Token',
     chatId: 'Chat-ID',
+    telegramThreadId: 'Forum-Themen-ID',
+    telegramThreadIdHelp: 'Optional. Sendet in ein einzelnes Thema einer Forum-Gruppe — die letzte Zahl im Themen-Link (t.me/c/.../25). Leer lassen für das allgemeine Thema.',
+    telegramThreadIdInvalid: 'Die Forum-Themen-ID muss eine Zahl sein.',
     smtpServer: 'SMTP-Server',
     smtpPort: 'SMTP-Port',
     security: 'Sicherheit',

+ 6 - 0
frontend/src/i18n/locales/en.ts

@@ -2457,6 +2457,8 @@ export default {
     styleGlow: 'Glow',
     styleVibrant: 'Vibrant',
     themeToggleHint: 'Toggle between dark, light, and system mode using the icon in the sidebar.',
+    progressInTitle: 'Print progress in tab',
+    progressInTitleDescription: 'Show the active print\'s percentage and a progress ring in the browser tab.',
     // Archive
     autoArchivePrints: 'Auto-archive prints',
     autoArchiveDescription: 'Automatically save 3MF files when prints complete',
@@ -3699,6 +3701,7 @@ export default {
     showModified: 'Show modified dates',
     hideModified: 'Hide modified dates',
     lastModified: 'Last modified',
+    lastActivity: 'Last activity',
     resultsCount: '{{showing}} of {{total}} files',
     selectAll: 'Select All',
     deselectAll: 'Deselect All',
@@ -5661,6 +5664,9 @@ export default {
     pushoverExpire: 'Emergency Expire (s)',
     botToken: 'Bot Token',
     chatId: 'Chat ID',
+    telegramThreadId: 'Forum Topic ID',
+    telegramThreadIdHelp: 'Optional. Posts into a single topic of a forum group — the last number in the topic link (t.me/c/.../25). Leave empty for the General topic.',
+    telegramThreadIdInvalid: 'Forum Topic ID must be a number.',
     smtpServer: 'SMTP Server',
     smtpPort: 'SMTP Port',
     security: 'Security',

+ 6 - 0
frontend/src/i18n/locales/es.ts

@@ -2441,6 +2441,8 @@ export default {
     styleGlow: 'Resplandor',
     styleVibrant: 'Vibrante',
     themeToggleHint: 'Alterne entre modo oscuro, claro y sistema con el icono en la barra lateral.',
+    progressInTitle: 'Progreso en la pestaña',
+    progressInTitleDescription: 'Muestra el porcentaje de la impresión activa y un anillo de progreso en la pestaña del navegador.',
     // Archive
     autoArchivePrints: 'Archivar impresiones automáticamente',
     autoArchiveDescription: 'Guardar automáticamente los archivos 3MF cuando se completan las impresiones',
@@ -3673,6 +3675,7 @@ export default {
     showModified: 'Mostrar fechas de modificación',
     hideModified: 'Ocultar fechas de modificación',
     lastModified: 'Última modificación',
+    lastActivity: 'Última actividad',
     resultsCount: '{{showing}} de {{total}} archivos',
     selectAll: 'Seleccionar todo',
     deselectAll: 'Deseleccionar todo',
@@ -5626,6 +5629,9 @@ export default {
     pushoverExpire: 'Expiración de emergencia (s)',
     botToken: 'Token del bot',
     chatId: 'ID del chat',
+    telegramThreadId: 'ID del tema del foro',
+    telegramThreadIdHelp: 'Opcional. Envía a un único tema de un grupo de foro: el último número del enlace del tema (t.me/c/.../25). Déjalo vacío para el tema General.',
+    telegramThreadIdInvalid: 'El ID del tema del foro debe ser un número.',
     smtpServer: 'Servidor SMTP',
     smtpPort: 'Puerto SMTP',
     security: 'Seguridad',

+ 6 - 0
frontend/src/i18n/locales/fr.ts

@@ -2393,6 +2393,8 @@ export default {
     styleGlow: 'Lumineux',
     styleVibrant: 'Vif',
     themeToggleHint: 'Basculer entre le mode sombre, clair et système avec l\'icône dans la barre latérale.',
+    progressInTitle: 'Progression dans l\'onglet',
+    progressInTitleDescription: 'Affiche le pourcentage de l\'impression en cours et un anneau de progression dans l\'onglet du navigateur.',
     autoArchivePrints: 'Archiver automatiquement les impressions',
     autoArchiveDescription: 'Sauvegarder automatiquement les fichiers 3MF à la fin des impressions',
     saveThumbnailsDescription: 'Extraire et sauvegarder les images d\'aperçu des fichiers 3MF',
@@ -3659,6 +3661,7 @@ export default {
     showModified: 'Afficher les dates de modification',
     hideModified: 'Masquer les dates de modification',
     lastModified: 'Dernière modification',
+    lastActivity: 'Dernière activité',
     resultsCount: '{{showing}} sur {{total}} fichiers',
     selectAll: 'Tout sélectionner',
     deselectAll: 'Tout désélectionner',
@@ -5607,6 +5610,9 @@ export default {
     pushoverExpire: 'Expiration urgence (s)',
     botToken: 'Jeton du bot',
     chatId: 'ID du chat',
+    telegramThreadId: 'ID du sujet de forum',
+    telegramThreadIdHelp: 'Facultatif. Envoie dans un seul sujet d\'un groupe forum : le dernier nombre du lien du sujet (t.me/c/.../25). Laisser vide pour le sujet General.',
+    telegramThreadIdInvalid: 'L\'ID du sujet de forum doit être un nombre.',
     smtpServer: 'Serveur SMTP',
     smtpPort: 'Port SMTP',
     security: 'Sécurité',

+ 6 - 0
frontend/src/i18n/locales/it.ts

@@ -2392,6 +2392,8 @@ export default {
     styleGlow: 'Luminoso',
     styleVibrant: 'Vibrante',
     themeToggleHint: 'Passa tra modalità scura, chiara e sistema con l\'icona nella barra laterale.',
+    progressInTitle: 'Avanzamento nella scheda',
+    progressInTitleDescription: 'Mostra la percentuale della stampa attiva e un anello di avanzamento nella scheda del browser.',
     autoArchivePrints: 'Archiviazione automatica stampe',
     autoArchiveDescription: 'Salva automaticamente i file 3MF al completamento delle stampe',
     saveThumbnailsDescription: 'Estrai e salva le immagini di anteprima dai file 3MF',
@@ -3658,6 +3660,7 @@ export default {
     showModified: 'Mostra date di modifica',
     hideModified: 'Nascondi date di modifica',
     lastModified: 'Ultima modifica',
+    lastActivity: 'Ultima attività',
     resultsCount: '{{showing}} di {{total}} file',
     selectAll: 'Seleziona tutto',
     deselectAll: 'Deseleziona tutto',
@@ -5606,6 +5609,9 @@ export default {
     pushoverExpire: 'Scadenza emergenza (s)',
     botToken: 'Token del bot',
     chatId: 'ID chat',
+    telegramThreadId: 'ID argomento forum',
+    telegramThreadIdHelp: 'Opzionale. Invia in un singolo argomento di un gruppo forum: l\'ultimo numero nel link dell\'argomento (t.me/c/.../25). Lascia vuoto per l\'argomento Generale.',
+    telegramThreadIdInvalid: 'L\'ID argomento forum deve essere un numero.',
     smtpServer: 'Server SMTP',
     smtpPort: 'Porta SMTP',
     security: 'Sicurezza',

+ 6 - 0
frontend/src/i18n/locales/ja.ts

@@ -2437,6 +2437,8 @@ export default {
     styleGlow: 'グロー',
     styleVibrant: 'ビビッド',
     themeToggleHint: 'サイドバーのアイコンでダーク、ライト、システムモードを切り替えます。',
+    progressInTitle: 'タブに印刷の進捗を表示',
+    progressInTitleDescription: 'ブラウザのタブに進行中の印刷の進捗率と進捗リングを表示します。',
     // Archive
     autoArchivePrints: '印刷を自動アーカイブ',
     autoArchiveDescription: '印刷完了時に3MFファイルを自動保存',
@@ -3670,6 +3672,7 @@ export default {
     showModified: '更新日時を表示',
     hideModified: '更新日時を非表示',
     lastModified: '最終更新',
+    lastActivity: '最終アクティビティ',
     resultsCount: '{{total}}件中{{showing}}件',
     selectAll: 'すべて選択',
     deselectAll: 'すべて選択解除',
@@ -5618,6 +5621,9 @@ export default {
     pushoverExpire: '緊急有効期限 (秒)',
     botToken: 'ボットトークン',
     chatId: 'チャットID',
+    telegramThreadId: 'フォーラムトピック ID',
+    telegramThreadIdHelp: '任意。フォーラムグループ内の特定のトピックに送信します。トピックリンクの末尾の数字です (t.me/c/.../25)。空欄の場合は General トピックに送信されます。',
+    telegramThreadIdInvalid: 'フォーラムトピック ID は数値で入力してください。',
     smtpServer: 'SMTPサーバー',
     smtpPort: 'SMTPポート',
     security: 'セキュリティ',

+ 6 - 0
frontend/src/i18n/locales/ko.ts

@@ -2308,6 +2308,8 @@ export default {
     styleGlow: '글로우',
     styleVibrant: '비브런트',
     themeToggleHint: '사이드바의 태양/달 아이콘으로 다크 모드와 라이트 모드를 전환하세요.',
+    progressInTitle: '탭에 인쇄 진행률 표시',
+    progressInTitleDescription: '브라우저 탭에 진행 중인 인쇄의 백분율과 진행 링을 표시합니다.',
     autoArchivePrints: '인쇄 자동 아카이브',
     autoArchiveDescription: '인쇄 완료 시 3MF 파일 자동 저장',
     saveThumbnailsDescription: '3MF 파일에서 미리보기 이미지 추출 및 저장',
@@ -3482,6 +3484,7 @@ export default {
     showModified: '수정 날짜 표시',
     hideModified: '수정 날짜 숨기기',
     lastModified: '마지막 수정',
+    lastActivity: '마지막 활동',
     resultsCount: '전체 {{total}}개 중 {{showing}}개',
     selectAll: '모두 선택',
     deselectAll: '모두 선택 해제',
@@ -5330,6 +5333,9 @@ export default {
     pushoverExpire: '긴급 만료 (초)',
     botToken: '봇 토큰',
     chatId: '채팅 ID',
+    telegramThreadId: '포럼 주제 ID',
+    telegramThreadIdHelp: '선택 사항. 포럼 그룹의 특정 주제로 전송합니다. 주제 링크의 마지막 숫자입니다 (t.me/c/.../25). 비워 두면 General 주제로 전송됩니다.',
+    telegramThreadIdInvalid: '포럼 주제 ID는 숫자여야 합니다.',
     smtpServer: 'SMTP 서버',
     smtpPort: 'SMTP 포트',
     security: '보안',

+ 6 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2392,6 +2392,8 @@ export default {
     styleGlow: 'Brilhante',
     styleVibrant: 'Vibrante',
     themeToggleHint: 'Alternar entre modo escuro, claro e sistema usando o ícone na barra lateral.',
+    progressInTitle: 'Progresso na aba',
+    progressInTitleDescription: 'Mostra a porcentagem da impressão ativa e um anel de progresso na aba do navegador.',
     autoArchivePrints: 'Arquivar impressões automaticamente',
     autoArchiveDescription: 'Salvar automaticamente arquivos 3MF quando impressões forem concluídas',
     saveThumbnailsDescription: 'Extrair e salvar imagens de pré-visualização dos arquivos 3MF',
@@ -3658,6 +3660,7 @@ export default {
     showModified: 'Mostrar datas de modificação',
     hideModified: 'Ocultar datas de modificação',
     lastModified: 'Última modificação',
+    lastActivity: 'Última atividade',
     resultsCount: '{{showing}} de {{total}} arquivos',
     selectAll: 'Selecionar tudo',
     deselectAll: 'Desmarcar tudo',
@@ -5606,6 +5609,9 @@ export default {
     pushoverExpire: 'Expiração de emergência (s)',
     botToken: 'Token do Bot',
     chatId: 'ID do Chat',
+    telegramThreadId: 'ID do tópico do fórum',
+    telegramThreadIdHelp: 'Opcional. Envia para um único tópico de um grupo de fórum: o último número no link do tópico (t.me/c/.../25). Deixe vazio para o tópico Geral.',
+    telegramThreadIdInvalid: 'O ID do tópico do fórum deve ser um número.',
     smtpServer: 'Servidor SMTP',
     smtpPort: 'Porta SMTP',
     security: 'Segurança',

+ 6 - 0
frontend/src/i18n/locales/ru.ts

@@ -2309,6 +2309,8 @@ export default {
     styleGlow: "Свечение",
     styleVibrant: "Насыщенный",
     themeToggleHint: "Переключайте тёмную, светлую и системную тему значком в боковой панели.",
+    progressInTitle: "Прогресс во вкладке",
+    progressInTitleDescription: "Показывает процент текущей печати и кольцо прогресса во вкладке браузера.",
     autoArchivePrints: "Автоматически архивировать печать",
     autoArchiveDescription: "Автоматически сохранять 3MF после завершения печати",
     saveThumbnailsDescription: "Извлекать и сохранять изображения предпросмотра из 3MF",
@@ -3474,6 +3476,7 @@ export default {
     showModified: "Показать даты изменения",
     hideModified: "Скрыть даты изменения",
     lastModified: "Изменено",
+    lastActivity: "Последняя активность",
     resultsCount: "Показано {{showing}} из {{total}} файлов",
     selectAll: "Выбрать всё",
     deselectAll: "Снять выделение",
@@ -5317,6 +5320,9 @@ export default {
     pushoverExpire: "Срок действия экстренного уведомления (с)",
     botToken: "Токен бота",
     chatId: "ID чата",
+    telegramThreadId: "ID темы форума",
+    telegramThreadIdHelp: "Необязательно. Отправляет в конкретную тему форум-группы — последнее число в ссылке на тему (t.me/c/.../25). Оставьте пустым для темы General.",
+    telegramThreadIdInvalid: "ID темы форума должен быть числом.",
     smtpServer: "SMTP-сервер",
     smtpPort: "Порт SMTP",
     security: "Защита соединения",

+ 6 - 0
frontend/src/i18n/locales/tr.ts

@@ -2442,6 +2442,8 @@ export default {
     styleGlow: 'Parıltı',
     styleVibrant: 'Canlı',
     themeToggleHint: 'Kenar çubuğundaki güneş/ay simgesini kullanarak koyu ve açık mod arasında geçiş yapın.',
+    progressInTitle: 'Sekmede baskı ilerlemesi',
+    progressInTitleDescription: 'Tarayıcı sekmesinde etkin baskının yüzdesini ve bir ilerleme halkası gösterir.',
     // Arşiv
     autoArchivePrints: 'Baskıları otomatik arşivle',
     autoArchiveDescription: 'Baskılar tamamlandığında 3MF dosyalarını otomatik olarak kaydet',
@@ -3666,6 +3668,7 @@ export default {
     showModified: 'Değiştirme tarihlerini göster',
     hideModified: 'Değiştirme tarihlerini gizle',
     lastModified: 'Son değiştirme',
+    lastActivity: 'Son etkinlik',
     resultsCount: '{{total}} dosyadan {{showing}} tanesi',
     selectAll: 'Tümünü Seç',
     deselectAll: 'Seçimi Kaldır',
@@ -5566,6 +5569,9 @@ export default {
     pushoverExpire: 'Acil sona erme (sn)',
     botToken: 'Bot Belirteci',
     chatId: 'Sohbet ID',
+    telegramThreadId: 'Forum Konu Kimliği',
+    telegramThreadIdHelp: 'İsteğe bağlı. Forum grubundaki tek bir konuya gönderir: konu bağlantısındaki son sayı (t.me/c/.../25). Genel konu için boş bırakın.',
+    telegramThreadIdInvalid: 'Forum konu kimliği bir sayı olmalıdır.',
     smtpServer: 'SMTP Sunucusu',
     smtpPort: 'SMTP Portu',
     security: 'Güvenlik',

+ 6 - 0
frontend/src/i18n/locales/uk.ts

@@ -2457,6 +2457,8 @@ export default {
     styleGlow: "Світіння",
     styleVibrant: "Яскравий",
     themeToggleHint: "Перемикайтеся між темним, світлим і системним режимами за допомогою значка на бічній панелі.",
+    progressInTitle: "Прогрес у вкладці",
+    progressInTitleDescription: "Показує відсоток активного друку та кільце прогресу на вкладці браузера.",
     // Archive
     autoArchivePrints: "Автоматично архівувати друки",
     autoArchiveDescription: "Автоматично зберігати файли 3MF після завершення друку",
@@ -3699,6 +3701,7 @@ export default {
     showModified: "Показати змінені дати",
     hideModified: "Приховати змінені дати",
     lastModified: "Востаннє змінено",
+    lastActivity: "Остання активність",
     resultsCount: "{{showing}} з {{total}} файлів",
     selectAll: "Вибрати усі",
     deselectAll: "Зняти вибір із усіх",
@@ -5661,6 +5664,9 @@ export default {
     pushoverExpire: "Термін дії екстреного сповіщення (с)",
     botToken: "Токен бота",
     chatId: "Ідентифікатор чату",
+    telegramThreadId: "ID теми форуму",
+    telegramThreadIdHelp: "Необов'язково. Надсилає в конкретну тему форум-групи — останнє число у посиланні на тему (t.me/c/.../25). Залиште порожнім для теми General.",
+    telegramThreadIdInvalid: "ID теми форуму має бути числом.",
     smtpServer: "Сервер SMTP",
     smtpPort: "Порт SMTP",
     security: "Безпека",

+ 6 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2437,6 +2437,8 @@ export default {
     styleGlow: '发光',
     styleVibrant: '鲜艳',
     themeToggleHint: '使用侧边栏中的图标在深色、浅色和系统模式之间切换。',
+    progressInTitle: '在标签页显示打印进度',
+    progressInTitleDescription: '在浏览器标签页中显示当前打印的百分比和进度环。',
     autoArchivePrints: '自动归档打印',
     autoArchiveDescription: '打印完成时自动保存3MF文件',
     saveThumbnailsDescription: '从3MF文件中提取并保存预览图像',
@@ -3658,6 +3660,7 @@ export default {
     showModified: '显示修改日期',
     hideModified: '隐藏修改日期',
     lastModified: '最后修改',
+    lastActivity: '最近活动',
     resultsCount: '{{showing}} / {{total}} 个文件',
     selectAll: '全选',
     deselectAll: '取消全选',
@@ -5606,6 +5609,9 @@ export default {
     pushoverExpire: '紧急过期 (秒)',
     botToken: '机器人令牌',
     chatId: '聊天 ID',
+    telegramThreadId: '论坛话题 ID',
+    telegramThreadIdHelp: '可选。发送到论坛群组中的指定话题,即话题链接末尾的数字 (t.me/c/.../25)。留空则发送到常规话题。',
+    telegramThreadIdInvalid: '论坛话题 ID 必须是数字。',
     smtpServer: 'SMTP 服务器',
     smtpPort: 'SMTP 端口',
     security: '安全',

+ 6 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2437,6 +2437,8 @@ export default {
     styleGlow: '發光',
     styleVibrant: '鮮豔',
     themeToggleHint: '使用側邊欄中的圖示在深色、淺色和系統模式之間切換。',
+    progressInTitle: '在分頁顯示列印進度',
+    progressInTitleDescription: '在瀏覽器分頁中顯示目前列印的百分比和進度環。',
     autoArchivePrints: '自動歸檔列印',
     autoArchiveDescription: '列印完成時自動儲存3MF檔案',
     saveThumbnailsDescription: '從3MF檔案中提取並儲存預覽影像',
@@ -3658,6 +3660,7 @@ export default {
     showModified: '顯示修改日期',
     hideModified: '隱藏修改日期',
     lastModified: '最後修改',
+    lastActivity: '最近活動',
     resultsCount: '{{showing}} / {{total}} 個檔案',
     selectAll: '全選',
     deselectAll: '取消全選',
@@ -5606,6 +5609,9 @@ export default {
     pushoverExpire: '緊急逾時 (秒)',
     botToken: '機器人權杖',
     chatId: '聊天 ID',
+    telegramThreadId: '論壇主題 ID',
+    telegramThreadIdHelp: '選填。傳送到論壇群組中的指定主題,即主題連結結尾的數字 (t.me/c/.../25)。留空則傳送到一般主題。',
+    telegramThreadIdInvalid: '論壇主題 ID 必須是數字。',
     smtpServer: 'SMTP 伺服器',
     smtpPort: 'SMTP 連接埠',
     security: '安全',

+ 18 - 2
frontend/src/pages/FileManagerPage.tsx

@@ -558,11 +558,12 @@ interface FolderTreeItemProps {
   depth?: number;
   wrapNames?: boolean;
   defaultExpanded?: boolean;
+  showModified?: boolean;
   hasPermission: (permission: Permission) => boolean;
   t: TFunction;
 }
 
-function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, hasPermission, t }: FolderTreeItemProps) {
+function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, showModified = false, hasPermission, t }: FolderTreeItemProps) {
   const [expanded, setExpanded] = useState(defaultExpanded);
   const [showActions, setShowActions] = useState(false);
   const hasChildren = folder.children.length > 0;
@@ -609,7 +610,20 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
         ) : (
           <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
         )}
-        <span className={`text-sm flex-1 min-w-0 ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
+        <div className="flex-1 min-w-0">
+          <span className={`block text-sm ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
+          {/* #2680 follow-up: the same toolbar toggle that shows dates on file
+              cards also shows them here. This is `latest_activity_at` — the
+              newest timestamp among the folder itself, its files and its
+              subfolders (the value "sort by recent activity" orders on) — not
+              the folder's own on-disk mtime, hence the distinct label. */}
+          {showModified && folder.latest_activity_at && (
+            <span className="mt-0.5 flex items-center gap-1 text-xs text-bambu-gray" title={t('fileManager.lastActivity')}>
+              <CalendarClock className="w-3 h-3 flex-shrink-0" />
+              <span className="truncate">{formatDate(folder.latest_activity_at)}</span>
+            </span>
+          )}
+        </div>
         {/* Link indicator - clickable to change link */}
         {isLinked && (
           <button
@@ -709,6 +723,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               depth={depth + 1}
               wrapNames={wrapNames}
               defaultExpanded={defaultExpanded}
+              showModified={showModified}
               hasPermission={hasPermission}
               t={t}
             />
@@ -1957,6 +1972,7 @@ export function FileManagerPage() {
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
                 wrapNames={wrapFolderNames}
                 defaultExpanded={!collapseFoldersByDefault}
+                showModified={showModified}
                 hasPermission={hasPermission}
                 t={t}
               />

+ 19 - 0
frontend/src/pages/SettingsPage.tsx

@@ -172,6 +172,7 @@ export function SettingsPage() {
     setMode,
     setDarkStyle, setDarkBackground, setDarkAccent,
     setLightStyle, setLightBackground, setLightAccent,
+    progressInTitle, setProgressInTitle,
   } = useTheme();
   const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
   // Transient typed strings for the per-filament humidity threshold inputs
@@ -1811,6 +1812,24 @@ export function SettingsPage() {
               <p className="text-xs text-bambu-gray">
                 {t('settings.themeToggleHint')}
               </p>
+
+              <div className="flex items-center justify-between pt-2 border-t border-bambu-dark-tertiary">
+                <div>
+                  <p className="text-white">{t('settings.progressInTitle')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {t('settings.progressInTitleDescription')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={progressInTitle}
+                    onChange={(e) => { setProgressInTitle(e.target.checked); showToast(t('settings.toast.settingsSaved'), 'success'); }}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
             </CardContent>
           </Card>
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-apAuCUp0.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-xPJs-OAQ.js"></script>
+    <script type="module" crossorigin src="/assets/index-apAuCUp0.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов