Преглед изворни кода

Merge pull request #2688 from maziggy/1.2.5.1

v1.2.5.1
MartinNYHC пре 1 месец
родитељ
комит
775e9d7649
64 измењених фајлова са 2232 додато и 187 уклоњено
  1. 7 0
      CHANGELOG.md
  2. 55 7
      backend/app/api/routes/camera.py
  3. 94 15
      backend/app/api/routes/library.py
  4. 1 1
      backend/app/core/config.py
  5. 15 0
      backend/app/core/database.py
  6. 16 0
      backend/app/models/library.py
  7. 4 0
      backend/app/schemas/library.py
  8. 39 9
      backend/app/services/bambu_mqtt.py
  9. 46 8
      backend/app/services/external_camera.py
  10. 13 6
      backend/app/services/git_providers/gitea.py
  11. 6 2
      backend/app/services/ldap_service.py
  12. 18 0
      backend/app/services/log_reader.py
  13. 38 11
      backend/app/services/print_scheduler.py
  14. 7 0
      backend/app/services/printer_manager.py
  15. 60 22
      backend/app/services/slicer_api.py
  16. 9 0
      backend/app/services/virtual_printer/manager.py
  17. 138 0
      backend/tests/integration/test_external_folders_api.py
  18. 7 7
      backend/tests/integration/test_library_slice_api.py
  19. 189 4
      backend/tests/unit/services/test_bambu_mqtt.py
  20. 33 0
      backend/tests/unit/services/test_printer_manager.py
  21. 101 1
      backend/tests/unit/services/test_slicer_api.py
  22. 67 2
      backend/tests/unit/services/test_virtual_printer.py
  23. 199 0
      backend/tests/unit/test_camera_usb_stream_cleanup.py
  24. 30 0
      backend/tests/unit/test_git_providers.py
  25. 150 0
      backend/tests/unit/test_scheduler_force_color_ams_fallback.py
  26. 49 0
      backend/tests/unit/test_support_helpers.py
  27. 35 32
      backend/tests/unit/test_vp_mqtt_bridge.py
  28. 43 0
      frontend/src/__tests__/components/FilamentMapping.test.tsx
  29. 28 0
      frontend/src/__tests__/components/ModelViewerModal.test.tsx
  30. 56 0
      frontend/src/__tests__/components/spool-form/PAProfileSectionNozzle.test.tsx
  31. 32 0
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  32. 106 0
      frontend/src/__tests__/pages/QueuePage.test.tsx
  33. 54 0
      frontend/src/__tests__/utils/installedNozzleDiameters.test.ts
  34. 41 1
      frontend/src/__tests__/utils/printer.test.ts
  35. 4 1
      frontend/src/api/client.ts
  36. 2 2
      frontend/src/components/ModelViewerModal.tsx
  37. 6 3
      frontend/src/components/PrintModal/FilamentMapping.tsx
  38. 5 2
      frontend/src/components/PrintModal/PrinterSelector.tsx
  39. 3 2
      frontend/src/components/PrinterQueueWidget.tsx
  40. 4 16
      frontend/src/components/SpoolFormModal.tsx
  41. 5 0
      frontend/src/components/spool-form/PAProfileSection.tsx
  42. 42 1
      frontend/src/components/spool-form/utils.ts
  43. 5 0
      frontend/src/i18n/locales/de.ts
  44. 5 0
      frontend/src/i18n/locales/en.ts
  45. 5 0
      frontend/src/i18n/locales/es.ts
  46. 5 0
      frontend/src/i18n/locales/fr.ts
  47. 5 0
      frontend/src/i18n/locales/it.ts
  48. 5 0
      frontend/src/i18n/locales/ja.ts
  49. 5 0
      frontend/src/i18n/locales/ko.ts
  50. 5 0
      frontend/src/i18n/locales/pt-BR.ts
  51. 5 0
      frontend/src/i18n/locales/ru.ts
  52. 5 0
      frontend/src/i18n/locales/tr.ts
  53. 5 0
      frontend/src/i18n/locales/zh-CN.ts
  54. 5 0
      frontend/src/i18n/locales/zh-TW.ts
  55. 44 3
      frontend/src/pages/FileManagerPage.tsx
  56. 27 2
      frontend/src/pages/PrintersPage.tsx
  57. 193 6
      frontend/src/pages/QueuePage.tsx
  58. 4 15
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
  59. 26 0
      frontend/src/utils/amsHelpers.ts
  60. 18 3
      frontend/src/utils/printer.ts
  61. 1 0
      static/assets/index-Di24iyOw.css
  62. 0 0
      static/assets/index-FqCjQymn.js
  63. 0 1
      static/assets/index-kl51qImb.css
  64. 2 2
      static/index.html

Разлика између датотеке није приказан због своје велике величине
+ 7 - 0
CHANGELOG.md


+ 55 - 7
backend/app/api/routes/camera.py

@@ -666,6 +666,7 @@ async def camera_stream(
     # Check for external camera first
     # Check for external camera first
     if printer.external_camera_enabled and printer.external_camera_url:
     if printer.external_camera_enabled and printer.external_camera_url:
         import time
         import time
+        import uuid
 
 
         from backend.app.services.external_camera import generate_mjpeg_stream
         from backend.app.services.external_camera import generate_mjpeg_stream
 
 
@@ -675,21 +676,60 @@ async def camera_stream(
             "Using external camera (%s) for printer %s at %s fps", printer.external_camera_type, printer_id, fps
             "Using external camera (%s) for printer %s at %s fps", printer.external_camera_type, printer_id, fps
         )
         )
 
 
+        # Register the stream into the SAME registries the RTSP/chamber paths use
+        # (#2675) so `/camera/stop` and cleanup_orphaned_streams can find and kill
+        # a leaked ffmpeg holding a USB device open. Before this, external streams
+        # only tracked _active_external_streams and were structurally invisible to
+        # both the stop endpoint and the janitor. The stream_id keeps the
+        # `{printer_id}-` prefix both scanners key on, plus a unique suffix so two
+        # concurrent viewers of one printer don't clobber each other's entry.
+        stream_id = f"{printer_id}-ext-{uuid.uuid4().hex[:8]}"
+        stop_event = asyncio.Event()
+        _disconnect_events[stream_id] = stop_event
         # Track stream start
         # Track stream start
         _stream_start_times[printer_id] = time.time()
         _stream_start_times[printer_id] = time.time()
         _active_external_streams.add(printer_id)
         _active_external_streams.add(printer_id)
 
 
+        # Mutable holder so the wrapper's finally can unregister whatever process
+        # is currently registered (the RTSP path may respawn across reconnects).
+        current_proc: dict[str, asyncio.subprocess.Process] = {}
+
+        def _register_external_process(proc: asyncio.subprocess.Process) -> None:
+            prev = current_proc.get("proc")
+            if prev is not None and prev.pid != proc.pid:
+                _spawned_ffmpeg_pids.pop(prev.pid, None)
+            current_proc["proc"] = proc
+            _active_streams[stream_id] = proc
+            _spawned_ffmpeg_pids[proc.pid] = time.time()
+            _stream_last_frame_times[stream_id] = time.time()
+
         async def external_stream_wrapper():
         async def external_stream_wrapper():
             """Wrap external stream to track start/stop and update frame times."""
             """Wrap external stream to track start/stop and update frame times."""
             try:
             try:
                 async for frame in generate_mjpeg_stream(
                 async for frame in generate_mjpeg_stream(
-                    printer.external_camera_url, printer.external_camera_type, fps
+                    printer.external_camera_url,
+                    printer.external_camera_type,
+                    fps,
+                    on_process=_register_external_process,
+                    stop_event=stop_event,
                 ):
                 ):
                     # generate_mjpeg_stream already handles rate limiting;
                     # generate_mjpeg_stream already handles rate limiting;
-                    # just track frame times for stall detection
-                    _last_frame_times[printer_id] = time.time()
+                    # track frame times (per-printer + per-stream) for stall detection
+                    now = time.time()
+                    _last_frame_times[printer_id] = now
+                    _stream_last_frame_times[stream_id] = now
                     yield frame
                     yield frame
             finally:
             finally:
+                # Best-effort unregister. If an abrupt disconnect skips this
+                # finally, the registry entries persist — which is exactly what
+                # lets the stop endpoint / janitor reap the leaked process.
+                stop_event.set()
+                proc = current_proc.get("proc")
+                if proc is not None:
+                    _spawned_ffmpeg_pids.pop(proc.pid, None)
+                _active_streams.pop(stream_id, None)
+                _disconnect_events.pop(stream_id, None)
+                _stream_last_frame_times.pop(stream_id, None)
                 _active_external_streams.discard(printer_id)
                 _active_external_streams.discard(printer_id)
                 logger.info("External camera stream ended for printer %s", printer_id)
                 logger.info("External camera stream ended for printer %s", printer_id)
 
 
@@ -1529,9 +1569,14 @@ async def delete_reference(
 
 
 
 
 def _scan_bambu_ffmpeg_pids() -> list[int]:
 def _scan_bambu_ffmpeg_pids() -> list[int]:
-    """Scan /proc for ffmpeg processes with Bambu RTSP URLs.
+    """Scan /proc for ffmpeg processes that are ours.
+
+    Two shapes are matched, both unambiguously Bambuddy's:
+    - Bambu RTSP: no other software connects to ``rtsp(s)://bblp:``.
+    - External USB (V4L2): an ffmpeg spawned with ``-f v4l2`` is our USB camera
+      stream (#2675). Only orphans are killed — the caller excludes PIDs still in
+      ``_active_streams``, so a live USB stream (now registered there) is spared.
 
 
-    These are definitely ours — no other software connects to rtsp(s)://bblp:.
     This catches orphans that survive app restarts and are not in any tracking dict.
     This catches orphans that survive app restarts and are not in any tracking dict.
     """
     """
     import os
     import os
@@ -1544,8 +1589,11 @@ def _scan_bambu_ffmpeg_pids() -> list[int]:
             try:
             try:
                 with open(f"/proc/{entry}/cmdline", "rb") as f:
                 with open(f"/proc/{entry}/cmdline", "rb") as f:
                     cmdline = f.read()
                     cmdline = f.read()
-                # Match both rtsp:// (via TLS proxy) and rtsps:// (direct)
-                if b"ffmpeg" in cmdline and (b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline):
+                if b"ffmpeg" not in cmdline:
+                    continue
+                # Match both rtsp:// (via TLS proxy) and rtsps:// (direct), plus
+                # the `-f v4l2` input flag our USB camera command always carries.
+                if b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline or b"v4l2" in cmdline:
                     pids.append(int(entry))
                     pids.append(int(entry))
             except (OSError, PermissionError, ValueError):
             except (OSError, PermissionError, ValueError):
                 continue
                 continue

+ 94 - 15
backend/app/api/routes/library.py

@@ -751,24 +751,34 @@ async def list_folders(
     )
     )
     file_counts = dict(file_counts_result.all())
     file_counts = dict(file_counts_result.all())
 
 
-    # Latest immediate-child file activity per folder (#1770). Sibling of the
-    # file_counts subquery — same WHERE clause, MAX(updated_at) instead of
-    # COUNT(id). Subfolder descent is not aggregated here; the frontend's
-    # "sort by recent activity" mode is satisfied by immediate-parent bubble.
+    # Latest immediate-child file activity per folder (#1770/#2680). Real on-disk
+    # mtime when we have it (external scans populate ``fs_modified_at``), else the
+    # DB ``updated_at`` — COALESCE so external rows scanned before this field
+    # existed, and internal uploads, still contribute a signal. This is the
+    # per-folder *leaf* value; subtree descent is aggregated recursively below.
     latest_file_activity_result = await db.execute(
     latest_file_activity_result = await db.execute(
-        select(LibraryFile.folder_id, func.max(LibraryFile.updated_at))
+        select(
+            LibraryFile.folder_id,
+            func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
+        )
         .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None))
         .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None))
         .group_by(LibraryFile.folder_id)
         .group_by(LibraryFile.folder_id)
     )
     )
     latest_file_activity = dict(latest_file_activity_result.all())
     latest_file_activity = dict(latest_file_activity_result.all())
 
 
-    # Build tree structure
+    # Build tree structure. Each folder's initial ``latest_activity_at`` is its own
+    # leaf activity: the newer of its real directory mtime (fallback updated_at)
+    # and its immediate files' mtime. The recursive bubble below then rolls each
+    # subtree's newest descendant up to its ancestors (#2680 — sorting must match
+    # ``ls -t`` recursively, so a freshly-added deep file lifts every parent).
     folder_map = {}
     folder_map = {}
     root_folders = []
     root_folders = []
 
 
     for folder, project_name, archive_name in rows:
     for folder, project_name, archive_name in rows:
+        own_activity = folder.fs_modified_at or folder.updated_at
         latest_file = latest_file_activity.get(folder.id)
         latest_file = latest_file_activity.get(folder.id)
-        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
+        if latest_file is not None and latest_file > own_activity:
+            own_activity = latest_file
         folder_item = FolderTreeItem(
         folder_item = FolderTreeItem(
             id=folder.id,
             id=folder.id,
             name=folder.name,
             name=folder.name,
@@ -781,7 +791,7 @@ async def list_folders(
             external_path=folder.external_path,
             external_path=folder.external_path,
             external_readonly=folder.external_readonly,
             external_readonly=folder.external_readonly,
             file_count=file_counts.get(folder.id, 0),
             file_count=file_counts.get(folder.id, 0),
-            latest_activity_at=latest_activity_at,
+            latest_activity_at=own_activity,
             children=[],
             children=[],
         )
         )
         folder_map[folder.id] = folder_item
         folder_map[folder.id] = folder_item
@@ -794,6 +804,28 @@ async def list_folders(
         elif folder.parent_id in folder_map:
         elif folder.parent_id in folder_map:
             folder_map[folder.parent_id].children.append(folder_item)
             folder_map[folder.parent_id].children.append(folder_item)
 
 
+    # Recursive newest-descendant bubble (#2680). Post-order: a folder's activity
+    # becomes the max of its own leaf activity and every descendant's, so sorting
+    # the tree by ``latest_activity_at`` surfaces the branch with the most recent
+    # activity anywhere inside it. Iterative stack keeps deep external mounts off
+    # Python's recursion limit.
+    def _bubble(root: FolderTreeItem) -> None:
+        order: list[FolderTreeItem] = []
+        stack = [root]
+        while stack:
+            node = stack.pop()
+            order.append(node)
+            stack.extend(node.children)
+        for node in reversed(order):  # deepest first
+            for child in node.children:
+                if child.latest_activity_at is not None and (
+                    node.latest_activity_at is None or child.latest_activity_at > node.latest_activity_at
+                ):
+                    node.latest_activity_at = child.latest_activity_at
+
+    for root in root_folders:
+        _bubble(root)
+
     return root_folders
     return root_folders
 
 
 
 
@@ -819,11 +851,12 @@ async def get_folders_by_project(
 
 
     folders = []
     folders = []
     for folder, project_name in rows:
     for folder, project_name in rows:
-        # Get file count + latest file activity (#1770) in one trip
+        # Get file count + latest file activity (#1770/#2680) in one trip. Prefer
+        # the real on-disk mtime (external scans), fall back to the DB updated_at.
         agg_result = await db.execute(
         agg_result = await db.execute(
             select(
             select(
                 func.count(LibraryFile.id),
                 func.count(LibraryFile.id),
-                func.max(LibraryFile.updated_at),
+                func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
             ).where(
             ).where(
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.deleted_at.is_(None),
                 LibraryFile.deleted_at.is_(None),
@@ -831,7 +864,8 @@ async def get_folders_by_project(
         )
         )
         file_count, latest_file = agg_result.one()
         file_count, latest_file = agg_result.one()
         file_count = file_count or 0
         file_count = file_count or 0
-        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
+        own_activity = folder.fs_modified_at or folder.updated_at
+        latest_activity_at = max(own_activity, latest_file) if latest_file is not None else own_activity
 
 
         folders.append(
         folders.append(
             FolderResponse(
             FolderResponse(
@@ -878,11 +912,12 @@ async def get_folders_by_archive(
 
 
     folders = []
     folders = []
     for folder, archive_name in rows:
     for folder, archive_name in rows:
-        # Get file count + latest file activity (#1770) in one trip
+        # Get file count + latest file activity (#1770/#2680) in one trip. Prefer
+        # the real on-disk mtime (external scans), fall back to the DB updated_at.
         agg_result = await db.execute(
         agg_result = await db.execute(
             select(
             select(
                 func.count(LibraryFile.id),
                 func.count(LibraryFile.id),
-                func.max(LibraryFile.updated_at),
+                func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
             ).where(
             ).where(
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.deleted_at.is_(None),
                 LibraryFile.deleted_at.is_(None),
@@ -890,7 +925,8 @@ async def get_folders_by_archive(
         )
         )
         file_count, latest_file = agg_result.one()
         file_count, latest_file = agg_result.one()
         file_count = file_count or 0
         file_count = file_count or 0
-        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
+        own_activity = folder.fs_modified_at or folder.updated_at
+        latest_activity_at = max(own_activity, latest_file) if latest_file is not None else own_activity
 
 
         folders.append(
         folders.append(
             FolderResponse(
             FolderResponse(
@@ -1482,6 +1518,16 @@ async def create_external_folder(
     )
     )
 
 
 
 
+def _mtime_to_datetime(mtime: float) -> datetime:
+    """Convert an ``os.stat().st_mtime`` epoch value to a naive-UTC datetime (#2680).
+
+    Naive UTC to match the other library timestamp columns (``created_at`` /
+    ``updated_at`` are naive ``func.now()``), so activity comparisons never mix
+    naive and aware values on either dialect.
+    """
+    return datetime.fromtimestamp(mtime, tz=timezone.utc).replace(tzinfo=None)
+
+
 @router.post("/folders/{folder_id}/scan")
 @router.post("/folders/{folder_id}/scan")
 async def scan_external_folder(
 async def scan_external_folder(
     folder_id: int,
     folder_id: int,
@@ -1557,6 +1603,8 @@ async def scan_external_folder(
     removed = 0
     removed = 0
     found_paths: set[str] = set()
     found_paths: set[str] = set()
     seen_rel_dirs: set[str] = set()
     seen_rel_dirs: set[str] = set()
+    # Real on-disk mtime per visited folder id (#2680), applied after the walk.
+    folder_mtimes: dict[int, datetime] = {}
 
 
     for dirpath, dirnames, filenames in os.walk(ext_path):
     for dirpath, dirnames, filenames in os.walk(ext_path):
         # Filter hidden directories unless configured
         # Filter hidden directories unless configured
@@ -1606,6 +1654,15 @@ async def scan_external_folder(
 
 
         target_folder_id = folder_cache.get(rel_dir, folder_id)
         target_folder_id = folder_cache.get(rel_dir, folder_id)
 
 
+        # Record this directory's own mtime (#2680). os.walk visits every
+        # directory once, so this covers the root external folder and every
+        # subfolder (existing or just created). Applied to the folder rows
+        # after the walk completes.
+        try:
+            folder_mtimes[target_folder_id] = _mtime_to_datetime(os.stat(dirpath).st_mtime)
+        except OSError:
+            pass
+
         for filename in filenames:
         for filename in filenames:
             # Skip hidden files unless configured
             # Skip hidden files unless configured
             if not folder.external_show_hidden and filename.startswith("."):
             if not folder.external_show_hidden and filename.startswith("."):
@@ -1634,7 +1691,17 @@ async def scan_external_folder(
             found_paths.add(file_path_str)
             found_paths.add(file_path_str)
 
 
             if file_path_str in existing_files:
             if file_path_str in existing_files:
-                continue  # Already tracked
+                # Already tracked — refresh its on-disk mtime (#2680) so a file
+                # edited/replaced over the mount (samba, etc.) re-sorts correctly
+                # and old rows scanned before this field existed get backfilled.
+                tracked = existing_files[file_path_str]
+                try:
+                    fs_mtime = _mtime_to_datetime(filepath.stat().st_mtime)
+                except OSError:
+                    fs_mtime = None
+                if fs_mtime is not None and tracked.fs_modified_at != fs_mtime:
+                    tracked.fs_modified_at = fs_mtime
+                continue
 
 
             # Get file info
             # Get file info
             try:
             try:
@@ -1717,6 +1784,7 @@ async def scan_external_folder(
                 file_hash=None,  # Skip hashing external files for performance
                 file_hash=None,  # Skip hashing external files for performance
                 thumbnail_path=thumbnail_path,
                 thumbnail_path=thumbnail_path,
                 file_metadata=_without_print_name(file_metadata),
                 file_metadata=_without_print_name(file_metadata),
+                fs_modified_at=_mtime_to_datetime(stat.st_mtime),  # #2680: real on-disk mtime
             )
             )
             db.add(db_file)
             db.add(db_file)
             added += 1
             added += 1
@@ -1767,6 +1835,16 @@ async def scan_external_folder(
                 sub_folder_obj = sub_folder_result.scalar_one_or_none()
                 sub_folder_obj = sub_folder_result.scalar_one_or_none()
                 if sub_folder_obj:
                 if sub_folder_obj:
                     await db.delete(sub_folder_obj)
                     await db.delete(sub_folder_obj)
+                    folder_mtimes.pop(sub_fid, None)
+
+    # Persist each visited folder's real directory mtime (#2680). Fetched in one
+    # trip; folders deleted by the cleanup above were dropped from folder_mtimes.
+    if folder_mtimes:
+        folders_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id.in_(list(folder_mtimes.keys()))))
+        for folder_obj in folders_result.scalars().all():
+            new_mtime = folder_mtimes.get(folder_obj.id)
+            if new_mtime is not None and folder_obj.fs_modified_at != new_mtime:
+                folder_obj.fs_modified_at = new_mtime
 
 
     await db.commit()
     await db.commit()
 
 
@@ -1928,6 +2006,7 @@ async def list_files(
                 created_by_id=f.created_by_id,
                 created_by_id=f.created_by_id,
                 created_by_username=f.created_by.username if f.created_by else None,
                 created_by_username=f.created_by.username if f.created_by else None,
                 created_at=f.created_at,
                 created_at=f.created_at,
+                fs_modified_at=f.fs_modified_at,
                 print_name=print_name,
                 print_name=print_name,
                 print_time_seconds=print_time,
                 print_time_seconds=print_time,
                 filament_used_grams=filament_grams,
                 filament_used_grams=filament_grams,

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

@@ -7,7 +7,7 @@ from pydantic import Field
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "1.2.5"
+APP_VERSION = "1.2.5.1"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 
 

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

@@ -3768,6 +3768,21 @@ async def run_migrations(conn):
             "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS controls_printer_power BOOLEAN DEFAULT true",
             "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS controls_printer_power BOOLEAN DEFAULT true",
         )
         )
 
 
+    # Migration: real filesystem mtime for library files/folders (#2680). The
+    # folder tree's "sort by recent activity" and the file pane's date sort must
+    # track the on-disk mtime (``ls -t``), not Bambuddy's DB ``updated_at`` — for
+    # a bulk external scan every row's ``updated_at`` is the same scan instant, so
+    # ordering was arbitrary. Nullable; the timestamp type differs by dialect
+    # (SQLite DATETIME vs Postgres TIMESTAMP) so an existing-DB upgrade doesn't hit
+    # "type datetime does not exist" on Postgres. On a fresh DB create_all() already
+    # built the column, so the ALTER is swallowed as "already exists".
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at DATETIME")
+        await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at TIMESTAMP")
+        await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at TIMESTAMP")
+
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)
     await _migrate_rename_user_print_template_names(conn)

+ 16 - 0
backend/app/models/library.py

@@ -31,6 +31,14 @@ class LibraryFolder(Base):
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
+    # Real on-disk modification time of the directory this folder mirrors (#2680).
+    # For external folders this is captured from ``os.stat().st_mtime`` on scan so
+    # the tree's "sort by recent activity" matches ``ls -t`` instead of ordering by
+    # the DB row's ``updated_at`` (which is the scan instant, identical for every
+    # row of a bulk scan). Null for managed (internal) folders, which have no
+    # meaningful directory mtime — callers fall back to ``updated_at``/``created_at``.
+    fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
     # Relationships
     # Relationships
     parent: Mapped["LibraryFolder | None"] = relationship(
     parent: Mapped["LibraryFolder | None"] = relationship(
         "LibraryFolder",
         "LibraryFolder",
@@ -102,6 +110,14 @@ class LibraryFile(Base):
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
+    # Real on-disk modification time of the file (#2680). Captured from
+    # ``os.stat().st_mtime`` for external files on scan so the file pane's date
+    # sort and the folder tree's recursive "recent activity" bubble reflect the
+    # actual filesystem mtime (``ls -t``) rather than the DB ``updated_at`` (the
+    # scan instant, identical across a bulk scan). Null for managed uploads —
+    # callers fall back to ``created_at``.
+    fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
     # Relationships
     # Relationships
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     project: Mapped["Project | None"] = relationship()
     project: Mapped["Project | None"] = relationship()

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

@@ -205,6 +205,10 @@ class FileListResponse(BaseModel):
     created_by_id: int | None = None
     created_by_id: int | None = None
     created_by_username: str | None = None
     created_by_username: str | None = None
     created_at: datetime
     created_at: datetime
+    # Real on-disk modification time (#2680). Populated for external files from
+    # their filesystem mtime; null for managed uploads. The file pane's date sort
+    # and the "Modified" column use ``fs_modified_at ?? created_at``.
+    fs_modified_at: datetime | None = None
 
 
     # Key metadata fields for display
     # Key metadata fields for display
     print_name: str | None = None
     print_name: str | None = None

+ 39 - 9
backend/app/services/bambu_mqtt.py

@@ -140,8 +140,16 @@ def apply_tray_exist_bits(
     is valid idle-printer state (#1365 — X1C between prints) and MUST be applied
     is valid idle-printer state (#1365 — X1C between prints) and MUST be applied
     so spool removal is detected without requiring a manual reconnect.
     so spool removal is detected without requiring a manual reconnect.
 
 
-    AMS-HT units (``id >= 128``) use a separate addressing scheme and are
-    skipped here.
+    AMS-HT units (``id`` 128-135) are single-tray dry boxes whose presence bit
+    is packed as ONE consecutive bit starting at 16 (``16 + (ams_id - 128)``),
+    NOT ``ams_id * 4`` (which would overflow to bit 512+). This is the firmware's
+    authoritative empty signal for the HT — the only working clear path, since
+    the HT keeps echoing stale ``tray_type`` and its ``state`` is firmware-variant
+    (#2670). Verified against OrcaSlicer ``DevFilaSystem.cpp``
+    (``is_exists = tray_exist_bits >> (16 + (ams_id-128))``) and a live H2D
+    capture (HT-A → bit 16). The A2L-Lite (normalised to id 6 upstream) lands at
+    bits 24-27 via the regular ``ams_id * 4`` formula, matching OrcaSlicer's
+    ``AMS_LITE_MIXED`` offset, so it needs no special case here.
 
 
     `tray_exist_bits_str` is expected as a hex string (firmware sends it that
     `tray_exist_bits_str` is expected as a hex string (firmware sends it that
     way). Ints are tolerated for defensive symmetry but typically not seen
     way). Ints are tolerated for defensive symmetry but typically not seen
@@ -182,8 +190,13 @@ def apply_tray_exist_bits(
             ams_id = int(ams_id_raw) if isinstance(ams_id_raw, str) else ams_id_raw
             ams_id = int(ams_id_raw) if isinstance(ams_id_raw, str) else ams_id_raw
         except (ValueError, TypeError):
         except (ValueError, TypeError):
             continue
             continue
-        if not isinstance(ams_id, int) or ams_id >= 128:
-            # Skip AMS-HT (id >= 128) — separate addressing scheme.
+        if not isinstance(ams_id, int):
+            continue
+        # AMS-HT (n3s, id 128-135): single tray, presence bit at 16+(ams_id-128).
+        # Regular AMS (and the A2L-Lite normalised to id 6): ams_id*4 + tray_id.
+        # Anything outside those ranges has no known bit layout — don't guess it.
+        is_ht = 128 <= ams_id <= 135
+        if not is_ht and not (0 <= ams_id <= 15):
             continue
             continue
         for tray in ams_unit.get("tray", []):
         for tray in ams_unit.get("tray", []):
             if not isinstance(tray, dict):
             if not isinstance(tray, dict):
@@ -197,7 +210,7 @@ def apply_tray_exist_bits(
                 continue
                 continue
             if not isinstance(tray_id, int):
             if not isinstance(tray_id, int):
                 continue
                 continue
-            global_bit = ams_id * 4 + tray_id
+            global_bit = (16 + (ams_id - 128)) if is_ht else (ams_id * 4 + tray_id)
             slot_exists = (tray_exist_bits >> global_bit) & 1
             slot_exists = (tray_exist_bits >> global_bit) & 1
             if annotate_exists:
             if annotate_exists:
                 tray["exists"] = bool(slot_exists)
                 tray["exists"] = bool(slot_exists)
@@ -1427,10 +1440,19 @@ class BambuMQTTClient:
                 elif cmd == "ams_filament_setting":
                 elif cmd == "ams_filament_setting":
                     self._last_ams_cmd_time = 0.0
                     self._last_ams_cmd_time = 0.0
                     self._ams_cmd_unanswered = 0
                     self._ams_cmd_unanswered = 0
-            if "command" in print_data and print_data.get("command") == "extrusion_cali_get":
+            is_kprofile_response = "command" in print_data and print_data.get("command") == "extrusion_cali_get"
+            if is_kprofile_response:
                 self._handle_kprofile_response(print_data)
                 self._handle_kprofile_response(print_data)
 
 
-            self._update_state(print_data)
+            # An extrusion_cali_get response echoes the *requested* nozzle
+            # diameter (get_kprofiles probes 0.2/0.4/0.6/0.8 in turn), not the
+            # installed hardware. Feeding it to _update_state clobbered the real
+            # nozzle size (#2663) — typically leaving 0.8, the last size probed,
+            # which then failed the #1899 dispatch guard. The response carries no
+            # status telemetry, so skip it; the true nozzle comes from pushall.
+            # (Same reasoning as get_accessories in _handle_system_response.)
+            if not is_kprofile_response:
+                self._update_state(print_data)
 
 
     def _handle_system_response(self, data: dict):
     def _handle_system_response(self, data: dict):
         """Handle system responses including accessories info.
         """Handle system responses including accessories info.
@@ -2511,9 +2533,17 @@ class BambuMQTTClient:
                 if self.on_drying_complete:
                 if self.on_drying_complete:
                     self.on_drying_complete(ams_id)
                     self.on_drying_complete(ams_id)
 
 
-        # Create a hash of relevant AMS data to detect changes
+        # Create a hash of relevant AMS data to detect changes.
+        # Hash the MERGED state, not the raw incoming ams_list: a removal signalled
+        # only by tray_exist_bits (firmware still echoing the old tray_type in the
+        # payload, unchanged remain) clears merged_ams via apply_tray_exist_bits
+        # above but leaves the raw payload's tracked fields untouched — so a
+        # raw-based hash never flips and on_ams_change never fires, leaving the
+        # spool_assignment row bound to an emptied slot (#2670). merged_ams also
+        # always spans every unit, so a partial single-unit update can't produce a
+        # spuriously different hash from a full pushall.
         ams_hash_data = []
         ams_hash_data = []
-        for ams_unit in ams_list:
+        for ams_unit in merged_ams:
             for tray in ams_unit.get("tray", []):
             for tray in ams_unit.get("tray", []):
                 # Include fields that matter for filament tracking
                 # Include fields that matter for filament tracking
                 ams_hash_data.append(
                 ams_hash_data.append(

+ 46 - 8
backend/app/services/external_camera.py

@@ -11,7 +11,7 @@ import asyncio
 import logging
 import logging
 import re
 import re
 import shutil
 import shutil
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Callable
 from pathlib import Path
 from pathlib import Path
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
@@ -592,13 +592,30 @@ async def test_connection(url: str, camera_type: str) -> dict:
         return {"success": False, "error": f"Connection failed: {error_type}"}
         return {"success": False, "error": f"Connection failed: {error_type}"}
 
 
 
 
-async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> AsyncGenerator[bytes, None]:
+async def generate_mjpeg_stream(
+    url: str,
+    camera_type: str,
+    fps: int = 10,
+    *,
+    on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
+    stop_event: asyncio.Event | None = None,
+) -> AsyncGenerator[bytes, None]:
     """Generator yielding MJPEG frames for streaming.
     """Generator yielding MJPEG frames for streaming.
 
 
     Args:
     Args:
         url: Camera URL or USB device path
         url: Camera URL or USB device path
         camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
         camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
         fps: Target frames per second
         fps: Target frames per second
+        on_process: Called with the spawned ffmpeg process for the ``usb`` and
+            ``rtsp`` paths so the route layer can register it into the shared
+            stream registries — that's what lets ``/camera/stop`` and the orphan
+            janitor find and kill a leaked ffmpeg that's holding a USB device
+            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).
+        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.
 
 
     Yields:
     Yields:
         MJPEG frame data with HTTP multipart boundaries
         MJPEG frame data with HTTP multipart boundaries
@@ -617,7 +634,7 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
                 if current_time - last_frame_time >= frame_interval:
                 if current_time - last_frame_time >= frame_interval:
                     last_frame_time = current_time
                     last_frame_time = current_time
                     yield _format_mjpeg_frame(frame)
                     yield _format_mjpeg_frame(frame)
-            if not frame_yielded or attempt == max_retries:
+            if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
                 break
                 break
             logger.warning(
             logger.warning(
                 "External MJPEG stream ended, reconnecting (attempt %d/%d)...",
                 "External MJPEG stream ended, reconnecting (attempt %d/%d)...",
@@ -631,10 +648,10 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
         max_retries = 3
         max_retries = 3
         for attempt in range(max_retries + 1):
         for attempt in range(max_retries + 1):
             frame_yielded = False
             frame_yielded = False
-            async for frame in _stream_rtsp(url, fps):
+            async for frame in _stream_rtsp(url, fps, on_process=on_process):
                 frame_yielded = True
                 frame_yielded = True
                 yield _format_mjpeg_frame(frame)
                 yield _format_mjpeg_frame(frame)
-            if not frame_yielded or attempt == max_retries:
+            if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
                 break
                 break
             logger.warning(
             logger.warning(
                 "External RTSP stream ended, reconnecting (attempt %d/%d)...",
                 "External RTSP stream ended, reconnecting (attempt %d/%d)...",
@@ -645,7 +662,7 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
 
 
     elif camera_type == "usb":
     elif camera_type == "usb":
         # Use ffmpeg to stream from USB camera
         # Use ffmpeg to stream from USB camera
-        async for frame in _stream_usb(url, fps):
+        async for frame in _stream_usb(url, fps, on_process=on_process):
             yield _format_mjpeg_frame(frame)
             yield _format_mjpeg_frame(frame)
 
 
     elif camera_type == "snapshot":
     elif camera_type == "snapshot":
@@ -724,7 +741,12 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
         logger.error("MJPEG stream error: %s", e)
         logger.error("MJPEG stream error: %s", e)
 
 
 
 
-async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
+async def _stream_rtsp(
+    url: str,
+    fps: int,
+    *,
+    on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
+) -> AsyncGenerator[bytes, None]:
     """Stream frames from RTSP URL via ffmpeg.
     """Stream frames from RTSP URL via ffmpeg.
 
 
     For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
     For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
@@ -805,6 +827,11 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
             stdout=asyncio.subprocess.PIPE,
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
         )
+        # Register immediately — before the startup probe below — so a process
+        # that hangs on connect (rather than exiting) is still reachable by the
+        # stop endpoint / orphan janitor (#2675).
+        if on_process is not None:
+            on_process(process)
 
 
         # Brief check for immediate startup failures
         # Brief check for immediate startup failures
         await asyncio.sleep(0.1)
         await asyncio.sleep(0.1)
@@ -865,7 +892,12 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
             await proxy_server.wait_closed()
             await proxy_server.wait_closed()
 
 
 
 
-async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
+async def _stream_usb(
+    device: str,
+    fps: int,
+    *,
+    on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
+) -> AsyncGenerator[bytes, None]:
     """Stream frames from USB camera via ffmpeg."""
     """Stream frames from USB camera via ffmpeg."""
     ffmpeg = get_ffmpeg_path()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
@@ -907,6 +939,12 @@ async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
             stdout=asyncio.subprocess.PIPE,
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
         )
+        # Register immediately — before the startup probe below — so a process
+        # that hangs in open()/ioctl on a still-locked device (rather than
+        # exiting with a "busy" error) is still reachable by the stop endpoint /
+        # orphan janitor (#2675).
+        if on_process is not None:
+            on_process(process)
 
 
         # Give ffmpeg a moment to start and check for immediate failures
         # Give ffmpeg a moment to start and check for immediate failures
         await asyncio.sleep(0.5)
         await asyncio.sleep(0.5)

+ 13 - 6
backend/app/services/git_providers/gitea.py

@@ -63,14 +63,21 @@ class GiteaBackend(GitHubBackend):
             return tree_node.get("sha")
             return tree_node.get("sha")
         return None
         return None
 
 
+    # Gitea/Forgejo can be hosted under a URL path prefix (ROOT_URL like
+    # https://host/gitea), so the repo lives at /<prefix...>/<owner>/<repo>
+    # rather than at the host root (#2642). Capture the scheme+host+prefix as
+    # one group and the final two path segments as owner/repo; the lazy prefix
+    # group is empty for a root-hosted instance. One shared pattern keeps
+    # parse_repo_url() and get_api_base() from drifting.
+    _HTTPS_REPO_RE = re.compile(
+        r"(https?://[\w.\-]+(?::\d+)?(?:/[\w.\-]+)*?)/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$"
+    )
+
     def parse_repo_url(self, url: str) -> tuple[str, str]:
     def parse_repo_url(self, url: str) -> tuple[str, str]:
         """Return (owner, repo) — accepts both https:// and http:// for self-hosted instances."""
         """Return (owner, repo) — accepts both https:// and http:// for self-hosted instances."""
         if not url or len(url) > 500:
         if not url or len(url) > 500:
             raise ValueError("Invalid Git URL: URL too long or empty")
             raise ValueError("Invalid Git URL: URL too long or empty")
-        match = re.match(
-            r"https?://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
-            url,
-        )
+        match = self._HTTPS_REPO_RE.match(url)
         if match:
         if match:
             return match.group(2), match.group(3).removesuffix(".git")
             return match.group(2), match.group(3).removesuffix(".git")
         match = re.match(
         match = re.match(
@@ -82,8 +89,8 @@ class GiteaBackend(GitHubBackend):
         raise ValueError(f"Cannot parse repository URL: {url}")
         raise ValueError(f"Cannot parse repository URL: {url}")
 
 
     def get_api_base(self, repo_url: str) -> str:
     def get_api_base(self, repo_url: str) -> str:
-        """Derive API base from the repository URL's scheme and host."""
-        match = re.match(r"(https?://[\w.\-]+(:\d+)?)/", repo_url)
+        """Derive API base from the repository URL's scheme, host and any path prefix."""
+        match = self._HTTPS_REPO_RE.match(repo_url)
         if match:
         if match:
             return f"{match.group(1)}/api/v1"
             return f"{match.group(1)}/api/v1"
         raise ValueError(f"Cannot derive API base from URL: {repo_url}")
         raise ValueError(f"Cannot derive API base from URL: {repo_url}")

+ 6 - 2
backend/app/services/ldap_service.py

@@ -256,10 +256,14 @@ def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) ->
             return None
             return None
 
 
         info = _extract_user_info(service_conn, config, user_entry, username)
         info = _extract_user_info(service_conn, config, user_entry, username)
+        # Don't log the raw DN — its leaf CN is the user's real name (PII, #2681).
+        # The username + group count is enough to confirm a successful auth; the
+        # support-bundle sanitizer also redacts any DN that slips through (e.g. an
+        # ldap3 exception string), but keeping it out of the log at the source is
+        # the primary hygiene per the "no private data in logs" rule.
         logger.info(
         logger.info(
-            "LDAP authentication successful for user: %s (DN: %s, groups: %d)",
+            "LDAP authentication successful for user: %s (groups: %d)",
             info.username,
             info.username,
-            user_dn,
             len(info.groups),
             len(info.groups),
         )
         )
         return info
         return info

+ 18 - 0
backend/app/services/log_reader.py

@@ -25,6 +25,21 @@ logger = logging.getLogger(__name__)
 # parse it out; the log-health scanner does not.
 # parse it out; the log-health scanner does not.
 LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
 LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
 
 
+# LDAP Distinguished Names carry PII — the leaf ``CN=`` is the user's real name
+# (#2681). Match a run of at least two ``attr=value`` RDN components joined by
+# commas, where ``attr`` is a known LDAP attribute type. Requiring two components
+# keeps this from clobbering an incidental ``key=value`` in an unrelated log line,
+# while still catching DNs wherever they surface — the deliberate "auth successful"
+# line, ldap3 exception strings, and group DNs alike. Bias is intentionally toward
+# redaction: over-redacting a rare debug line to ``[DN]`` is a safe failure; leaking
+# a name is not.
+# The value char class excludes `<>;+` — RFC 4514 requires those escaped inside a
+# DN value, so an unescaped one marks the end of the DN, not part of it. That stops
+# the final (comma-unbounded) component from greedily swallowing trailing log text
+# such as ``… -> GroupName``.
+_LDAP_RDN = r"(?:CN|OU|DC|UID|O|L|ST|C|SN|GN|DN|E|MAIL|STREET|GIVENNAME|SURNAME)=[^,\n<>;+]+"
+_LDAP_DN_PATTERN = re.compile(rf"(?i)\b{_LDAP_RDN}(?:\s*,\s*{_LDAP_RDN})+")
+
 
 
 class LogEntry(BaseModel):
 class LogEntry(BaseModel):
     """A single parsed log entry."""
     """A single parsed log entry."""
@@ -159,6 +174,9 @@ def sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None
     # Replace email addresses
     # Replace email addresses
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
 
 
+    # Replace LDAP Distinguished Names (#2681) — PII on par with email.
+    content = _LDAP_DN_PATTERN.sub("[DN]", content)
+
     # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
     # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
     content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
     content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
 
 

+ 38 - 11
backend/app/services/print_scheduler.py

@@ -1158,6 +1158,14 @@ class PrintScheduler:
         to carry ``force_color_match: True``.  The printer must have **every** such slot loaded
         to carry ``force_color_match: True``.  The printer must have **every** such slot loaded
         with an exact type+color match.
         with an exact type+color match.
 
 
+        When both the override and a candidate tray carry a ``tray_info_idx``, they must also
+        match on it: Bambu reports every PLA variant as ``tray_type == "PLA"``, so the
+        Basic/Matte/Silk distinction lives only in ``tray_info_idx`` (GFA00/GFA01/GFA06/...).
+        Without this, a job sliced for PLA Matte matched every white PLA regardless of variant
+        (#2650). If either side lacks an idx (custom/third-party spools report a blank one, and
+        older 3MFs carry none) we fall back to the historical type+colour behaviour so those
+        setups are unaffected.
+
         Returns:
         Returns:
             List of ``"TYPE (color)"`` strings for unmatched slots (empty list means all match).
             List of ``"TYPE (color)"`` strings for unmatched slots (empty list means all match).
         """
         """
@@ -1165,26 +1173,32 @@ class PrintScheduler:
         if not status:
         if not status:
             return [f"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})" for o in force_overrides]
             return [f"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})" for o in force_overrides]
 
 
-        # Build set of loaded type+colour pairs from AMS and external spool
-        loaded: set[tuple[str, str]] = set()
+        # Build loaded (type, colour, tray_info_idx) triples from AMS and external spool.
+        loaded: list[tuple[str, str, str]] = []
         for ams_unit in status.raw_data.get("ams", []):
         for ams_unit in status.raw_data.get("ams", []):
             for tray in ams_unit.get("tray", []):
             for tray in ams_unit.get("tray", []):
                 tray_type = tray.get("tray_type")
                 tray_type = tray.get("tray_type")
-                tray_color = tray.get("tray_color", "")
                 if tray_type:
                 if tray_type:
-                    color_norm = tray_color.replace("#", "").lower()[:6]
-                    loaded.add((_canonical_filament_type(tray_type), color_norm))
+                    color_norm = (tray.get("tray_color", "") or "").replace("#", "").lower()[:6]
+                    loaded.append(
+                        (_canonical_filament_type(tray_type), color_norm, tray.get("tray_info_idx", "") or "")
+                    )
         for vt in status.raw_data.get("vt_tray") or []:
         for vt in status.raw_data.get("vt_tray") or []:
             vt_type = vt.get("tray_type")
             vt_type = vt.get("tray_type")
             if vt_type:
             if vt_type:
                 color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
                 color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
-                loaded.add((_canonical_filament_type(vt_type), color_norm))
+                loaded.append((_canonical_filament_type(vt_type), color_norm, vt.get("tray_info_idx", "") or ""))
 
 
         missing = []
         missing = []
         for o in force_overrides:
         for o in force_overrides:
             o_type = _canonical_filament_type(o.get("type") or "")
             o_type = _canonical_filament_type(o.get("type") or "")
             o_color = (o.get("color") or "").replace("#", "").lower()[:6]
             o_color = (o.get("color") or "").replace("#", "").lower()[:6]
-            if (o_type, o_color) not in loaded:
+            o_idx = o.get("tray_info_idx") or ""
+            satisfied = any(
+                t_type == o_type and t_color == o_color and (not o_idx or not t_idx or o_idx == t_idx)
+                for t_type, t_color, t_idx in loaded
+            )
+            if not satisfied:
                 color_label = o.get("color_name") or o.get("color", "?")
                 color_label = o.get("color_name") or o.get("color", "?")
                 missing.append(f"{o_type} ({color_label})")
                 missing.append(f"{o_type} ({color_label})")
         return missing
         return missing
@@ -1374,9 +1388,18 @@ class PrintScheduler:
                         override = override_map[req["slot_id"]]
                         override = override_map[req["slot_id"]]
                         req["type"] = override["type"]
                         req["type"] = override["type"]
                         req["color"] = override["color"]
                         req["color"] = override["color"]
-                        # Clear tray_info_idx so matching uses type+color instead of
-                        # the original 3MF's tray_info_idx (which would match the old filament)
-                        req["tray_info_idx"] = ""
+                        # A manual/preference override SWAPS the slot's filament, so the
+                        # 3MF's original tray_info_idx now points at the old spool and must
+                        # be cleared — matching then falls back to type+colour. A
+                        # force_color_match override is not a swap: it carries the 3MF's
+                        # intended variant (Basic GFA00 / Matte GFA01 / Silk GFA06), so keep
+                        # it here too, letting the matcher pin the correct variant slot on a
+                        # printer holding two same-colour spools of different variants (#2650).
+                        # If that variant isn't loaded the matcher falls back to type+colour,
+                        # so an eligible printer never fails to map.
+                        req["tray_info_idx"] = (
+                            override.get("tray_info_idx", "") if override.get("force_color_match") else ""
+                        )
                         logger.debug(
                         logger.debug(
                             "Queue item %s: Override slot %d -> %s %s",
                             "Queue item %s: Override slot %d -> %s %s",
                             item.id,
                             item.id,
@@ -1440,7 +1463,11 @@ class PrintScheduler:
                 "slot_id": o["slot_id"],
                 "slot_id": o["slot_id"],
                 "type": o.get("type", ""),
                 "type": o.get("type", ""),
                 "color": o.get("color", ""),
                 "color": o.get("color", ""),
-                "tray_info_idx": "",
+                # These are all force_color_match overrides, so the idx (when the
+                # 3MF carried one) is the intended variant, not a stale swap —
+                # keep it so the matcher pins the right variant slot, falling back
+                # to type+colour when it isn't loaded (#2650).
+                "tray_info_idx": o.get("tray_info_idx", ""),
             }
             }
             for o in force_overrides
             for o in force_overrides
         ]
         ]

+ 7 - 0
backend/app/services/printer_manager.py

@@ -1154,6 +1154,13 @@ def printer_state_to_dict(
                         "drying_temp": tray.get("drying_temp"),
                         "drying_temp": tray.get("drying_temp"),
                         "drying_time": tray.get("drying_time"),
                         "drying_time": tray.get("drying_time"),
                         "state": state_val,
                         "state": state_val,
+                        # Firmware's authoritative presence bit (tray_exist_bits),
+                        # set by apply_tray_exist_bits. The REST serializer already
+                        # emits it (routes/printers.py); without it here the WS
+                        # shallow-merge drops `exists` after the first frame and
+                        # getEmptySlotKind falls back to the firmware-variant state
+                        # 9/10 heuristic — wrong for AMS-HT in both directions (#2670).
+                        "exists": tray.get("exists"),
                     }
                     }
                 )
                 )
             # Prefer humidity_raw (actual percentage) over humidity (index 1-5)
             # Prefer humidity_raw (actual percentage) over humidity (index 1-5)

+ 60 - 22
backend/app/services/slicer_api.py

@@ -9,7 +9,9 @@ under the hood, response body is raw G-code or 3MF with metadata in the
 """
 """
 
 
 import asyncio
 import asyncio
+import io
 import logging
 import logging
+import zipfile
 from collections.abc import Callable
 from collections.abc import Callable
 from typing import NamedTuple
 from typing import NamedTuple
 
 
@@ -74,6 +76,62 @@ def _format_sidecar_error(response: httpx.Response) -> str:
     return (message or details or response.text)[:500]
     return (message or details or response.text)[:500]
 
 
 
 
+def _handle_slice_response(response: httpx.Response, *, export_3mf: bool) -> SliceResult:
+    """Turn a sidecar ``/slice`` HTTP response into a validated ``SliceResult``.
+
+    Shared by ``slice_with_profiles`` / ``slice_without_profiles`` so the status
+    handling and output validation live in one place.
+
+    Beyond the status check, this guards against the sidecar (or a reverse proxy
+    in front of it) returning **HTTP 200 with a body that isn't a real slice**
+    (#2671): a stock/misconfigured sidecar, a proxy interstitial or truncated
+    response, or an OrcaSlicer/BambuStudio CLI crash that produces empty output.
+    Without this check Bambuddy would store that tiny blob as a ``.gcode.3mf``,
+    let it be queued, and FTP it to the printer — a silently-broken print. When
+    a 3MF export was requested the body must be a valid ZIP (3MF container);
+    anything else is treated as a sidecar failure.
+
+    Raises:
+        SlicerInputError: 4xx from the sidecar (bad input / proxy body limit).
+        SlicerApiServerError: 5xx, or a 2xx whose body is not a valid 3MF.
+    """
+    if response.status_code == 413:
+        # A 413 almost never comes from the slicer itself — it's a reverse proxy
+        # (nginx/SWAG/Traefik) or a CDN capping the multipart upload (model +
+        # profiles). Name the real fix so the user doesn't tweak the wrong layer.
+        raise SlicerInputError(
+            "The slice request was rejected as too large (HTTP 413). A reverse proxy "
+            "in front of the slicer sidecar is capping the request body — raise "
+            "'client_max_body_size' (nginx/SWAG) or the equivalent on the proxy that "
+            "sits directly in front of the sidecar, then reload it. If the sidecar is "
+            "behind Cloudflare, note its request-size cap."
+        )
+    if response.status_code >= 500:
+        raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
+    if response.status_code >= 400:
+        raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
+
+    content = response.content
+    if export_3mf and not zipfile.is_zipfile(io.BytesIO(content)):
+        # 200 OK but the body is not a 3MF zip → the sidecar did not produce a
+        # usable slice. Surface it loudly instead of persisting a corrupt file.
+        detail = _format_sidecar_error(response) if len(content) <= 500 else ""
+        raise SlicerApiServerError(
+            f"Slicer sidecar returned HTTP {response.status_code} but the body is not a valid "
+            f"3MF ({len(content)} bytes). This usually means a misconfigured sidecar, an "
+            f"OrcaSlicer/BambuStudio CLI crash producing no output, or a reverse proxy returning "
+            f"an error page or truncating the response — verify the sidecar URL and any proxy in "
+            f"front of it." + (f" Body: {detail}" if detail else "")
+        )
+
+    return SliceResult(
+        content=content,
+        print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
+        filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
+        filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
+    )
+
+
 def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
 def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
     """Register an app-scoped client so per-request services can pool transport."""
     """Register an app-scoped client so per-request services can pool transport."""
     global _shared_http_client
     global _shared_http_client
@@ -294,17 +352,7 @@ class SlicerApiService:
                 except (asyncio.CancelledError, Exception):
                 except (asyncio.CancelledError, Exception):
                     pass  # Polling errors must not fail the slice.
                     pass  # Polling errors must not fail the slice.
 
 
-        if response.status_code >= 500:
-            raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
-        if response.status_code >= 400:
-            raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
-
-        return SliceResult(
-            content=response.content,
-            print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
-            filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
-            filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
-        )
+        return _handle_slice_response(response, export_3mf=export_3mf)
 
 
     async def slice_without_profiles(
     async def slice_without_profiles(
         self,
         self,
@@ -372,17 +420,7 @@ class SlicerApiService:
                 except (asyncio.CancelledError, Exception):
                 except (asyncio.CancelledError, Exception):
                     pass
                     pass
 
 
-        if response.status_code >= 500:
-            raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
-        if response.status_code >= 400:
-            raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
-
-        return SliceResult(
-            content=response.content,
-            print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
-            filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
-            filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
-        )
+        return _handle_slice_response(response, export_3mf=export_3mf)
 
 
 
 
 def _safe_int(value: str | None) -> int:
 def _safe_int(value: str | None) -> int:

+ 9 - 0
backend/app/services/virtual_printer/manager.py

@@ -903,11 +903,20 @@ class VirtualPrinterInstance:
                             if types:
                             if types:
                                 required_filament_types_json = json.dumps(types)
                                 required_filament_types_json = json.dumps(types)
                             if self.queue_force_color_match:
                             if self.queue_force_color_match:
+                                # Carry tray_info_idx so force_color_match can
+                                # tell Bambu PLA variants apart (#2650). Bambu
+                                # reports Basic/Matte/Silk all as tray_type
+                                # "PLA"; the variant lives only in tray_info_idx
+                                # (GFA00/GFA01/GFA06/...). A blank idx (custom or
+                                # third-party spool) means "no variant
+                                # constraint" and the scheduler falls back to
+                                # type+colour.
                                 overrides = [
                                 overrides = [
                                     {
                                     {
                                         "slot_id": r["slot_id"],
                                         "slot_id": r["slot_id"],
                                         "type": r.get("type", ""),
                                         "type": r.get("type", ""),
                                         "color": r.get("color", ""),
                                         "color": r.get("color", ""),
+                                        "tray_info_idx": r.get("tray_info_idx", ""),
                                         "force_color_match": True,
                                         "force_color_match": True,
                                     }
                                     }
                                     for r in requirements
                                     for r in requirements

+ 138 - 0
backend/tests/integration/test_external_folders_api.py

@@ -542,6 +542,144 @@ class TestExternalFolderScan:
         assert subfolder["external_readonly"] is True
         assert subfolder["external_readonly"] is True
 
 
 
 
+class TestExternalFolderModifiedTime:
+    """Filesystem mtime capture + recursive activity sort (#2680).
+
+    The folder tree's "sort by recent activity" and the file pane's date sort
+    must track the real on-disk mtime (``ls -t``), not the DB ``updated_at`` (the
+    scan instant, identical across a bulk scan).
+    """
+
+    @staticmethod
+    def _set_mtime(path: Path, epoch: float) -> None:
+        os.utime(path, (epoch, epoch))
+
+    @pytest.fixture
+    async def make_folder(self, async_client, db_session):
+        async def _make(ext_dir: Path, name: str = "MTime Test") -> dict:
+            data = {
+                "name": name,
+                "external_path": str(ext_dir),
+                "readonly": True,
+                "show_hidden": False,
+            }
+            resp = await async_client.post("/api/v1/library/folders/external", json=data)
+            assert resp.status_code == 200
+            return resp.json()
+
+        return _make
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_captures_file_fs_mtime(self, async_client, db_session, tmp_path, make_folder):
+        """Each scanned file carries its real on-disk mtime, not the scan time."""
+        ext = tmp_path / "prints"
+        ext.mkdir()
+        old = ext / "old.3mf"
+        new = ext / "new.3mf"
+        old.write_bytes(b"a")
+        new.write_bytes(b"b")
+        # old.3mf modified 2021-01-01, new.3mf modified 2024-01-01.
+        self._set_mtime(old, 1609459200.0)  # 2021-01-01T00:00:00Z
+        self._set_mtime(new, 1704067200.0)  # 2024-01-01T00:00:00Z
+
+        folder = await make_folder(ext)
+        await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+
+        resp = await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")
+        files = {f["filename"]: f for f in resp.json()}
+        assert files["old.3mf"]["fs_modified_at"] is not None
+        assert files["new.3mf"]["fs_modified_at"] is not None
+        # The real mtime, not "now": the 2021 file must predate the 2024 file.
+        assert files["old.3mf"]["fs_modified_at"] < files["new.3mf"]["fs_modified_at"]
+        assert files["old.3mf"]["fs_modified_at"].startswith("2021")
+        assert files["new.3mf"]["fs_modified_at"].startswith("2024")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rescan_refreshes_changed_file_mtime(self, async_client, db_session, tmp_path, make_folder):
+        """A file edited over the mount re-sorts on the next scan (#2680)."""
+        ext = tmp_path / "prints"
+        ext.mkdir()
+        f = ext / "part.3mf"
+        f.write_bytes(b"a")
+        self._set_mtime(f, 1609459200.0)  # 2021
+
+        folder = await make_folder(ext)
+        await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+
+        # File touched later (samba edit); re-scan must pick up the new mtime.
+        self._set_mtime(f, 1704067200.0)  # 2024
+        await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+
+        resp = await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")
+        got = resp.json()[0]
+        assert got["fs_modified_at"].startswith("2024")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_recursive_activity_bubbles_deep_file_to_root(self, async_client, db_session, tmp_path, make_folder):
+        """A freshly-added deep file lifts every ancestor's activity (#2680).
+
+        ``a`` holds only an OLD file directly but a NEW file three levels down;
+        ``b`` holds a MIDDLE-aged file directly. Recursive bubble must rank ``a``
+        (newest descendant) ahead of ``b`` even though a's own direct file and
+        directory are older.
+        """
+        root = tmp_path / "root"
+        deep = root / "a" / "x" / "y"
+        deep.mkdir(parents=True)
+        (root / "b").mkdir()
+
+        a_direct = root / "a" / "shallow.3mf"
+        deep_file = deep / "deep.3mf"
+        b_direct = root / "b" / "mid.3mf"
+        for p, data in ((a_direct, b"1"), (deep_file, b"2"), (b_direct, b"3")):
+            p.write_bytes(data)
+
+        self._set_mtime(a_direct, 1609459200.0)  # 2021 (oldest)
+        self._set_mtime(b_direct, 1656633600.0)  # 2022-07 (middle)
+        self._set_mtime(deep_file, 1704067200.0)  # 2024 (newest, deep under a)
+        # Directory mtimes are all old so only the deep FILE can lift branch a.
+        for d in (root, root / "a", root / "a" / "x", deep, root / "b"):
+            self._set_mtime(d, 1609459200.0)
+
+        folder = await make_folder(root)
+        await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+
+        tree = (await async_client.get("/api/v1/library/folders")).json()
+        top = find_folder_in_tree(tree, folder["name"])
+        assert top is not None
+        children = {c["name"]: c for c in top["children"]}
+        assert "a" in children and "b" in children
+        # Branch a's activity == the deep 2024 file; b's == its 2022 file.
+        assert children["a"]["latest_activity_at"] > children["b"]["latest_activity_at"]
+        assert children["a"]["latest_activity_at"].startswith("2024")
+        # The root itself bubbles up to the newest descendant anywhere inside it.
+        assert top["latest_activity_at"].startswith("2024")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_captures_folder_fs_mtime(self, async_client, db_session, tmp_path, make_folder):
+        """An empty-but-recently-touched subfolder still carries a real mtime."""
+        root = tmp_path / "root"
+        sub = root / "sub"
+        sub.mkdir(parents=True)
+        # A file so the subfolder survives the empty-subfolder cleanup.
+        (sub / "keep.3mf").write_bytes(b"a")
+        self._set_mtime(sub / "keep.3mf", 1609459200.0)  # 2021
+        self._set_mtime(sub, 1704067200.0)  # dir touched 2024
+
+        folder = await make_folder(root)
+        await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+
+        tree = (await async_client.get("/api/v1/library/folders")).json()
+        subfolder = find_folder_in_tree(tree, "sub")
+        assert subfolder is not None
+        # Dir mtime (2024) beats the single 2021 file → folder activity is 2024.
+        assert subfolder["latest_activity_at"].startswith("2024")
+
+
 class TestExternalFolderProtections:
 class TestExternalFolderProtections:
     """Tests for read-only protections on external folders."""
     """Tests for read-only protections on external folders."""
 
 

+ 7 - 7
backend/tests/integration/test_library_slice_api.py

@@ -206,7 +206,7 @@ class TestSliceLibraryFile:
             captured["url"] = str(request.url)
             captured["url"] = str(request.url)
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake-3mf",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "656",
                     "x-print-time-seconds": "656",
                     "x-filament-used-g": "0.94",
                     "x-filament-used-g": "0.94",
@@ -249,7 +249,7 @@ class TestSliceLibraryFile:
             captured["body"] = bytes(request.content)
             captured["body"] = bytes(request.content)
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "10",
                     "x-print-time-seconds": "10",
                     "x-filament-used-g": "0.1",
                     "x-filament-used-g": "0.1",
@@ -291,7 +291,7 @@ class TestSliceLibraryFile:
             captured["body"] = bytes(request.content)
             captured["body"] = bytes(request.content)
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "10",
                     "x-print-time-seconds": "10",
                     "x-filament-used-g": "0.1",
                     "x-filament-used-g": "0.1",
@@ -416,7 +416,7 @@ class TestSliceLibraryFile:
             # Retry: no profile triplet → succeed with embedded settings
             # Retry: no profile triplet → succeed with embedded settings
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake-3mf",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "100",
                     "x-print-time-seconds": "100",
                     "x-filament-used-g": "1.0",
                     "x-filament-used-g": "1.0",
@@ -499,7 +499,7 @@ class TestSliceLibraryFile:
             captured["body"] = request.content
             captured["body"] = request.content
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake-3mf",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "1",
                     "x-print-time-seconds": "1",
                     "x-filament-used-g": "0",
                     "x-filament-used-g": "0",
@@ -564,7 +564,7 @@ class TestSliceLibraryFile:
             captured["body"] = request.content
             captured["body"] = request.content
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake-3mf",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "100",
                     "x-print-time-seconds": "100",
                     "x-filament-used-g": "1.0",
                     "x-filament-used-g": "1.0",
@@ -607,7 +607,7 @@ class TestSliceLibraryFile:
             captured["body"] = request.content
             captured["body"] = request.content
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake-3mf",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "1",
                     "x-print-time-seconds": "1",
                     "x-filament-used-g": "0",
                     "x-filament-used-g": "0",

+ 189 - 4
backend/tests/unit/services/test_bambu_mqtt.py

@@ -642,6 +642,43 @@ class TestAMSDataMerging:
         assert tray["tray_sub_brands"] == "", "tray_sub_brands should be cleared"
         assert tray["tray_sub_brands"] == "", "tray_sub_brands should be cleared"
         assert tray["tag_uid"] == "0000000000000000", "tag_uid should be cleared"
         assert tray["tag_uid"] == "0000000000000000", "tag_uid should be cleared"
 
 
+    def test_bitmask_only_ht_removal_fires_on_ams_change(self, mqtt_client):
+        """#2670: an AMS-HT whose spool is removed can be signalled by
+        tray_exist_bits alone while the firmware keeps echoing the stale
+        tray_type/tag_uid/remain in the tray payload. apply_tray_exist_bits
+        clears the merged slot, but a change-hash built from the RAW payload
+        never flips (the echoed fields are unchanged), so on_ams_change would
+        not fire and the spool_assignment row would stay bound to an empty slot.
+        Hashing the MERGED state fixes it.
+        """
+        from unittest.mock import Mock
+
+        mqtt_client.on_ams_change = Mock()
+
+        # Loaded HT-A: bit 16 set (0x10000). Fires once as the initial state.
+        loaded = {
+            "ams": [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA", "tag_uid": "C7EFC10300000100", "remain": 75}]}],
+            "tray_exist_bits": "10000",
+            "power_on_flag": True,
+        }
+        mqtt_client._handle_ams_data(loaded)
+        assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == "PLA"
+        mqtt_client.on_ams_change.reset_mock()
+
+        # Removal signalled ONLY by the bitmask: bit 16 now clear, but the tray
+        # payload STILL echoes the same PLA/tag/remain — a raw-based hash would
+        # be byte-identical to the loaded push above and never fire.
+        bitmask_only_removal = {
+            "ams": [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA", "tag_uid": "C7EFC10300000100", "remain": 75}]}],
+            "tray_exist_bits": "0",
+            "power_on_flag": True,
+        }
+        mqtt_client._handle_ams_data(bitmask_only_removal)
+
+        # Merged slot cleared, and the callback fired off the merged-state hash.
+        assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == ""
+        mqtt_client.on_ams_change.assert_called_once()
+
     def test_partial_update_preserves_other_fields(self, mqtt_client):
     def test_partial_update_preserves_other_fields(self, mqtt_client):
         """Test that partial updates still preserve non-slot-status fields."""
         """Test that partial updates still preserve non-slot-status fields."""
         # Initial state with full data
         # Initial state with full data
@@ -1331,14 +1368,17 @@ class TestApplyTrayExistBitsHelper:
         assert units[0]["tray"][0]["state"] == 9
         assert units[0]["tray"][0]["state"] == 9
         assert isinstance(units[0]["tray"][0]["state"], int)
         assert isinstance(units[0]["tray"][0]["state"], int)
 
 
-    def test_ams_ht_unit_skipped(self):
-        """AMS-HT (id >= 128) uses a different addressing scheme."""
+    def test_ams_ht_unit_now_handled_not_skipped(self):
+        """AMS-HT (id 128-135) is no longer skipped: it uses its own bit at
+        16+(ams_id-128). With all bits 0 the HT slot clears like any other
+        (#2670 — the old skip left the HT permanently stale). See the dedicated
+        HT bit-math tests below for the encoding."""
         from backend.app.services.bambu_mqtt import apply_tray_exist_bits
         from backend.app.services.bambu_mqtt import apply_tray_exist_bits
 
 
         units = [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA"}]}]
         units = [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA"}]}]
         cleared = apply_tray_exist_bits(units, "0", power_on_flag=True)
         cleared = apply_tray_exist_bits(units, "0", power_on_flag=True)
-        assert cleared == 0
-        assert units[0]["tray"][0]["tray_type"] == "PLA"
+        assert cleared == 1
+        assert units[0]["tray"][0]["tray_type"] == ""
 
 
     def test_string_ids_handled(self):
     def test_string_ids_handled(self):
         """Bridge cache stores ids as strings (JSON wire format)."""
         """Bridge cache stores ids as strings (JSON wire format)."""
@@ -1421,6 +1461,82 @@ class TestApplyTrayExistBitsHelper:
         assert "exists" not in units[0]["tray"][0]
         assert "exists" not in units[0]["tray"][0]
         assert "exists" not in units[0]["tray"][1]
         assert "exists" not in units[0]["tray"][1]
 
 
+    def test_ht_unit_presence_bit_is_16_not_ams_id_times_4(self):
+        """#2670: AMS-HT (n3s, id 128) is single-tray; its presence bit is
+        16+(ams_id-128)=bit 16, NOT ams_id*4 (=512, which the old code skipped
+        outright, so the HT slot never cleared). Real H2D capture: loaded HT-A
+        reports tray_exist_bits 0x10f7f (bit 16 set); after unload it reports
+        0xf7f (bit 16 clear). Verified against OrcaSlicer DevFilaSystem.cpp."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        # Loaded (bit 16 set) → slot preserved, exists=True.
+        loaded = [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "000000FF"}]}]
+        assert apply_tray_exist_bits(loaded, "10f7f", power_on_flag=False, annotate_exists=True) == 0
+        assert loaded[0]["tray"][0]["tray_type"] == "PLA"
+        assert loaded[0]["tray"][0]["exists"] is True
+
+        # Empty (bit 16 clear) → slot cleared, state forced to 9, exists=False.
+        empty = [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "000000FF"}]}]
+        assert apply_tray_exist_bits(empty, "f7f", power_on_flag=False, annotate_exists=True) == 1
+        assert empty[0]["tray"][0]["tray_type"] == ""
+        assert empty[0]["tray"][0]["state"] == 9
+        assert empty[0]["tray"][0]["exists"] is False
+
+    def test_ht_second_unit_is_bit_17_not_bit_20(self):
+        """#2670: multiple AMS-HT units pack into CONSECUTIVE bits — HT-A=16,
+        HT-B=17 — NOT the 4-strided bit 20 a naive ams_id*4-style extrapolation
+        would give. This pins the exact encoding OrcaSlicer uses and guards every
+        dual-HT printer. 0x20000 sets ONLY bit 17: a bit-20 implementation would
+        wrongly clear this loaded HT-B."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 129, "tray": [{"id": 0, "tray_type": "PETG", "tray_color": "00FF00FF"}]}]
+        assert apply_tray_exist_bits(units, "20000", power_on_flag=False, annotate_exists=True) == 0
+        assert units[0]["tray"][0]["tray_type"] == "PETG"
+        assert units[0]["tray"][0]["exists"] is True
+
+    def test_ht_dual_unit_clears_only_the_empty_one(self):
+        """HT-A loaded + HT-B empty in one push, disambiguated by their
+        consecutive bits. 0x10000 = bit 16 only → HT-A (128) present, HT-B
+        (129, bit 17) empty."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [
+            {"id": 128, "tray": [{"id": 0, "tray_type": "PLA"}]},
+            {"id": 129, "tray": [{"id": 0, "tray_type": "PETG"}]},
+        ]
+        assert apply_tray_exist_bits(units, "10000", power_on_flag=False, annotate_exists=True) == 1
+        assert units[0]["tray"][0]["tray_type"] == "PLA"  # HT-A present
+        assert units[0]["tray"][0]["exists"] is True
+        assert units[1]["tray"][0]["tray_type"] == ""  # HT-B cleared
+        assert units[1]["tray"][0]["exists"] is False
+
+    def test_ht_and_regular_ams_use_their_own_formulas_together(self):
+        """Regular AMS keeps ams_id*4+tray_id while HT uses 16+(ams_id-128) in a
+        single call. bits 0x1 = regular AMS0 slot0 present (bit 0); HT-A empty
+        (bit 16 clear)."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [
+            {"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]},
+            {"id": 128, "tray": [{"id": 0, "tray_type": "ASA"}]},
+        ]
+        assert apply_tray_exist_bits(units, "1", power_on_flag=True, annotate_exists=True) == 1
+        assert units[0]["tray"][0]["tray_type"] == "PLA"  # regular AMS0 slot0 (bit 0) present
+        assert units[0]["tray"][0]["exists"] is True
+        assert units[1]["tray"][0]["tray_type"] == ""  # HT-A (bit 16) empty
+        assert units[1]["tray"][0]["exists"] is False
+
+    def test_unknown_ams_id_range_is_left_untouched(self):
+        """An id outside regular (0-15) and HT (128-135) has no known bit layout
+        — the helper must NOT guess a bit and must NOT clear the slot."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 200, "tray": [{"id": 0, "tray_type": "PLA"}]}]
+        assert apply_tray_exist_bits(units, "0", power_on_flag=True, annotate_exists=True) == 0
+        assert units[0]["tray"][0]["tray_type"] == "PLA"
+        assert "exists" not in units[0]["tray"][0]
+
 
 
 class TestNozzleRackData:
 class TestNozzleRackData:
     """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
     """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
@@ -6468,3 +6584,72 @@ class TestPresumedPowerOffRecovery:
         self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
         self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
 
 
         assert mqtt_client.state.state == "FINISH"
         assert mqtt_client.state.state == "FINISH"
+
+
+class TestKProfileResponseDoesNotClobberNozzle:
+    """#2663: the K-profile fetch (get_kprofiles) probes every nozzle size
+    0.2/0.4/0.6/0.8 with an ``extrusion_cali_get`` request. Each response
+    echoes the *requested* nozzle_diameter at the top level, which is NOT the
+    installed hardware. _process_message must not feed those responses to
+    _update_state, or the real nozzle size gets overwritten (typically to 0.8,
+    the last size probed) and the #1899 dispatch guard then blocks prints.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="A1TEST",
+            access_code="12345678",
+        )
+
+    def test_kprofile_response_does_not_overwrite_nozzle_diameter(self, mqtt_client):
+        # A genuine pushall reports the real 0.4mm nozzle.
+        mqtt_client._process_message({"print": {"nozzle_diameter": "0.4"}})
+        assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
+
+        # The K-profile probe's 0.8mm response arrives (as it did on the
+        # reporter's A1s). It must NOT clobber the hardware nozzle.
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "command": "extrusion_cali_get",
+                    "nozzle_diameter": "0.8",
+                    "filaments": [],
+                    "sequence_id": "1501",
+                }
+            }
+        )
+        assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
+
+    def test_kprofile_response_is_still_parsed(self, mqtt_client):
+        # Skipping _update_state must not skip the K-profile handler: the
+        # response's profiles still populate state.kprofiles.
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "command": "extrusion_cali_get",
+                    "nozzle_diameter": "0.4",
+                    "filaments": [
+                        {
+                            "cali_idx": 0,
+                            "nozzle_diameter": "0.4",
+                            "filament_id": "GFA00",
+                            "name": "PLA",
+                            "k_value": "0.020000",
+                        }
+                    ],
+                }
+            }
+        )
+        assert len(mqtt_client.state.kprofiles) == 1
+        assert mqtt_client.state.kprofiles[0].filament_id == "GFA00"
+
+    def test_genuine_pushall_still_updates_nozzle(self, mqtt_client):
+        # The one legitimate source of the hardware nozzle still works, and a
+        # later pushall corrects a value an old build left wrong.
+        mqtt_client.state.nozzles[0].nozzle_diameter = "0.8"
+        mqtt_client._process_message({"print": {"nozzle_diameter": "0.4"}})
+        assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"

+ 33 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -986,6 +986,39 @@ class TestPrinterStateToDict:
 
 
         assert result["ams"][0]["tray"][0]["tag_uid"] is None
         assert result["ams"][0]["tray"][0]["tag_uid"] is None
 
 
+    def test_exists_bit_is_serialized_for_websocket(self, mock_state):
+        """#2670: the WS status payload must carry the firmware presence bit
+        `exists` (set by apply_tray_exist_bits) — the REST serializer already
+        does. Without it the frontend shallow-merge drops `exists` after the
+        first WS frame and getEmptySlotKind falls back to the firmware-variant
+        state 9/10 heuristic, which is wrong for AMS-HT in both directions.
+        """
+        mock_state.raw_data = {
+            "ams": [
+                {
+                    "id": 128,
+                    "tray": [
+                        # Empty HT: apply_tray_exist_bits cleared it and set exists=False.
+                        {"id": 0, "state": 9, "tray_type": "", "exists": False},
+                    ],
+                },
+                {
+                    "id": 0,
+                    "tray": [
+                        # Present non-RFID spool: exists=True, no tray_type ("?").
+                        {"id": 0, "state": 10, "tray_type": "", "exists": True},
+                    ],
+                },
+            ]
+        }
+
+        result = printer_state_to_dict(mock_state)
+
+        ht_tray = result["ams"][0]["tray"][0]
+        reg_tray = result["ams"][1]["tray"][0]
+        assert ht_tray["exists"] is False
+        assert reg_tray["exists"] is True
+
     def test_vt_tray_parsing(self, mock_state):
     def test_vt_tray_parsing(self, mock_state):
         """Verify virtual tray is parsed correctly as a list."""
         """Verify virtual tray is parsed correctly as a list."""
         mock_state.raw_data = {
         mock_state.raw_data = {

+ 101 - 1
backend/tests/unit/services/test_slicer_api.py

@@ -226,9 +226,11 @@ class TestSliceWithProfiles:
 
 
         def handler(request: httpx.Request) -> httpx.Response:
         def handler(request: httpx.Request) -> httpx.Response:
             captured["body"] = request.content
             captured["body"] = request.content
+            # export_3mf=True → the response body must be a valid 3MF zip, or the
+            # #2671 output validation rejects it. This test is about the request.
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"3MF-BYTES",
+                content=_valid_3mf_zip(),
                 headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
                 headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
             )
             )
 
 
@@ -362,6 +364,104 @@ class TestSliceWithProfiles:
         assert result.filament_used_mm == 0.0
         assert result.filament_used_mm == 0.0
 
 
 
 
+def _valid_3mf_zip() -> bytes:
+    """Minimal-but-valid ZIP so is_zipfile() accepts it as a 3MF container."""
+    import io
+    import zipfile
+
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w") as zf:
+        zf.writestr("[Content_Types].xml", "<Types/>")
+        zf.writestr("Metadata/plate_1.gcode", "; G-CODE\nG28\n")
+    return buf.getvalue()
+
+
+class TestSliceOutputValidation:
+    """#2671: a 200 with a non-3MF body must not be persisted as a slice."""
+
+    _SLICE_KW = {
+        "model_bytes": b"solid Cube\n",
+        "model_filename": "Cube.stl",
+        "printer_profile_json": "{}",
+        "process_profile_json": "{}",
+        "filament_profile_jsons": ["{}"],
+    }
+
+    @pytest.mark.asyncio
+    async def test_413_gives_actionable_reverse_proxy_message(self):
+        # A 413 is a proxy/CDN body-size cap, not the slicer — the message must
+        # point at the right layer so the user stops editing the wrong one.
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(status_code=413, content=b"<html>413 Request Entity Too Large</html>")
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        with pytest.raises(SlicerInputError) as exc_info:
+            await service.slice_with_profiles(export_3mf=True, **self._SLICE_KW)
+        msg = str(exc_info.value)
+        assert "413" in msg
+        assert "client_max_body_size" in msg
+        assert "proxy" in msg.lower()
+
+    @pytest.mark.asyncio
+    async def test_export_3mf_rejects_non_zip_200_body(self):
+        # The exact failure from #2671: sidecar/proxy returns 200 with a tiny
+        # garbage body; Bambuddy must NOT accept it as a sliced 3MF.
+        body = b'{"detail":"Not Found"}xxxxxx'  # 28 bytes, not a zip
+        assert len(body) == 28
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(status_code=200, content=body)
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        with pytest.raises(SlicerApiServerError) as exc_info:
+            await service.slice_with_profiles(export_3mf=True, **self._SLICE_KW)
+        msg = str(exc_info.value)
+        assert "not a valid" in msg.lower()
+        assert "28 bytes" in msg
+
+    @pytest.mark.asyncio
+    async def test_export_3mf_accepts_valid_zip_body(self):
+        zip_bytes = _valid_3mf_zip()
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=200,
+                content=zip_bytes,
+                headers={"x-print-time-seconds": "656"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        result = await service.slice_with_profiles(export_3mf=True, **self._SLICE_KW)
+        assert result.content == zip_bytes
+        assert result.print_time_seconds == 656
+
+    @pytest.mark.asyncio
+    async def test_raw_gcode_body_not_zip_validated(self):
+        # export_3mf defaults False (preview / raw-gcode callers): the body is
+        # legitimately not a zip, so the validation must NOT fire.
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(status_code=200, content=b"; G-CODE\nG28\n")
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        result = await service.slice_with_profiles(**self._SLICE_KW)
+        assert result.content == b"; G-CODE\nG28\n"
+
+    @pytest.mark.asyncio
+    async def test_without_profiles_also_rejects_non_zip_200_body(self):
+        # The validation lives in the shared response handler, so the
+        # embedded-settings path (slice_without_profiles) is covered too.
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(status_code=200, content=b"nope")
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        with pytest.raises(SlicerApiServerError):
+            await service.slice_without_profiles(
+                model_bytes=b"solid Cube\n",
+                model_filename="Cube.stl",
+                export_3mf=True,
+            )
+
+
 class TestHealth:
 class TestHealth:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_health_returns_body(self):
     async def test_health_returns_body(self):

+ 67 - 2
backend/tests/unit/services/test_virtual_printer.py

@@ -1169,12 +1169,77 @@ class TestVirtualPrinterInstance:
         assert queue_item.filament_overrides is not None
         assert queue_item.filament_overrides is not None
         overrides = json.loads(queue_item.filament_overrides)
         overrides = json.loads(queue_item.filament_overrides)
         assert overrides == [
         assert overrides == [
-            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "force_color_match": True},
-            {"slot_id": 2, "type": "PLA", "color": "#FF00FF", "force_color_match": True},
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "", "force_color_match": True},
+            {"slot_id": 2, "type": "PLA", "color": "#FF00FF", "tray_info_idx": "", "force_color_match": True},
         ]
         ]
         # required_filament_types still populated alongside overrides.
         # required_filament_types still populated alongside overrides.
         assert json.loads(queue_item.required_filament_types) == ["PLA"]
         assert json.loads(queue_item.required_filament_types) == ["PLA"]
 
 
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_force_color_match_carries_tray_info_idx(self, tmp_path):
+        """#2650: the force override must carry the 3MF's ``tray_info_idx`` so the
+        scheduler can tell Bambu PLA variants apart (Basic GFA00 / Matte GFA01 /
+        Silk GFA06) — they all report ``tray_type == "PLA"`` with the same colour,
+        so type+colour alone dispatches onto the wrong variant."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=24,
+            name="Variant",
+            mode="queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800024",
+            auto_dispatch=True,
+            queue_force_color_match=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "variant.3mf"
+        _write_3mf_with_filaments(
+            file_path,
+            [
+                # White PLA Matte — same colour as Basic/Silk, distinguished only by idx.
+                {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0", "tray_info_idx": "GFA01"},
+            ],
+            plate_index=1,
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "variant"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        overrides = json.loads(added_items[0].filament_overrides)
+        assert overrides == [
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA01", "force_color_match": True},
+        ]
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_add_to_print_queue_force_color_match_skips_when_3mf_unparseable(self, tmp_path):
     async def test_add_to_print_queue_force_color_match_skips_when_3mf_unparseable(self, tmp_path):
         """A malformed or fake-bytes 3MF must not crash the upload path —
         """A malformed or fake-bytes 3MF must not crash the upload path —

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

@@ -0,0 +1,199 @@
+"""External/USB camera ffmpeg-leak cleanup (#2675, reporter @bitbarista).
+
+An external USB (V4L2) camera's ffmpeg used to be reachable only from its own
+stream generator's ``finally`` — which an abrupt client disconnect can skip
+(same cancellation-timing class as #776). Because external streams never
+registered into ``_active_streams`` / ``_disconnect_events`` / the spawned-PID
+map, both ``/camera/stop`` and ``cleanup_orphaned_streams`` were structurally
+blind to the leak, leaving ``/dev/videoN`` locked. The fix registers the external
+ffmpeg into the same registries the RTSP path uses.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from contextlib import suppress
+from unittest.mock import mock_open, patch
+
+import pytest
+
+from backend.app.api.routes import camera
+from backend.app.services import external_camera
+
+
+async def _instant_sleep(*_args, **_kwargs) -> None:
+    """Drop-in for asyncio.sleep that returns immediately (no self-recursion)."""
+    return None
+
+
+class _CleanProc:
+    """ffmpeg that terminates cleanly when asked."""
+
+    def __init__(self, pid: int) -> None:
+        self.pid = pid
+        self.returncode = None
+
+    def terminate(self) -> None:
+        self.returncode = 0
+
+    def kill(self) -> None:
+        self.returncode = -9
+
+    async def wait(self) -> int:
+        return self.returncode if self.returncode is not None else 0
+
+
+class _ImmediateEOFReader:
+    async def read(self, _size: int = -1) -> bytes:
+        return b""
+
+
+class _UsbProc:
+    """ffmpeg for a USB stream: yields no frames, exits at first read."""
+
+    def __init__(self, pid: int = 52001) -> None:
+        self.pid = pid
+        self.returncode = None
+        self.stdout = _ImmediateEOFReader()
+        self.stderr = _ImmediateEOFReader()
+
+    def terminate(self) -> None:
+        self.returncode = 0
+
+    def kill(self) -> None:
+        self.returncode = -9
+
+    async def wait(self) -> int:
+        return 0
+
+
+# ---------------------------------------------------------------------------
+# 1. The stream generator hands its ffmpeg process to the on_process callback
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_stream_usb_registers_process_via_on_process(monkeypatch):
+    """``_stream_usb`` must call ``on_process`` with the spawned ffmpeg so the
+    route can register it — this is the linchpin of the whole fix."""
+
+    class _FakePath:
+        def __init__(self, _p: str) -> None:
+            pass
+
+        def exists(self) -> bool:
+            return True
+
+    proc = _UsbProc()
+
+    async def fake_create_subprocess_exec(*_args, **_kwargs):
+        return proc
+
+    monkeypatch.setattr(external_camera, "get_ffmpeg_path", lambda: "/fake/ffmpeg")
+    monkeypatch.setattr(external_camera, "Path", _FakePath)
+    monkeypatch.setattr(external_camera.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
+    monkeypatch.setattr(external_camera.asyncio, "sleep", _instant_sleep)
+
+    captured: list[object] = []
+    stream = external_camera._stream_usb("/dev/video0", 10, on_process=captured.append)
+    try:
+        async for _frame in stream:
+            pass
+    finally:
+        with suppress(Exception):
+            await stream.aclose()
+
+    assert captured == [proc], "the spawned ffmpeg process must be handed to on_process"
+
+
+# ---------------------------------------------------------------------------
+# 2. /camera/stop now finds and kills a registered external USB process
+#    (the reported {"stopped": 0} → {"stopped": 1})
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_stop_endpoint_terminates_registered_external_process(monkeypatch):
+    monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
+    monkeypatch.setattr(camera, "get_subscriber_count", lambda _key: 0)
+
+    async def fake_shutdown(_key):
+        return False
+
+    monkeypatch.setattr(camera, "shutdown_broadcaster", fake_shutdown)
+
+    printer_id = 7
+    sid = f"{printer_id}-ext-abc12345"
+    proc = _CleanProc(pid=52010)
+    event = asyncio.Event()
+    camera._active_streams[sid] = proc
+    camera._disconnect_events[sid] = event
+    camera._spawned_ffmpeg_pids[proc.pid] = time.time()
+    camera._stream_last_frame_times[sid] = time.time()
+
+    try:
+        result = await camera.stop_camera_stream(printer_id, _=None)
+        assert result["stopped"] == 1
+        assert proc.returncode is not None, "the external ffmpeg must be terminated"
+        assert event.is_set(), "the stream's stop event must be signalled"
+        # Registry fully cleaned so it can't be double-reaped.
+        assert sid not in camera._active_streams
+        assert sid not in camera._disconnect_events
+        assert proc.pid not in camera._spawned_ffmpeg_pids
+    finally:
+        camera._active_streams.pop(sid, None)
+        camera._disconnect_events.pop(sid, None)
+        camera._spawned_ffmpeg_pids.pop(proc.pid, None)
+        camera._stream_last_frame_times.pop(sid, None)
+
+
+# ---------------------------------------------------------------------------
+# 3. The orphan janitor reaps a stale registered external USB stream
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_cleanup_janitor_reaps_stale_external_usb_stream(monkeypatch):
+    monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
+    monkeypatch.setattr(camera, "_scan_bambu_ffmpeg_pids", lambda: [])
+
+    import os
+
+    proc = _CleanProc(pid=os.getpid())  # real pid so layer-2 existence check keeps it
+    sid = "7-ext-deadbeef"
+    now = time.time()
+    camera._active_streams[sid] = proc
+    camera._spawned_ffmpeg_pids[proc.pid] = now - 120  # spawned long ago
+    camera._stream_last_frame_times[sid] = now - 60  # stale: no frames >30s
+    camera._disconnect_events[sid] = asyncio.Event()
+
+    try:
+        await asyncio.wait_for(camera.cleanup_orphaned_streams(), timeout=2.0)
+        assert proc.returncode is not None, "stale external ffmpeg must be killed"
+        assert sid not in camera._active_streams
+    finally:
+        camera._active_streams.pop(sid, None)
+        camera._spawned_ffmpeg_pids.pop(proc.pid, None)
+        camera._stream_last_frame_times.pop(sid, None)
+        camera._disconnect_events.pop(sid, None)
+
+
+# ---------------------------------------------------------------------------
+# 4. The /proc "nuclear net" now matches USB (v4l2) ffmpeg from prior sessions
+# ---------------------------------------------------------------------------
+
+
+def test_scan_matches_v4l2_ffmpeg(monkeypatch):
+    cmdline = b"ffmpeg\x00-f\x00v4l2\x00-i\x00/dev/video0\x00-f\x00mjpeg\x00-\x00"
+    monkeypatch.setattr("os.listdir", lambda _p: ["52020"])
+    with patch("builtins.open", mock_open(read_data=cmdline)):
+        assert 52020 in camera._scan_bambu_ffmpeg_pids()
+
+
+def test_scan_ignores_unrelated_ffmpeg(monkeypatch):
+    # A transcode of a local file is not ours — must not be reaped.
+    cmdline = b"ffmpeg\x00-i\x00/home/user/movie.mp4\x00out.mkv\x00"
+    monkeypatch.setattr("os.listdir", lambda _p: ["52021"])
+    with patch("builtins.open", mock_open(read_data=cmdline)):
+        assert camera._scan_bambu_ffmpeg_pids() == []

+ 30 - 0
backend/tests/unit/test_git_providers.py

@@ -463,6 +463,26 @@ class TestGiteaBackendApiBase:
         assert owner == "owner"
         assert owner == "owner"
         assert repo == "repo"
         assert repo == "repo"
 
 
+    def test_parse_url_subpath_hosted(self):
+        # Gitea under a ROOT_URL path prefix, e.g. https://host/gitea (#2642)
+        owner, repo = self.backend.parse_repo_url("https://DOMAIN/gitea/user/repo")
+        assert owner == "user"
+        assert repo == "repo"
+
+    def test_parse_url_subpath_hosted_with_git_suffix(self):
+        owner, repo = self.backend.parse_repo_url("https://DOMAIN/gitea/user/repo.git")
+        assert owner == "user"
+        assert repo == "repo"
+
+    def test_derives_api_base_subpath_hosted(self):
+        # API base must keep the path prefix so calls hit /gitea/api/v1 (#2642)
+        result = self.backend.get_api_base("https://DOMAIN/gitea/user/repo")
+        assert result == "https://DOMAIN/gitea/api/v1"
+
+    def test_derives_api_base_subpath_hosted_with_port(self):
+        result = self.backend.get_api_base("https://DOMAIN:3000/gitea/user/repo")
+        assert result == "https://DOMAIN:3000/gitea/api/v1"
+
 
 
 class TestGiteaBackendPushFiles:
 class TestGiteaBackendPushFiles:
     def setup_method(self):
     def setup_method(self):
@@ -1358,6 +1378,16 @@ class TestForgejoBackendApiBase:
         assert owner == "owner"
         assert owner == "owner"
         assert repo == "repo"
         assert repo == "repo"
 
 
+    def test_parse_url_subpath_hosted(self):
+        # Forgejo inherits GiteaBackend's subpath handling (#2642)
+        owner, repo = self.backend.parse_repo_url("https://DOMAIN/forgejo/user/repo")
+        assert owner == "user"
+        assert repo == "repo"
+
+    def test_derives_api_base_subpath_hosted(self):
+        result = self.backend.get_api_base("https://DOMAIN/forgejo/user/repo")
+        assert result == "https://DOMAIN/forgejo/api/v1"
+
 
 
 class TestForgejoTestConnection:
 class TestForgejoTestConnection:
     """ForgejoBackend overrides test_connection to handle Forgejo v15+ 404-not-403 behaviour."""
     """ForgejoBackend overrides test_connection to handle Forgejo v15+ 404-not-403 behaviour."""

+ 150 - 0
backend/tests/unit/test_scheduler_force_color_ams_fallback.py

@@ -124,6 +124,27 @@ class TestBuildOverrideDirectMapping:
         # Should match by colour (#CBC6B8 ≈ CBC6B8FF after strip), not by tray_info_idx.
         # Should match by colour (#CBC6B8 ≈ CBC6B8FF after strip), not by tray_info_idx.
         assert result == [0]
         assert result == [0]
 
 
+    def test_direct_mapping_pins_variant_when_override_carries_idx(self, scheduler):
+        """#2650: the no-3MF fallback honours a force override's own tray_info_idx,
+        so two same-colour PLA variants map to the intended slot rather than the
+        first same-colour tray."""
+        status = self._status(
+            ams=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
+                        {"id": 1, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA01"},
+                    ],
+                }
+            ]
+        )
+        overrides = [
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA01", "force_color_match": True}
+        ]
+        result = scheduler._build_override_direct_mapping(overrides, status)
+        assert result == [1]  # global_tray_id 1 = GFA01 (Matte), not GFA00 (Basic) at 0
+
 
 
 class TestComputeAmsMappingFallback:
 class TestComputeAmsMappingFallback:
     """Integration tests for the force-color fallback inside
     """Integration tests for the force-color fallback inside
@@ -241,3 +262,132 @@ class TestComputeAmsMappingFallback:
             result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
             result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
 
 
         assert result is None
         assert result is None
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_force_color_override_pins_the_matching_variant_slot(self, mock_pm, scheduler):
+        """#2650 slot selection: with two same-colour PLA spools of different
+        variants loaded, applying a force_color_match override must map to the
+        tray whose tray_info_idx matches the 3MF (Matte GFA01), not the first
+        same-colour tray (Basic GFA00)."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
+                            {"id": 1, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA01"},
+                        ],
+                    }
+                ]
+            },
+            ams_filament_backup=None,
+        )
+        item = self._make_item(
+            filament_overrides_json=(
+                '[{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", '
+                '"tray_info_idx": "GFA01", "force_color_match": true}]'
+            )
+        )
+        filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA01"}]
+        db = AsyncMock()
+
+        with (
+            patch.object(scheduler, "_get_filament_requirements", return_value=filament_reqs),
+            patch.object(scheduler, "_get_bool_setting", new=AsyncMock(return_value=False)),
+        ):
+            result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
+
+        assert result == [1]  # global_tray_id 1 = GFA01 (Matte), not GFA00 (Basic) at 0
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_preference_override_still_clears_idx_so_a_swap_matches_by_colour(self, mock_pm, scheduler):
+        """A non-force (preference) override is a filament SWAP: its slot must
+        match by the new type+colour, never by a stale 3MF variant that would pin
+        the old spool. Only force_color_match overrides keep their idx — anything
+        else is cleared, exactly as before #2650."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            # The 3MF's original variant (blue GFA01) and the swapped-to red.
+                            {"id": 0, "tray_type": "PLA", "tray_color": "0000FFFF", "tray_info_idx": "GFA01"},
+                            {"id": 1, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFA00"},
+                        ],
+                    }
+                ]
+            },
+            ams_filament_backup=None,
+        )
+        # 3MF wants blue GFA01; the user swaps this slot to red via a preference
+        # override that (defensively) also carries the stale GFA01 idx.
+        item = self._make_item(
+            filament_overrides_json='[{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "GFA01"}]'
+        )
+        filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#0000FF", "tray_info_idx": "GFA01"}]
+        db = AsyncMock()
+
+        with (
+            patch.object(scheduler, "_get_filament_requirements", return_value=filament_reqs),
+            patch.object(scheduler, "_get_bool_setting", new=AsyncMock(return_value=False)),
+        ):
+            result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
+
+        # idx cleared → matches the swapped-to red spool (global 1), not the stale
+        # GFA01 blue (global 0) that a preserved idx would have pinned.
+        assert result == [1]
+
+
+class TestGetMissingForceColorSlotsVariant:
+    """force_color_match must distinguish Bambu PLA variants that share a base
+    type+colour but differ in tray_info_idx (Basic GFA00 / Matte GFA01 /
+    Silk GFA06), while still accepting spools that report no idx (#2650)."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def _status(self, trays: list[dict]) -> MagicMock:
+        """One AMS unit whose trays are the given dicts (white PLA of assorted variants)."""
+        return MagicMock(raw_data={"ams": [{"id": 0, "tray": trays}]})
+
+    @staticmethod
+    def _white(idx: str) -> dict:
+        return {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": idx}
+
+    def _override(self, idx: str | None) -> list[dict]:
+        o = {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "force_color_match": True}
+        if idx is not None:
+            o["tray_info_idx"] = idx
+        return [o]
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_matte_requirement_rejects_basic_and_silk(self, mock_pm, scheduler):
+        """A GFA01 (Matte) job is unsatisfied by a printer loaded with only
+        Basic/Silk white PLA — the core #2650 regression."""
+        mock_pm.get_status.return_value = self._status([self._white("GFA00"), self._white("GFA06")])
+        assert scheduler._get_missing_force_color_slots(5, self._override("GFA01")) == ["PLA (#FFFFFF)"]
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_matte_requirement_accepts_matte(self, mock_pm, scheduler):
+        """The correct variant being loaded satisfies the override."""
+        mock_pm.get_status.return_value = self._status([self._white("GFA00"), self._white("GFA01")])
+        assert scheduler._get_missing_force_color_slots(5, self._override("GFA01")) == []
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_blank_loaded_idx_falls_back_to_type_and_colour(self, mock_pm, scheduler):
+        """A custom/third-party spool reports a blank tray_info_idx, so it must
+        still satisfy a variant-specific requirement (type+colour fallback)."""
+        mock_pm.get_status.return_value = self._status([self._white("")])
+        assert scheduler._get_missing_force_color_slots(5, self._override("GFA01")) == []
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_requirement_without_idx_unchanged(self, mock_pm, scheduler):
+        """An older 3MF whose override carries no idx keeps the historical
+        type+colour behaviour and matches any white PLA."""
+        mock_pm.get_status.return_value = self._status([self._white("GFA06")])
+        assert scheduler._get_missing_force_color_slots(5, self._override(None)) == []

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

@@ -330,6 +330,55 @@ class TestSanitizeLogContent:
         assert "/home/[user]/" in result
         assert "/home/[user]/" in result
         assert "[IP]" in result
         assert "[IP]" in result
 
 
+    def test_ldap_dn_redacted_reporter_line(self):
+        """#2681: the exact reporter line — the CN (real name) must not survive."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = (
+            "LDAP authentication successful for user: jschmoe "
+            "(DN: CN=Joe Schmoe,CN=Users,DC=ad,DC=example,DC=com, groups: 4)"
+        )
+        result = _sanitize_log_content(content)
+        assert "Joe Schmoe" not in result
+        assert "DC=example" not in result
+        assert result == "LDAP authentication successful for user: jschmoe (DN: [DN], groups: 4)"
+
+    def test_ldap_dn_redacted_in_exception_string(self):
+        """DNs that leak indirectly via ldap3 exception text are caught too."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = "LDAP bind failed for user jschmoe: invalidCredentials at uid=jschmoe,ou=people,dc=example,dc=org"
+        result = _sanitize_log_content(content)
+        assert "uid=jschmoe" not in result
+        assert "[DN]" in result
+
+    def test_ldap_group_dn_redacted(self):
+        """Group DNs (from group-mapping logs) are PII-bearing and redacted."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = "Mapped CN=Admins,OU=Groups,DC=corp,DC=local -> Administrators"
+        result = _sanitize_log_content(content)
+        assert "CN=Admins" not in result
+        assert "DC=corp" not in result
+        assert "[DN]" in result
+        assert "Administrators" in result  # the non-PII target group name survives
+
+    def test_non_dn_key_value_line_not_clobbered(self):
+        """An ordinary key=value log line must not be mistaken for a DN."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = "Dispatch decision: mode=queue, state=FINISH, printer=1"
+        result = _sanitize_log_content(content)
+        assert result == content
+
+    def test_single_rdn_not_redacted(self):
+        """A lone attr=value (not a multi-component DN) is left alone."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = "Country C=US selected"
+        result = _sanitize_log_content(content)
+        assert result == content
+
 
 
 class TestCollectSupportInfo:
 class TestCollectSupportInfo:
     """Tests for _collect_support_info() new diagnostic sections."""
     """Tests for _collect_support_info() new diagnostic sections."""

+ 35 - 32
backend/tests/unit/test_vp_mqtt_bridge.py

@@ -845,46 +845,49 @@ class TestPushStatusCache:
         await bridge.stop()
         await bridge.stop()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_tray_exist_bits_skips_ams_ht_units(self):
-        """AMS-HT units (id >= 128) use a separate addressing scheme and
-        must not be touched by the bitmask cleanup — bit math at
-        global_bit = ams_id * 4 + tray_id would overrun normal AMS bits.
-        Pin the skip so future AMS-HT support doesn't accidentally wipe
-        loaded HT slots.
+    async def test_tray_exist_bits_clears_empty_ams_ht_unit(self):
+        """AMS-HT (id 128-135) presence rides bit 16+(ams_id-128), so the bridge
+        cache clears an empty HT slot just like the internal AMS card — keeping
+        the slicer-facing view in sync (#1726, #2670). Loaded (bit 16 set) is
+        preserved; empty (bit 16 clear) is wiped.
         """
         """
         server = _make_server()
         server = _make_server()
         bridge = _make_bridge(server)
         bridge = _make_bridge(server)
         await bridge.start()
         await bridge.start()
 
 
-        bridge._on_printer_raw(
-            f"device/{H2D_SERIAL}/report",
-            json.dumps(
-                {
-                    "print": {
-                        "command": "push_status",
-                        "ams": {
-                            "ams": [
-                                {
-                                    "id": "128",
-                                    "tray": [
-                                        {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
-                                    ],
-                                }
-                            ],
-                            "tray_exist_bits": "0",
-                            "power_on_flag": True,
-                        },
+        def _push(tray_exist_bits: str) -> dict:
+            bridge._on_printer_raw(
+                f"device/{H2D_SERIAL}/report",
+                json.dumps(
+                    {
+                        "print": {
+                            "command": "push_status",
+                            "ams": {
+                                "ams": [
+                                    {
+                                        "id": "128",
+                                        "tray": [
+                                            {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
+                                        ],
+                                    }
+                                ],
+                                "tray_exist_bits": tray_exist_bits,
+                                "power_on_flag": True,
+                            },
+                        }
                     }
                     }
-                }
-            ).encode(),
-        )
+                ).encode(),
+            )
+
+        # Loaded: bit 16 set → HT slot preserved.
+        _push("10000")
         await asyncio.sleep(0.01)
         await asyncio.sleep(0.01)
+        assert bridge.get_latest_print_state()["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
 
 
-        cached = bridge.get_latest_print_state()
-        ht_slot = cached["ams"]["ams"][0]["tray"][0]
-        # tray_exist_bits="0" alone would normally wipe — but AMS-HT is
-        # skipped, so the HT slot keeps its loaded data.
-        assert ht_slot["tray_type"] == "PLA"
+        # Empty: bit 16 clear → HT slot wiped so the slicer sees no phantom spool.
+        _push("0")
+        await asyncio.sleep(0.01)
+        assert bridge.get_latest_print_state()["ams"]["ams"][0]["tray"][0]["tray_type"] == ""
 
 
         await bridge.stop()
         await bridge.stop()
 
 

+ 43 - 0
frontend/src/__tests__/components/FilamentMapping.test.tsx

@@ -297,4 +297,47 @@ describe('FilamentMapping — FTS routing', () => {
       expect(swatch).toBeInTheDocument();
       expect(swatch).toBeInTheDocument();
     });
     });
   });
   });
+
+  it('pins the gram usage so a long name cannot clip it (#2669)', async () => {
+    // Long resolved name + gram usage. The name must be the truncating
+    // element; the "(25g)" must sit in its own non-truncating, shrink-0 span
+    // so it stays visible on narrow/mobile widths.
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus({}))),
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Polymaker PLA Matte' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.get('/api/v1/inventory/colors/by-material', () => HttpResponse.json({ color_name: null })),
+    );
+
+    render(
+      <FilamentMapping
+        printerId={1}
+        filamentReqs={{
+          filaments: [
+            { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, nozzle_id: 1, tray_info_idx: 'GFA01' },
+          ],
+        }}
+        manualMappings={{}}
+        onManualMappingChange={() => {}}
+        currencySymbol="$"
+        defaultCostPerKg={0}
+        defaultExpanded
+      />,
+    );
+
+    const grams = await screen.findByText('(25g)');
+    // The gram usage never truncates and never shrinks away.
+    expect(grams.className).toContain('shrink-0');
+    expect(grams.className).not.toContain('truncate');
+
+    // The name is the element that truncates instead.
+    const name = await screen.findByText('Polymaker PLA Matte');
+    expect(name.className).toContain('truncate');
+    // Name and grams are separate siblings, so the name shrinking can't take
+    // the grams with it.
+    expect(name).not.toBe(grams);
+    expect(grams.parentElement).toBe(name.parentElement);
+  });
 });
 });

+ 28 - 0
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { screen, fireEvent, waitFor } from '@testing-library/react';
 import { screen, fireEvent, waitFor } from '@testing-library/react';
 import { render } from '../utils';
 import { render } from '../utils';
 import { ModelViewerModal } from '../../components/ModelViewerModal';
 import { ModelViewerModal } from '../../components/ModelViewerModal';
+import { setStreamToken } from '../../api/client';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import { server } from '../mocks/server';
 
 
@@ -385,6 +386,33 @@ describe('ModelViewerModal', () => {
       });
       });
     });
     });
 
 
+    // #2661: plate-thumbnail endpoints are gated behind a camera stream token
+    // (an <img> can't send a Bearer header), so the src must carry ?token=.
+    // Without it the 3D Preview thumbnails 401 while the Slice dialog (which
+    // already appends the token) shows the same file's thumbnails fine.
+    it('appends the camera stream token to plate thumbnail URLs', async () => {
+      setStreamToken('tok-2661');
+      try {
+        render(
+          <ModelViewerModal
+            archiveId={1}
+            title="Test Model"
+            onClose={mockOnClose}
+          />
+        );
+
+        await waitFor(() => {
+          expect(screen.getByText('Plate 1')).toBeInTheDocument();
+        });
+
+        const thumb = screen.getByAltText('Plate 1') as HTMLImageElement;
+        expect(thumb.src).toContain('/api/v1/archives/1/plates/1/thumbnail');
+        expect(thumb.src).toContain('token=tok-2661');
+      } finally {
+        setStreamToken(null);
+      }
+    });
+
     it('allows plate selection via click', async () => {
     it('allows plate selection via click', async () => {
       render(
       render(
         <ModelViewerModal
         <ModelViewerModal

+ 56 - 0
frontend/src/__tests__/components/spool-form/PAProfileSectionNozzle.test.tsx

@@ -0,0 +1,56 @@
+/**
+ * Regression test for the PA-Profil picker's nozzle blindness (#2618).
+ *
+ * When a printer has two K-profiles for the same filament that differ only in
+ * nozzle size (e.g. PAHT-CF at 0.4mm K=0.042 and 0.6mm K=0.028), the picker
+ * must offer BOTH and label each with its nozzle diameter — not collapse to a
+ * single entry. The underlying cause lived in the fetch (it defaulted to the
+ * 0.4mm nozzle and never retrieved the 0.6mm profile); this test guards the
+ * rendering half: given both calibrations, the section shows both with a
+ * nozzle badge so they are distinguishable.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { I18nextProvider } from 'react-i18next';
+import i18n from '../../../i18n';
+import { PAProfileSection } from '../../../components/spool-form/PAProfileSection';
+import { defaultFormData } from '../../../components/spool-form/types';
+import type { PrinterWithCalibrations } from '../../../components/spool-form/types';
+
+const printers = [
+  {
+    printer: { id: 1, name: 'H2D', connected: true },
+    calibrations: [
+      // Same filament (non-generic id → id-match), different nozzle + extruder.
+      { cali_idx: 10, filament_id: 'GFN05', setting_id: '', name: 'PAHT-CF', k_value: 0.042, n_coef: 0, extruder_id: 0, nozzle_diameter: '0.4' },
+      { cali_idx: 11, filament_id: 'GFN05', setting_id: '', name: 'PAHT-CF', k_value: 0.028, n_coef: 0, extruder_id: 1, nozzle_diameter: '0.6' },
+    ],
+  },
+] as unknown as PrinterWithCalibrations[];
+
+describe('PAProfileSection nozzle-specific profiles (#2618)', () => {
+  it('renders both nozzle profiles for one filament, each with a nozzle badge', () => {
+    render(
+      <I18nextProvider i18n={i18n}>
+        <PAProfileSection
+          formData={{ ...defaultFormData, material: 'PAHT-CF', slicer_filament: 'GFN05' }}
+          updateField={vi.fn()}
+          printersWithCalibrations={printers}
+          selectedProfiles={new Set()}
+          setSelectedProfiles={vi.fn()}
+          expandedPrinters={new Set(['1'])}
+          setExpandedPrinters={vi.fn()}
+        />
+      </I18nextProvider>,
+    );
+
+    // Both nozzle-specific K values are offered — not just the 0.4mm one.
+    expect(screen.getByText('K=0.042')).toBeInTheDocument();
+    expect(screen.getByText('K=0.028')).toBeInTheDocument();
+
+    // Each is labelled by its nozzle so identically-named profiles are distinct.
+    expect(screen.getByText('0.4mm')).toBeInTheDocument();
+    expect(screen.getByText('0.6mm')).toBeInTheDocument();
+  });
+});

+ 32 - 0
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -62,6 +62,9 @@ const mockFiles = [
     print_count: 5,
     print_count: 5,
     duplicate_count: 0,
     duplicate_count: 0,
     created_at: '2024-01-01T00:00:00Z',
     created_at: '2024-01-01T00:00:00Z',
+    // #2680: real on-disk mtime in a distinctive year so the display test can
+    // prove fs_modified_at is preferred over created_at (2024).
+    fs_modified_at: '2030-06-15T12:00:00Z',
   },
   },
   {
   {
     id: 2,
     id: 2,
@@ -1080,4 +1083,33 @@ describe('FileManagerPage', () => {
       expect(includeRootValues).toContain('false');
       expect(includeRootValues).toContain('false');
     });
     });
   });
   });
+
+  describe('last-modified date display (#2680)', () => {
+    it('is hidden by default and revealed by the toolbar toggle', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      // Hidden by default.
+      expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
+
+      // Toggle on via the toolbar button.
+      await user.click(screen.getByTitle('Show modified dates'));
+
+      // benchy carries fs_modified_at in 2030, which must be preferred over its
+      // created_at (2024) — proving the real on-disk mtime drives the display.
+      await waitFor(() => {
+        expect(screen.getByText(/2030/)).toBeInTheDocument();
+      });
+
+      // Toggling off hides it again.
+      await user.click(screen.getByTitle('Hide modified dates'));
+      await waitFor(() => {
+        expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
+      });
+    });
+  });
 });
 });

+ 106 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -249,6 +249,51 @@ describe('QueuePage', () => {
     });
     });
   });
   });
 
 
+  describe('history pagination', () => {
+    // #2682: History rendered the full count in the header but only ever drew
+    // the first 50 rows, with no way to reach the rest. It now paginates with
+    // a "Show more" control.
+    const manyHistory = Array.from({ length: 60 }, (_, i) => ({
+      ...mockQueueItems[2],
+      id: 100 + i,
+      batch_id: null,
+      archive_name: `History Item ${String(i).padStart(2, '0')}`,
+      // Descending completed_at so index 0 is newest and sorts first; the
+      // default History sort is by date, newest first.
+      completed_at: new Date(Date.UTC(2024, 0, 1, 0, 0, 0) - i * 60000).toISOString(),
+    }));
+
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json(manyHistory);
+        })
+      );
+    });
+
+    it('caps the History list at one page and reveals the rest on Show more', async () => {
+      const user = userEvent.setup();
+      render(<QueuePage />);
+
+      await user.click(await screen.findByRole('button', { name: /^History/ }));
+
+      // First page is drawn; an item past the 50-row cap is not.
+      await waitFor(() => {
+        expect(screen.getByText('History Item 00')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('History Item 59')).not.toBeInTheDocument();
+      expect(screen.getByText('Showing 50 of 60')).toBeInTheDocument();
+
+      // Show more reveals the remainder and then disappears (nothing left).
+      await user.click(screen.getByRole('button', { name: /show more/i }));
+
+      await waitFor(() => {
+        expect(screen.getByText('History Item 59')).toBeInTheDocument();
+      });
+      expect(screen.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument();
+    });
+  });
+
   describe('empty state', () => {
   describe('empty state', () => {
     it('shows empty state when no queue items', async () => {
     it('shows empty state when no queue items', async () => {
       server.use(
       server.use(
@@ -534,4 +579,65 @@ describe('QueuePage', () => {
       expect(attempts).toBe(2);
       expect(attempts).toBe(2);
     });
     });
   });
   });
+
+  // #2667: mobile can't drag-reorder the queue, so pending rows get up/down
+  // arrows. They persist via the same POST /queue/reorder as drag. The
+  // buttons live in the DOM at every width (Tailwind `sm:hidden` is CSS-only),
+  // so they're clickable in jsdom.
+  describe('mobile reorder arrows (#2667)', () => {
+    const threePending = [1, 2, 3].map((n) => ({
+      ...mockQueueItems[0],
+      id: n,
+      archive_id: n,
+      position: n,
+      status: 'pending',
+      archive_name: `Pending ${n}`,
+    }));
+
+    it('renders Move Up / Move Down controls for pending items', async () => {
+      server.use(http.get('/api/v1/queue/', () => HttpResponse.json(threePending)));
+      render(<QueuePage />);
+
+      await waitFor(() => expect(screen.getByText('Pending 1')).toBeInTheDocument());
+
+      // One pair per pending row.
+      expect(screen.getAllByTitle('Move Up')).toHaveLength(3);
+      expect(screen.getAllByTitle('Move Down')).toHaveLength(3);
+    });
+
+    it('moving the first item down persists the swapped order', async () => {
+      let reorderBody: { items: { id: number; position: number }[] } | null = null;
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json(threePending)),
+        http.post('/api/v1/queue/reorder', async ({ request }) => {
+          reorderBody = (await request.json()) as typeof reorderBody;
+          return HttpResponse.json({ message: 'ok' });
+        }),
+      );
+      render(<QueuePage />);
+
+      await waitFor(() => expect(screen.getByText('Pending 1')).toBeInTheDocument());
+
+      // First row's "Move Down": item 1 drops below item 2 → [2, 1, 3].
+      await userEvent.click(screen.getAllByTitle('Move Down')[0]);
+
+      await waitFor(() => expect(reorderBody).not.toBeNull());
+      expect(reorderBody!.items).toEqual([
+        { id: 2, position: 1 },
+        { id: 1, position: 2 },
+        { id: 3, position: 3 },
+      ]);
+    });
+
+    it('disables Move Up on the first row and Move Down on the last', async () => {
+      server.use(http.get('/api/v1/queue/', () => HttpResponse.json(threePending)));
+      render(<QueuePage />);
+
+      await waitFor(() => expect(screen.getByText('Pending 1')).toBeInTheDocument());
+
+      // Rows render top-to-bottom in position order.
+      expect(screen.getAllByTitle('Move Up')[0]).toBeDisabled();
+      expect(screen.getAllByTitle('Move Down')[2]).toBeDisabled();
+    });
+  });
 });
 });

+ 54 - 0
frontend/src/__tests__/utils/installedNozzleDiameters.test.ts

@@ -0,0 +1,54 @@
+/**
+ * Tests for installedNozzleDiameters helper (#2618).
+ *
+ * The spool PA-Profil picker must fetch K-profiles across every nozzle the
+ * printer actually has installed, not just the hardcoded 0.4mm default — else
+ * a 0.6mm profile for the same filament is never surfaced. This helper lists
+ * the distinct reported diameters, skipping empty/non-positive defaults, and
+ * returns an empty array when the hardware hasn't been reported so the caller
+ * can keep its own fallback.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import { installedNozzleDiameters } from '../../utils/amsHelpers';
+
+describe('installedNozzleDiameters', () => {
+  it('returns an empty array when status is null or undefined', () => {
+    expect(installedNozzleDiameters(null)).toEqual([]);
+    expect(installedNozzleDiameters(undefined)).toEqual([]);
+  });
+
+  it('returns an empty array when no nozzles are reported', () => {
+    expect(installedNozzleDiameters({ nozzles: [] })).toEqual([]);
+    expect(installedNozzleDiameters({})).toEqual([]);
+  });
+
+  it('skips empty-string and non-positive nozzle defaults', () => {
+    expect(
+      installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '' }, { nozzle_diameter: '0' }] }),
+    ).toEqual([]);
+  });
+
+  it('returns the single installed diameter', () => {
+    expect(installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '0.4' }] })).toEqual(['0.4']);
+  });
+
+  it('returns both diameters on a dual-nozzle printer, in order', () => {
+    expect(
+      installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.6' }] }),
+    ).toEqual(['0.4', '0.6']);
+  });
+
+  it('dedupes repeated diameters (two 0.4 hotends report once)', () => {
+    expect(
+      installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.4' }] }),
+    ).toEqual(['0.4']);
+  });
+
+  it('keeps only the valid diameter when one hotend is still an empty default', () => {
+    expect(
+      installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '0.6' }, { nozzle_diameter: '' }] }),
+    ).toEqual(['0.6']);
+  });
+});

+ 41 - 1
frontend/src/__tests__/utils/printer.test.ts

@@ -8,7 +8,8 @@
  */
  */
 
 
 import { describe, it, expect } from 'vitest';
 import { describe, it, expect } from 'vitest';
-import { getPrinterImage, isGcodeCompatible } from '../../utils/printer';
+import { getPrinterImage, isGcodeCompatible, filterCompatibleQueueItems } from '../../utils/printer';
+import type { PrintQueueItem } from '../../api/client';
 
 
 describe('getPrinterImage', () => {
 describe('getPrinterImage', () => {
   describe('X2D (#988)', () => {
   describe('X2D (#988)', () => {
@@ -136,3 +137,42 @@ describe('isGcodeCompatible', () => {
     expect(isGcodeCompatible('H2D Pro', 'H2DPRO')).toBe(true);
     expect(isGcodeCompatible('H2D Pro', 'H2DPRO')).toBe(true);
   });
   });
 });
 });
+
+describe('filterCompatibleQueueItems — force-color PLA variant (#2650)', () => {
+  const makeItem = (
+    overrides: Array<{ slot_id: number; type: string; color: string; tray_info_idx?: string; force_color_match?: boolean }>,
+  ): PrintQueueItem => ({ id: 1, filament_overrides: overrides } as unknown as PrintQueueItem);
+
+  // A job sliced for White PLA Matte (GFA01).
+  const matteJob = makeItem([
+    { slot_id: 1, type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA01', force_color_match: true },
+  ]);
+  const loadedTypes = new Set(['PLA']);
+  const loaded = new Set(['PLA:ffffff']);
+
+  it('rejects a printer loaded only with other white PLA variants (Basic/Silk)', () => {
+    const variants = new Set(['PLA:ffffff:GFA00', 'PLA:ffffff:GFA06']);
+    expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(0);
+  });
+
+  it('accepts a printer loaded with the matching variant (Matte GFA01)', () => {
+    const variants = new Set(['PLA:ffffff:GFA00', 'PLA:ffffff:GFA01']);
+    expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(1);
+  });
+
+  it('accepts a same-colour spool that reports no tray_info_idx (custom/third-party)', () => {
+    const variants = new Set(['PLA:ffffff:']);
+    expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(1);
+  });
+
+  it('falls back to type+colour when no variant data is supplied', () => {
+    // loadedVariants omitted → the hint is never stricter than the data it has.
+    expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded)).toHaveLength(1);
+  });
+
+  it('an override without a tray_info_idx keeps the old type+colour behaviour', () => {
+    const noIdxJob = makeItem([{ slot_id: 1, type: 'PLA', color: '#FFFFFF', force_color_match: true }]);
+    const variants = new Set(['PLA:ffffff:GFA06']);
+    expect(filterCompatibleQueueItems([noIdxJob], loadedTypes, loaded, variants)).toHaveLength(1);
+  });
+});

+ 4 - 1
frontend/src/api/client.ts

@@ -2192,7 +2192,7 @@ export interface PrintQueueItem {
   // PrintModal's deficit warning was acknowledged.
   // PrintModal's deficit warning was acknowledged.
   skip_filament_check: boolean;
   skip_filament_check: boolean;
   ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints
   ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints
-  filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;  // Filament overrides for model-based assignment
+  filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null;  // Filament overrides for model-based assignment
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
   // Print options
   // Print options
   bed_levelling: CalibrationMode;
   bed_levelling: CalibrationMode;
@@ -6885,6 +6885,9 @@ export interface LibraryFileListItem {
   created_by_id: number | null;
   created_by_id: number | null;
   created_by_username: string | null;
   created_by_username: string | null;
   created_at: string;
   created_at: string;
+  // Real on-disk modification time (#2680). Null for managed uploads; the date
+  // sort and "Modified" column use `fs_modified_at ?? created_at`.
+  fs_modified_at: string | null;
   print_name: string | null;
   print_name: string | null;
   print_time_seconds: number | null;
   print_time_seconds: number | null;
   filament_used_grams: number | null;
   filament_used_grams: number | null;

+ 2 - 2
frontend/src/components/ModelViewerModal.tsx

@@ -5,7 +5,7 @@ import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Mi
 import { ModelViewer } from './ModelViewer';
 import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { Button } from './Button';
-import { api } from '../api/client';
+import { api, withStreamToken } from '../api/client';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 
@@ -477,7 +477,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
                           >
                           >
                             {plate.has_thumbnail && plate.thumbnail_url ? (
                             {plate.has_thumbnail && plate.thumbnail_url ? (
                               <img
                               <img
-                                src={plate.thumbnail_url}
+                                src={withStreamToken(plate.thumbnail_url)}
                                 alt={`Plate ${plate.index}`}
                                 alt={`Plate ${plate.index}`}
                                 className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
                                 className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
                               />
                               />

+ 6 - 3
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -243,8 +243,10 @@ export function FilamentMapping({
                 <span title={`Required: ${resolvedName} - ${colorLabel}`}>
                 <span title={`Required: ${resolvedName} - ${colorLabel}`}>
                   <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
                   <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
                 </span>
                 </span>
-                {/* Required type + grams + nozzle badge */}
-                <span className="text-white truncate flex items-center gap-1">
+                {/* Required type + grams + nozzle badge. Only the name
+                    truncates; the gram usage is pinned (shrink-0) so it never
+                    clips on narrow/mobile widths (#2669). */}
+                <span className="text-white flex items-center gap-1 min-w-0">
                   {isDualNozzle && item.nozzle_id != null && (
                   {isDualNozzle && item.nozzle_id != null && (
                     <span
                     <span
                       className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
                       className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
@@ -253,7 +255,8 @@ export function FilamentMapping({
                       {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
                       {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
                     </span>
                     </span>
                   )}
                   )}
-                  {resolvedName} <span className="text-bambu-gray">({item.used_grams}g)</span>
+                  <span className="truncate min-w-0" title={resolvedName}>{resolvedName}</span>
+                  <span className="text-bambu-gray shrink-0 whitespace-nowrap">({item.used_grams}g)</span>
                 </span>
                 </span>
                 {/* Arrow */}
                 {/* Arrow */}
                 <span className="text-bambu-gray">→</span>
                 <span className="text-bambu-gray">→</span>

+ 5 - 2
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -161,8 +161,11 @@ function InlineMappingEditor({
           <span title={`Required: ${req.type} - ${getColorName(req.color)}`}>
           <span title={`Required: ${req.type} - ${getColorName(req.color)}`}>
             <Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
             <Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
           </span>
           </span>
-          <span className="text-white truncate">
-            {req.type} <span className="text-bambu-gray">({req.used_grams}g)</span>
+          {/* Only the name truncates; the gram usage is pinned (shrink-0) so
+              it never clips on narrow/mobile widths (#2669). */}
+          <span className="text-white flex items-center gap-1 min-w-0">
+            <span className="truncate min-w-0" title={req.type}>{req.type}</span>
+            <span className="text-bambu-gray shrink-0 whitespace-nowrap">({req.used_grams}g)</span>
           </span>
           </span>
           <span className="text-bambu-gray">→</span>
           <span className="text-bambu-gray">→</span>
           <select
           <select

+ 3 - 2
frontend/src/components/PrinterQueueWidget.tsx

@@ -11,10 +11,11 @@ interface PrinterQueueWidgetProps {
   printerModel?: string | null;
   printerModel?: string | null;
   loadedFilamentTypes?: Set<string>;
   loadedFilamentTypes?: Set<string>;
   loadedFilaments?: Set<string>;  // "TYPE:rrggbb" pairs for filament override color matching
   loadedFilaments?: Set<string>;  // "TYPE:rrggbb" pairs for filament override color matching
+  loadedVariants?: Set<string>;  // "TYPE:rrggbb:idx" triples for PLA sub-variant matching (#2650)
   variant?: 'card' | 'panelExtension';
   variant?: 'card' | 'panelExtension';
 }
 }
 
 
-export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentTypes, loadedFilaments, variant = 'card' }: PrinterQueueWidgetProps) {
+export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentTypes, loadedFilaments, loadedVariants, variant = 'card' }: PrinterQueueWidgetProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: queue } = useQuery({
   const { data: queue } = useQuery({
     queryKey: ['queue', printerId, 'pending', printerModel],
     queryKey: ['queue', printerId, 'pending', printerModel],
@@ -23,7 +24,7 @@ export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentType
   });
   });
 
 
   // Filter queue to items this printer can actually print (filament type + color check)
   // Filter queue to items this printer can actually print (filament type + color check)
-  const compatibleQueue = queue ? filterCompatibleQueueItems(queue, loadedFilamentTypes, loadedFilaments) : undefined;
+  const compatibleQueue = queue ? filterCompatibleQueueItems(queue, loadedFilamentTypes, loadedFilaments, loadedVariants) : undefined;
   const totalPending = compatibleQueue?.length || 0;
   const totalPending = compatibleQueue?.length || 0;
 
 
   if (totalPending === 0) {
   if (totalPending === 0) {

+ 4 - 16
frontend/src/components/SpoolFormModal.tsx

@@ -8,7 +8,7 @@ import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
 import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
 import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
 import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
-import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } from './spool-form/utils';
+import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } from './spool-form/utils';
 import { MATERIALS } from './spool-form/constants';
 import { MATERIALS } from './spool-form/constants';
 import { FilamentSection } from './spool-form/FilamentSection';
 import { FilamentSection } from './spool-form/FilamentSection';
 import { ColorSection } from './spool-form/ColorSection';
 import { ColorSection } from './spool-form/ColorSection';
@@ -201,21 +201,9 @@ export function SpoolFormModal({
               const connected = status?.connected ?? false;
               const connected = status?.connected ?? false;
               let calibrations: PrinterWithCalibrations['calibrations'] = [];
               let calibrations: PrinterWithCalibrations['calibrations'] = [];
               if (connected) {
               if (connected) {
-                try {
-                  const kRes = await api.getKProfiles(printer.id);
-                  calibrations = kRes.profiles.map(p => ({
-                    cali_idx: p.slot_id,
-                    filament_id: p.filament_id,
-                    setting_id: p.setting_id || '',
-                    name: p.name,
-                    k_value: parseFloat(p.k_value) || 0,
-                    n_coef: parseFloat(p.n_coef) || 0,
-                    extruder_id: p.extruder_id,
-                    nozzle_diameter: p.nozzle_diameter,
-                  }));
-                } catch {
-                  // Printer may not support K-profiles
-                }
+                // Fetch across every installed nozzle so dual-nozzle printers
+                // surface both the 0.4mm and 0.6mm K-profiles, not just 0.4 (#2618).
+                calibrations = await fetchPrinterCalibrations(printer.id, status);
               }
               }
               results.push({ printer: { ...printer, connected }, calibrations });
               results.push({ printer: { ...printer, connected }, calibrations });
             }
             }

+ 5 - 0
frontend/src/components/spool-form/PAProfileSection.tsx

@@ -127,6 +127,11 @@ export function PAProfileSection({
           </span>
           </span>
         </div>
         </div>
         <div className="flex items-center gap-2 shrink-0">
         <div className="flex items-center gap-2 shrink-0">
+          {cal.nozzle_diameter && (
+            <span className="text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray">
+              {cal.nozzle_diameter}mm
+            </span>
+          )}
           <span className="text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray">
           <span className="text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray">
             K={cal.k_value.toFixed(3)}
             K={cal.k_value.toFixed(3)}
           </span>
           </span>

+ 42 - 1
frontend/src/components/spool-form/utils.ts

@@ -1,7 +1,48 @@
+import { api } from '../../api/client';
 import type { SlicerSetting, LocalPreset, BuiltinFilament } from '../../api/client';
 import type { SlicerSetting, LocalPreset, BuiltinFilament } from '../../api/client';
-import type { ColorPreset, FilamentOption } from './types';
+import { installedNozzleDiameters } from '../../utils/amsHelpers';
+import type { CalibrationProfile, ColorPreset, FilamentOption } from './types';
 import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
 import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
 
 
+/**
+ * Fetch a printer's K-profiles across every nozzle it actually has installed
+ * (#2618) and flatten them into CalibrationProfile rows for the PA-Profil
+ * picker. `getKProfiles` filters strictly by nozzle diameter and defaults to
+ * "0.4", so a single call hides non-0.4 profiles (e.g. a 0.6mm PAHT-CF K
+ * value) — the picker then shows only one of two nozzle-specific profiles.
+ * We query each installed diameter and merge. Falls back to "0.4" when the
+ * printer hasn't reported nozzle hardware, preserving prior behaviour.
+ * Per-diameter failures are swallowed (a printer that doesn't support the
+ * endpoint just yields no rows), matching the callers' previous try/catch.
+ */
+export async function fetchPrinterCalibrations(
+  printerId: number,
+  status: { nozzles?: { nozzle_diameter?: string }[] } | null | undefined,
+): Promise<CalibrationProfile[]> {
+  const diameters = installedNozzleDiameters(status);
+  const toFetch = diameters.length > 0 ? diameters : ['0.4'];
+  const responses = await Promise.all(
+    toFetch.map(d => api.getKProfiles(printerId, d).catch(() => null)),
+  );
+  const calibrations: CalibrationProfile[] = [];
+  for (const res of responses) {
+    if (!res) continue;
+    for (const p of res.profiles) {
+      calibrations.push({
+        cali_idx: p.slot_id,
+        filament_id: p.filament_id,
+        setting_id: p.setting_id || '',
+        name: p.name,
+        k_value: parseFloat(p.k_value) || 0,
+        n_coef: parseFloat(p.n_coef) || 0,
+        extruder_id: p.extruder_id,
+        nozzle_diameter: p.nozzle_diameter,
+      });
+    }
+  }
+  return calibrations;
+}
+
 // Fallback filament presets when cloud is not available
 // Fallback filament presets when cloud is not available
 const FALLBACK_PRESETS: FilamentOption[] = [
 const FALLBACK_PRESETS: FilamentOption[] = [
   { code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'] },
   { code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'] },

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

@@ -1205,6 +1205,8 @@ export default {
     history: {
     history: {
       emptyTitle: 'Noch kein Verlauf',
       emptyTitle: 'Noch kein Verlauf',
       emptyDescription: 'Abgeschlossene, abgebrochene und fehlgeschlagene Drucke erscheinen hier.',
       emptyDescription: 'Abgeschlossene, abgebrochene und fehlgeschlagene Drucke erscheinen hier.',
+      showMore: 'Mehr anzeigen',
+      showingCount: '{{shown}} von {{total}} werden angezeigt',
     },
     },
     dragGhost: {
     dragGhost: {
       multiCount: '{{count}} Einträge',
       multiCount: '{{count}} Einträge',
@@ -3647,6 +3649,9 @@ export default {
     prints: 'Drucke',
     prints: 'Drucke',
     ascending: 'Aufsteigend',
     ascending: 'Aufsteigend',
     descending: 'Absteigend',
     descending: 'Absteigend',
+    showModified: 'Änderungsdatum anzeigen',
+    hideModified: 'Änderungsdatum ausblenden',
+    lastModified: 'Zuletzt geändert',
     resultsCount: '{{showing}} von {{total}} Dateien',
     resultsCount: '{{showing}} von {{total}} Dateien',
     selectAll: 'Alle auswählen',
     selectAll: 'Alle auswählen',
     deselectAll: 'Auswahl aufheben',
     deselectAll: 'Auswahl aufheben',

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

@@ -1219,6 +1219,8 @@ export default {
     history: {
     history: {
       emptyTitle: 'No history yet',
       emptyTitle: 'No history yet',
       emptyDescription: 'Completed, cancelled, and failed prints will appear here.',
       emptyDescription: 'Completed, cancelled, and failed prints will appear here.',
+      showMore: 'Show more',
+      showingCount: 'Showing {{shown}} of {{total}}',
     },
     },
     // Drag ghost label when multi-dragging
     // Drag ghost label when multi-dragging
     dragGhost: {
     dragGhost: {
@@ -3676,6 +3678,9 @@ export default {
     prints: 'Prints',
     prints: 'Prints',
     ascending: 'Ascending',
     ascending: 'Ascending',
     descending: 'Descending',
     descending: 'Descending',
+    showModified: 'Show modified dates',
+    hideModified: 'Hide modified dates',
+    lastModified: 'Last modified',
     resultsCount: '{{showing}} of {{total}} files',
     resultsCount: '{{showing}} of {{total}} files',
     selectAll: 'Select All',
     selectAll: 'Select All',
     deselectAll: 'Deselect All',
     deselectAll: 'Deselect All',

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

@@ -1205,6 +1205,8 @@ export default {
     history: {
     history: {
       emptyTitle: 'Sin historial todavía',
       emptyTitle: 'Sin historial todavía',
       emptyDescription: 'Las impresiones completadas, canceladas y fallidas aparecerán aquí.',
       emptyDescription: 'Las impresiones completadas, canceladas y fallidas aparecerán aquí.',
+      showMore: 'Mostrar más',
+      showingCount: 'Mostrando {{shown}} de {{total}}',
     },
     },
     dragGhost: {
     dragGhost: {
       multiCount: '{{count}} elementos',
       multiCount: '{{count}} elementos',
@@ -3650,6 +3652,9 @@ export default {
     prints: 'Impresiones',
     prints: 'Impresiones',
     ascending: 'Ascendente',
     ascending: 'Ascendente',
     descending: 'Descendente',
     descending: 'Descendente',
+    showModified: 'Mostrar fechas de modificación',
+    hideModified: 'Ocultar fechas de modificación',
+    lastModified: 'Última modificación',
     resultsCount: '{{showing}} de {{total}} archivos',
     resultsCount: '{{showing}} de {{total}} archivos',
     selectAll: 'Seleccionar todo',
     selectAll: 'Seleccionar todo',
     deselectAll: 'Deseleccionar todo',
     deselectAll: 'Deseleccionar todo',

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

@@ -1205,6 +1205,8 @@ export default {
     history: {
     history: {
       emptyTitle: 'Aucun historique',
       emptyTitle: 'Aucun historique',
       emptyDescription: 'Les impressions terminées, annulées et échouées apparaîtront ici.',
       emptyDescription: 'Les impressions terminées, annulées et échouées apparaîtront ici.',
+      showMore: 'Afficher plus',
+      showingCount: 'Affichage de {{shown}} sur {{total}}',
     },
     },
     dragGhost: {
     dragGhost: {
       multiCount: '{{count}} éléments',
       multiCount: '{{count}} éléments',
@@ -3636,6 +3638,9 @@ export default {
     prints: 'Impressions',
     prints: 'Impressions',
     ascending: 'Croissant',
     ascending: 'Croissant',
     descending: 'Décroissant',
     descending: 'Décroissant',
+    showModified: 'Afficher les dates de modification',
+    hideModified: 'Masquer les dates de modification',
+    lastModified: 'Dernière modification',
     resultsCount: '{{showing}} sur {{total}} fichiers',
     resultsCount: '{{showing}} sur {{total}} fichiers',
     selectAll: 'Tout sélectionner',
     selectAll: 'Tout sélectionner',
     deselectAll: 'Tout désélectionner',
     deselectAll: 'Tout désélectionner',

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

@@ -1205,6 +1205,8 @@ export default {
     history: {
     history: {
       emptyTitle: 'Nessuna cronologia',
       emptyTitle: 'Nessuna cronologia',
       emptyDescription: 'Le stampe completate, annullate e fallite appariranno qui.',
       emptyDescription: 'Le stampe completate, annullate e fallite appariranno qui.',
+      showMore: 'Mostra altri',
+      showingCount: 'Visualizzati {{shown}} di {{total}}',
     },
     },
     dragGhost: {
     dragGhost: {
       multiCount: '{{count}} elementi',
       multiCount: '{{count}} elementi',
@@ -3635,6 +3637,9 @@ export default {
     prints: 'Stampe',
     prints: 'Stampe',
     ascending: 'Crescente',
     ascending: 'Crescente',
     descending: 'Decrescente',
     descending: 'Decrescente',
+    showModified: 'Mostra date di modifica',
+    hideModified: 'Nascondi date di modifica',
+    lastModified: 'Ultima modifica',
     resultsCount: '{{showing}} di {{total}} file',
     resultsCount: '{{showing}} di {{total}} file',
     selectAll: 'Seleziona tutto',
     selectAll: 'Seleziona tutto',
     deselectAll: 'Deseleziona tutto',
     deselectAll: 'Deseleziona tutto',

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

@@ -1204,6 +1204,8 @@ export default {
     history: {
     history: {
       emptyTitle: '履歴はまだありません',
       emptyTitle: '履歴はまだありません',
       emptyDescription: '完了・キャンセル・失敗した印刷がここに表示されます。',
       emptyDescription: '完了・キャンセル・失敗した印刷がここに表示されます。',
+      showMore: 'さらに表示',
+      showingCount: '{{total}} 件中 {{shown}} 件を表示',
     },
     },
     dragGhost: {
     dragGhost: {
       multiCount: '{{count}}件',
       multiCount: '{{count}}件',
@@ -3647,6 +3649,9 @@ export default {
     prints: '印刷回数',
     prints: '印刷回数',
     ascending: '昇順',
     ascending: '昇順',
     descending: '降順',
     descending: '降順',
+    showModified: '更新日時を表示',
+    hideModified: '更新日時を非表示',
+    lastModified: '最終更新',
     resultsCount: '{{total}}件中{{showing}}件',
     resultsCount: '{{total}}件中{{showing}}件',
     selectAll: 'すべて選択',
     selectAll: 'すべて選択',
     deselectAll: 'すべて選択解除',
     deselectAll: 'すべて選択解除',

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

@@ -1147,6 +1147,8 @@ export default {
     history: {
     history: {
       emptyTitle: '아직 기록이 없습니다',
       emptyTitle: '아직 기록이 없습니다',
       emptyDescription: '완료·취소·실패한 인쇄가 여기에 표시됩니다.',
       emptyDescription: '완료·취소·실패한 인쇄가 여기에 표시됩니다.',
+      showMore: '더 보기',
+      showingCount: '{{total}}개 중 {{shown}}개 표시',
     },
     },
     dragGhost: {
     dragGhost: {
       multiCount: '{{count}}개 항목',
       multiCount: '{{count}}개 항목',
@@ -3459,6 +3461,9 @@ export default {
     prints: '인쇄물',
     prints: '인쇄물',
     ascending: '오름차순',
     ascending: '오름차순',
     descending: '내림차순',
     descending: '내림차순',
+    showModified: '수정 날짜 표시',
+    hideModified: '수정 날짜 숨기기',
+    lastModified: '마지막 수정',
     resultsCount: '전체 {{total}}개 중 {{showing}}개',
     resultsCount: '전체 {{total}}개 중 {{showing}}개',
     selectAll: '모두 선택',
     selectAll: '모두 선택',
     deselectAll: '모두 선택 해제',
     deselectAll: '모두 선택 해제',

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

@@ -1205,6 +1205,8 @@ export default {
     history: {
     history: {
       emptyTitle: 'Sem histórico ainda',
       emptyTitle: 'Sem histórico ainda',
       emptyDescription: 'Impressões concluídas, canceladas e com falha aparecerão aqui.',
       emptyDescription: 'Impressões concluídas, canceladas e com falha aparecerão aqui.',
+      showMore: 'Mostrar mais',
+      showingCount: 'Mostrando {{shown}} de {{total}}',
     },
     },
     dragGhost: {
     dragGhost: {
       multiCount: '{{count}} itens',
       multiCount: '{{count}} itens',
@@ -3635,6 +3637,9 @@ export default {
     prints: 'Impressões',
     prints: 'Impressões',
     ascending: 'Crescente',
     ascending: 'Crescente',
     descending: 'Decrescente',
     descending: 'Decrescente',
+    showModified: 'Mostrar datas de modificação',
+    hideModified: 'Ocultar datas de modificação',
+    lastModified: 'Última modificação',
     resultsCount: '{{showing}} de {{total}} arquivos',
     resultsCount: '{{showing}} de {{total}} arquivos',
     selectAll: 'Selecionar tudo',
     selectAll: 'Selecionar tudo',
     deselectAll: 'Desmarcar tudo',
     deselectAll: 'Desmarcar tudo',

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

@@ -1153,6 +1153,8 @@ export default {
     history: {
     history: {
       emptyTitle: "История пока пуста",
       emptyTitle: "История пока пуста",
       emptyDescription: "Здесь появятся завершённые, отменённые и неудачные задания.",
       emptyDescription: "Здесь появятся завершённые, отменённые и неудачные задания.",
+      showMore: "Показать ещё",
+      showingCount: "Показано {{shown}} из {{total}}",
     },
     },
     dragGhost: {
     dragGhost: {
       multiCount: "{{count}} заданий",
       multiCount: "{{count}} заданий",
@@ -3451,6 +3453,9 @@ export default {
     prints: "Файлы печати",
     prints: "Файлы печати",
     ascending: "По возрастанию",
     ascending: "По возрастанию",
     descending: "По убыванию",
     descending: "По убыванию",
+    showModified: "Показать даты изменения",
+    hideModified: "Скрыть даты изменения",
+    lastModified: "Изменено",
     resultsCount: "Показано {{showing}} из {{total}} файлов",
     resultsCount: "Показано {{showing}} из {{total}} файлов",
     selectAll: "Выбрать всё",
     selectAll: "Выбрать всё",
     deselectAll: "Снять выделение",
     deselectAll: "Снять выделение",

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

@@ -1205,6 +1205,8 @@ export default {
     history: {
     history: {
       emptyTitle: 'Henüz geçmiş yok',
       emptyTitle: 'Henüz geçmiş yok',
       emptyDescription: 'Tamamlanan, iptal edilen ve başarısız baskılar burada görünür.',
       emptyDescription: 'Tamamlanan, iptal edilen ve başarısız baskılar burada görünür.',
+      showMore: 'Daha fazla göster',
+      showingCount: '{{total}} öğeden {{shown}} tanesi gösteriliyor',
     },
     },
     dragGhost: {
     dragGhost: {
       multiCount: '{{count}} öğe',
       multiCount: '{{count}} öğe',
@@ -3643,6 +3645,9 @@ export default {
     prints: 'Baskılar',
     prints: 'Baskılar',
     ascending: 'Artan',
     ascending: 'Artan',
     descending: 'Azalan',
     descending: 'Azalan',
+    showModified: 'Değiştirme tarihlerini göster',
+    hideModified: 'Değiştirme tarihlerini gizle',
+    lastModified: 'Son değiştirme',
     resultsCount: '{{total}} dosyadan {{showing}} tanesi',
     resultsCount: '{{total}} dosyadan {{showing}} tanesi',
     selectAll: 'Tümünü Seç',
     selectAll: 'Tümünü Seç',
     deselectAll: 'Seçimi Kaldır',
     deselectAll: 'Seçimi Kaldır',

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

@@ -1205,6 +1205,8 @@ export default {
     history: {
     history: {
       emptyTitle: '暂无历史',
       emptyTitle: '暂无历史',
       emptyDescription: '已完成、已取消和失败的打印将在此显示。',
       emptyDescription: '已完成、已取消和失败的打印将在此显示。',
+      showMore: '显示更多',
+      showingCount: '显示 {{total}} 项中的 {{shown}} 项',
     },
     },
     dragGhost: {
     dragGhost: {
       multiCount: '{{count}} 项',
       multiCount: '{{count}} 项',
@@ -3635,6 +3637,9 @@ export default {
     prints: '打印',
     prints: '打印',
     ascending: '升序',
     ascending: '升序',
     descending: '降序',
     descending: '降序',
+    showModified: '显示修改日期',
+    hideModified: '隐藏修改日期',
+    lastModified: '最后修改',
     resultsCount: '{{showing}} / {{total}} 个文件',
     resultsCount: '{{showing}} / {{total}} 个文件',
     selectAll: '全选',
     selectAll: '全选',
     deselectAll: '取消全选',
     deselectAll: '取消全选',

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

@@ -1205,6 +1205,8 @@ export default {
     history: {
     history: {
       emptyTitle: '目前沒有歷史記錄',
       emptyTitle: '目前沒有歷史記錄',
       emptyDescription: '已完成、已取消與失敗的列印將顯示於此。',
       emptyDescription: '已完成、已取消與失敗的列印將顯示於此。',
+      showMore: '顯示更多',
+      showingCount: '顯示 {{total}} 項中的 {{shown}} 項',
     },
     },
     dragGhost: {
     dragGhost: {
       multiCount: '{{count}} 項',
       multiCount: '{{count}} 項',
@@ -3635,6 +3637,9 @@ export default {
     prints: '列印',
     prints: '列印',
     ascending: '升序',
     ascending: '升序',
     descending: '降序',
     descending: '降序',
+    showModified: '顯示修改日期',
+    hideModified: '隱藏修改日期',
+    lastModified: '最後修改',
     resultsCount: '{{showing}} / {{total}} 個檔案',
     resultsCount: '{{showing}} / {{total}} 個檔案',
     selectAll: '全選',
     selectAll: '全選',
     deselectAll: '取消全選',
     deselectAll: '取消全選',

+ 44 - 3
frontend/src/pages/FileManagerPage.tsx

@@ -14,6 +14,7 @@ import {
   FolderPlus,
   FolderPlus,
   FileBox,
   FileBox,
   Clock,
   Clock,
+  CalendarClock,
   HardDrive,
   HardDrive,
   File,
   File,
   MoveRight,
   MoveRight,
@@ -69,7 +70,7 @@ import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
-import { formatDuration, parseUTCDate } from '../utils/date';
+import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
 import { formatFileSize } from '../utils/file';
 import { formatFileSize } from '../utils/file';
 
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
@@ -741,10 +742,11 @@ interface FileCardProps {
   hasPermission: (permission: Permission) => boolean;
   hasPermission: (permission: Permission) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
   authEnabled: boolean;
   authEnabled: boolean;
+  showModified: boolean;
   t: TFunction;
   t: TFunction;
 }
 }
 
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
 
 
   return (
   return (
@@ -811,6 +813,14 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
             {file.created_by_username}
             {file.created_by_username}
           </div>
           </div>
         )}
         )}
+        {/* #2680: last-modified date, toggled from the toolbar. Uses the real
+            on-disk mtime when known, else the DB created_at. */}
+        {showModified && (
+          <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1" title={t('fileManager.lastModified')}>
+            <CalendarClock className="w-3 h-3" />
+            {formatDate(file.fs_modified_at ?? file.created_at)}
+          </div>
+        )}
         {(file.tags?.length ?? 0) > 0 && (
         {(file.tags?.length ?? 0) > 0 && (
           <div className="mt-2 flex flex-wrap gap-1" onClick={(e) => e.stopPropagation()}>
           <div className="mt-2 flex flex-wrap gap-1" onClick={(e) => e.stopPropagation()}>
             {file.tags!.map((tg) => (
             {file.tags!.map((tg) => (
@@ -1078,6 +1088,10 @@ export function FileManagerPage() {
     const saved = localStorage.getItem('library-sort-direction');
     const saved = localStorage.getItem('library-sort-direction');
     return (saved as SortDirection) || 'asc';
     return (saved as SortDirection) || 'asc';
   });
   });
+  // Show/hide the last-modified date on each file card (#2680). Persisted.
+  const [showModified, setShowModified] = useState<boolean>(
+    () => localStorage.getItem('library-show-modified') === 'true'
+  );
 
 
   // Mobile detection for touch-friendly UI
   // Mobile detection for touch-friendly UI
   const isMobile = useIsMobile();
   const isMobile = useIsMobile();
@@ -1266,7 +1280,11 @@ export function FileManagerPage() {
           comparison = (a.print_name || a.filename).localeCompare(b.print_name || b.filename);
           comparison = (a.print_name || a.filename).localeCompare(b.print_name || b.filename);
           break;
           break;
         case 'date':
         case 'date':
-          comparison = (parseUTCDate(a.created_at)?.getTime() ?? 0) - (parseUTCDate(b.created_at)?.getTime() ?? 0);
+          // #2680: sort by real on-disk mtime (matches `ls -t`), falling back to
+          // the DB created_at for managed uploads that have no filesystem mtime.
+          comparison =
+            (parseUTCDate(a.fs_modified_at ?? a.created_at)?.getTime() ?? 0) -
+            (parseUTCDate(b.fs_modified_at ?? b.created_at)?.getTime() ?? 0);
           break;
           break;
         case 'size':
         case 'size':
           comparison = a.file_size - b.file_size;
           comparison = a.file_size - b.file_size;
@@ -2117,6 +2135,20 @@ export function FileManagerPage() {
                     <SortDesc className="w-4 h-4 text-white" />
                     <SortDesc className="w-4 h-4 text-white" />
                   )}
                   )}
                 </button>
                 </button>
+                <button
+                  onClick={() => setShowModified((v) => {
+                    const next = !v;
+                    localStorage.setItem('library-show-modified', String(next));
+                    return next;
+                  })}
+                  className={`p-1.5 rounded bg-bambu-dark border transition-colors ${
+                    showModified ? 'border-bambu-green text-bambu-green' : 'border-bambu-dark-tertiary text-white hover:border-bambu-green'
+                  }`}
+                  title={showModified ? t('fileManager.hideModified') : t('fileManager.showModified')}
+                  aria-pressed={showModified}
+                >
+                  <CalendarClock className="w-4 h-4" />
+                </button>
               </div>
               </div>
 
 
               {/* Results count */}
               {/* Results count */}
@@ -2305,6 +2337,7 @@ export function FileManagerPage() {
                     hasPermission={hasPermission}
                     hasPermission={hasPermission}
                     canModify={canModify}
                     canModify={canModify}
                     authEnabled={authEnabled}
                     authEnabled={authEnabled}
+                    showModified={showModified}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -2381,6 +2414,14 @@ export function FileManagerPage() {
                       </div>
                       </div>
                       <div className="min-w-0">
                       <div className="min-w-0">
                         <div className="text-sm text-white truncate">{file.print_name || file.filename}</div>
                         <div className="text-sm text-white truncate">{file.print_name || file.filename}</div>
+                        {/* #2680: last-modified date under the name, toggled from
+                            the toolbar. Real on-disk mtime when known, else created_at. */}
+                        {showModified && (
+                          <div className="text-xs text-bambu-gray flex items-center gap-1 mt-0.5" title={t('fileManager.lastModified')}>
+                            <CalendarClock className="w-3 h-3 flex-shrink-0" />
+                            <span className="truncate">{formatDate(file.fs_modified_at ?? file.created_at)}</span>
+                          </div>
+                        )}
                       </div>
                       </div>
                     </div>
                     </div>
                     {/* Uploaded By - only show when auth is enabled */}
                     {/* Uploaded By - only show when auth is enabled */}

+ 27 - 2
frontend/src/pages/PrintersPage.tsx

@@ -1990,6 +1990,30 @@ function PrinterCard({
     return filaments;
     return filaments;
   }, [status?.ams, status?.vt_tray]);
   }, [status?.ams, status?.vt_tray]);
 
 
+  // Collect loaded type+color+tray_info_idx triples for variant-aware force-color
+  // matching. Format: "TYPE:rrggbb:idx" (idx "" for custom/third-party spools) —
+  // distinguishes Bambu PLA sub-variants that share a base type+colour (#2650).
+  const loadedVariants = useMemo(() => {
+    const variants = new Set<string>();
+    if (status?.ams) {
+      for (const ams of status.ams) {
+        for (const tray of ams.tray || []) {
+          if (tray.tray_type && tray.tray_color) {
+            const color = tray.tray_color.replace('#', '').toLowerCase().slice(0, 6);
+            variants.add(`${tray.tray_type.toUpperCase()}:${color}:${tray.tray_info_idx || ''}`);
+          }
+        }
+      }
+    }
+    for (const vt of status?.vt_tray ?? []) {
+      if (vt.tray_type && vt.tray_color) {
+        const color = vt.tray_color.replace('#', '').toLowerCase().slice(0, 6);
+        variants.add(`${vt.tray_type.toUpperCase()}:${color}:${vt.tray_info_idx || ''}`);
+      }
+    }
+    return variants;
+  }, [status?.ams, status?.vt_tray]);
+
   // Fetch cloud filament info for tooltips (name includes color, also has K value)
   // Fetch cloud filament info for tooltips (name includes color, also has K value)
   const { data: filamentInfo } = useQuery({
   const { data: filamentInfo } = useQuery({
     queryKey: ['filamentInfo', trayInfoIds],
     queryKey: ['filamentInfo', trayInfoIds],
@@ -2163,8 +2187,8 @@ function PrinterCard({
   // An empty Set means no filaments are loaded — jobs requiring specific types are incompatible.
   // An empty Set means no filaments are loaded — jobs requiring specific types are incompatible.
   const queueCount = useMemo(() => {
   const queueCount = useMemo(() => {
     if (!queueItems?.length) return 0;
     if (!queueItems?.length) return 0;
-    return filterCompatibleQueueItems(queueItems, loadedFilamentTypes, loadedFilaments).length;
-  }, [queueItems, loadedFilamentTypes, loadedFilaments]);
+    return filterCompatibleQueueItems(queueItems, loadedFilamentTypes, loadedFilaments, loadedVariants).length;
+  }, [queueItems, loadedFilamentTypes, loadedFilaments, loadedVariants]);
 
 
   // Fetch currently printing queue item to show who started it (Issue #206)
   // Fetch currently printing queue item to show who started it (Issue #206)
   const { data: printingQueueItems } = useQuery({
   const { data: printingQueueItems } = useQuery({
@@ -3740,6 +3764,7 @@ function PrinterCard({
                         printerModel={printer.model}
                         printerModel={printer.model}
                         loadedFilamentTypes={loadedFilamentTypes}
                         loadedFilamentTypes={loadedFilamentTypes}
                         loadedFilaments={loadedFilaments}
                         loadedFilaments={loadedFilaments}
+                        loadedVariants={loadedVariants}
                         variant="panelExtension"
                         variant="panelExtension"
                       />
                       />
                     </div>
                     </div>

+ 193 - 6
frontend/src/pages/QueuePage.tsx

@@ -50,6 +50,7 @@ import {
   Weight,
   Weight,
   ChevronDown,
   ChevronDown,
   ChevronRight,
   ChevronRight,
+  ChevronUp,
   List,
   List,
   GanttChart,
   GanttChart,
   Code,
   Code,
@@ -347,6 +348,8 @@ function SortableQueueItem({
   onStop,
   onStop,
   onRequeue,
   onRequeue,
   onStart,
   onStart,
+  onMoveUp,
+  onMoveDown,
   timeFormat = 'system',
   timeFormat = 'system',
   isSelected = false,
   isSelected = false,
   onToggleSelect,
   onToggleSelect,
@@ -363,6 +366,11 @@ function SortableQueueItem({
   onStop: () => void;
   onStop: () => void;
   onRequeue: () => void;
   onRequeue: () => void;
   onStart: () => void;
   onStart: () => void;
+  // Mobile tap-to-reorder (#2667). Undefined = at a list boundary (button
+  // shown disabled) or reordering isn't available; the desktop drag handle
+  // is unaffected. Move one step among siblings, then persist via reorder.
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
   timeFormat?: TimeFormat;
   timeFormat?: TimeFormat;
   isSelected?: boolean;
   isSelected?: boolean;
   onToggleSelect?: () => void;
   onToggleSelect?: () => void;
@@ -451,6 +459,37 @@ function SortableQueueItem({
       )}
       )}
 
 
       <div className="flex items-start sm:items-center gap-2 sm:gap-4 p-3 sm:p-4">
       <div className="flex items-start sm:items-center gap-2 sm:gap-4 p-3 sm:p-4">
+        {/* Mobile reorder arrows (#2667). The desktop drag handle is hidden on
+            phones and touch-drag is unreliable there, so pending rows get
+            tap-to-move up/down controls instead. Shown only below `sm`. */}
+        {isPending && (onMoveUp || onMoveDown) && (
+          <div
+            className="flex sm:hidden flex-col shrink-0 -my-1"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <button
+              type="button"
+              onClick={onMoveUp}
+              disabled={!onMoveUp}
+              title={t('queue.moveUp')}
+              aria-label={t('queue.moveUp')}
+              className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
+            >
+              <ChevronUp className="w-5 h-5" />
+            </button>
+            <button
+              type="button"
+              onClick={onMoveDown}
+              disabled={!onMoveDown}
+              title={t('queue.moveDown')}
+              aria-label={t('queue.moveDown')}
+              className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
+            >
+              <ChevronDown className="w-5 h-5" />
+            </button>
+          </div>
+        )}
+
         {/* Mobile selection indicator — left accent bar only, no tick */}
         {/* Mobile selection indicator — left accent bar only, no tick */}
 
 
         {/* Selection checkbox for pending items - hidden on mobile, tap card instead */}
         {/* Selection checkbox for pending items - hidden on mobile, tap card instead */}
@@ -475,7 +514,7 @@ function SortableQueueItem({
           <div
           <div
             {...attributes}
             {...attributes}
             {...listeners}
             {...listeners}
-            className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation shrink-0"
+            className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-none shrink-0"
           >
           >
             <GripVertical className="w-4 h-4 text-bambu-gray" />
             <GripVertical className="w-4 h-4 text-bambu-gray" />
           </div>
           </div>
@@ -806,6 +845,13 @@ interface QueueRowRenderProps {
   canModify: (resource: any, action: any, createdById?: number | null) => boolean;
   canModify: (resource: any, action: any, createdById?: number | null) => boolean;
   t: (key: string, options?: Record<string, unknown>) => string;
   t: (key: string, options?: Record<string, unknown>) => string;
   aggregateForRows: (rows: QueueRow[]) => { count: number; time: number; weight: number };
   aggregateForRows: (rows: QueueRow[]) => { count: number; time: number; weight: number };
+  // Mobile tap-to-reorder (#2667). onMoveUp/onMoveDown move this whole row
+  // (single item or batch) one step among its siblings; onMoveBlock is the
+  // low-level primitive SortableBatchRow uses to move a child within the
+  // batch. All undefined when reordering isn't available (non-manual sort).
+  onMoveUp?: () => void;
+  onMoveDown?: () => void;
+  onMoveBlock?: (movingIds: number[], anchorId: number, placeAfter: boolean) => void;
 }
 }
 
 
 /** Renders either a single item or a collapsible batch group containing N
 /** Renders either a single item or a collapsible batch group containing N
@@ -823,6 +869,8 @@ function QueueRowRender(props: QueueRowRenderProps) {
     hasPermission,
     hasPermission,
     canModify,
     canModify,
     t,
     t,
+    onMoveUp,
+    onMoveDown,
   } = props;
   } = props;
 
 
   if (row.kind === 'item') {
   if (row.kind === 'item') {
@@ -835,6 +883,8 @@ function QueueRowRender(props: QueueRowRenderProps) {
         onStop={() => {}}
         onStop={() => {}}
         onRequeue={() => {}}
         onRequeue={() => {}}
         onStart={() => startMutation.mutate({ id: row.item.id })}
         onStart={() => startMutation.mutate({ id: row.item.id })}
+        onMoveUp={onMoveUp}
+        onMoveDown={onMoveDown}
         timeFormat={timeFormat}
         timeFormat={timeFormat}
         isSelected={selectedItems.includes(row.item.id)}
         isSelected={selectedItems.includes(row.item.id)}
         onToggleSelect={() => handleToggleSelect(row.item.id)}
         onToggleSelect={() => handleToggleSelect(row.item.id)}
@@ -866,6 +916,9 @@ function SortableBatchRow({
   canModify,
   canModify,
   t,
   t,
   aggregateForRows,
   aggregateForRows,
+  onMoveUp,
+  onMoveDown,
+  onMoveBlock,
 }: QueueRowRenderProps) {
 }: QueueRowRenderProps) {
   // Dispatcher (QueueRowRender) only mounts this with row.kind === 'batch';
   // Dispatcher (QueueRowRender) only mounts this with row.kind === 'batch';
   // narrow up-front so the hook below can reference batchId unconditionally.
   // narrow up-front so the hook below can reference batchId unconditionally.
@@ -908,6 +961,32 @@ function SortableBatchRow({
     >
     >
       {/* Parent header */}
       {/* Parent header */}
       <div className="flex items-center gap-2 sm:gap-3 p-3 sm:p-4">
       <div className="flex items-center gap-2 sm:gap-3 p-3 sm:p-4">
+        {/* Mobile reorder arrows for the whole group (#2667), mirroring the
+            desktop drag handle which is hidden on phones. */}
+        {canReorder && (onMoveUp || onMoveDown) && (
+          <div className="flex sm:hidden flex-col shrink-0 -my-1">
+            <button
+              type="button"
+              onClick={onMoveUp}
+              disabled={!onMoveUp}
+              title={t('queue.moveUp')}
+              aria-label={t('queue.moveUp')}
+              className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
+            >
+              <ChevronUp className="w-5 h-5" />
+            </button>
+            <button
+              type="button"
+              onClick={onMoveDown}
+              disabled={!onMoveDown}
+              title={t('queue.moveDown')}
+              aria-label={t('queue.moveDown')}
+              className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
+            >
+              <ChevronDown className="w-5 h-5" />
+            </button>
+          </div>
+        )}
         <button
         <button
           onClick={(e) => {
           onClick={(e) => {
             e.stopPropagation();
             e.stopPropagation();
@@ -932,7 +1011,7 @@ function SortableBatchRow({
           <div
           <div
             {...attributes}
             {...attributes}
             {...listeners}
             {...listeners}
-            className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation shrink-0"
+            className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-none shrink-0"
             title={t('queue.batch.dragGroup', { defaultValue: 'Drag group' })}
             title={t('queue.batch.dragGroup', { defaultValue: 'Drag group' })}
           >
           >
             <GripVertical className="w-4 h-4 text-bambu-gray" />
             <GripVertical className="w-4 h-4 text-bambu-gray" />
@@ -1002,7 +1081,7 @@ function SortableBatchRow({
       {/* Children (only when expanded) */}
       {/* Children (only when expanded) */}
       {!collapsed && (
       {!collapsed && (
         <div className="border-t border-bambu-dark-tertiary bg-black/20 p-2 sm:p-3 space-y-2">
         <div className="border-t border-bambu-dark-tertiary bg-black/20 p-2 sm:p-3 space-y-2">
-          {batchRow.items.map((child) => (
+          {batchRow.items.map((child, ci) => (
             <SortableQueueItem
             <SortableQueueItem
               key={child.id}
               key={child.id}
               item={child}
               item={child}
@@ -1012,6 +1091,16 @@ function SortableBatchRow({
               onStop={() => {}}
               onStop={() => {}}
               onRequeue={() => {}}
               onRequeue={() => {}}
               onStart={() => startMutation.mutate({ id: child.id })}
               onStart={() => startMutation.mutate({ id: child.id })}
+              onMoveUp={
+                onMoveBlock && ci > 0
+                  ? () => onMoveBlock([child.id], batchRow.items[ci - 1].id, false)
+                  : undefined
+              }
+              onMoveDown={
+                onMoveBlock && ci < batchRow.items.length - 1
+                  ? () => onMoveBlock([child.id], batchRow.items[ci + 1].id, true)
+                  : undefined
+              }
               timeFormat={timeFormat}
               timeFormat={timeFormat}
               isSelected={selectedItems.includes(child.id)}
               isSelected={selectedItems.includes(child.id)}
               onToggleSelect={() => handleToggleSelect(child.id)}
               onToggleSelect={() => handleToggleSelect(child.id)}
@@ -1033,6 +1122,8 @@ type HistoryRow =
 interface HistorySectionProps {
 interface HistorySectionProps {
   items: PrintQueueItem[];
   items: PrintQueueItem[];
   collapsed: boolean;
   collapsed: boolean;
+  visibleCount: number;
+  onShowMore: () => void;
   sortBy: 'date' | 'name' | 'printer';
   sortBy: 'date' | 'name' | 'printer';
   sortAsc: boolean;
   sortAsc: boolean;
   onSortByChange: (v: 'date' | 'name' | 'printer') => void;
   onSortByChange: (v: 'date' | 'name' | 'printer') => void;
@@ -1051,6 +1142,8 @@ interface HistorySectionProps {
 
 
 function HistorySection({
 function HistorySection({
   items,
   items,
+  visibleCount,
+  onShowMore,
   sortBy,
   sortBy,
   sortAsc,
   sortAsc,
   onSortByChange,
   onSortByChange,
@@ -1079,7 +1172,7 @@ function HistorySection({
   // position from the parent's sort selector.
   // position from the parent's sort selector.
   const rows: HistoryRow[] = [];
   const rows: HistoryRow[] = [];
   const seenBatches = new Set<number>();
   const seenBatches = new Set<number>();
-  for (const item of items.slice(0, 50)) {
+  for (const item of items.slice(0, visibleCount)) {
     if (item.batch_id != null) {
     if (item.batch_id != null) {
       if (seenBatches.has(item.batch_id)) continue;
       if (seenBatches.has(item.batch_id)) continue;
       seenBatches.add(item.batch_id);
       seenBatches.add(item.batch_id);
@@ -1227,10 +1320,25 @@ function HistorySection({
           );
           );
         })}
         })}
       </div>
       </div>
+      {items.length > visibleCount && (
+        <div className="mt-4 flex flex-col items-center gap-2">
+          <Button variant="secondary" size="sm" onClick={onShowMore}>
+            {t('queue.history.showMore')}
+          </Button>
+          <span className="text-xs text-bambu-gray">
+            {t('queue.history.showingCount', {
+              shown: Math.min(visibleCount, items.length),
+              total: items.length,
+            })}
+          </span>
+        </div>
+      )}
     </div>
     </div>
   );
   );
 }
 }
 
 
+const HISTORY_PAGE_SIZE = 50;
+
 export function QueuePage() {
 export function QueuePage() {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -1263,6 +1371,10 @@ export function QueuePage() {
     const saved = localStorage.getItem('queue.historySortAsc');
     const saved = localStorage.getItem('queue.historySortAsc');
     return saved !== null ? saved === 'true' : false;
     return saved !== null ? saved === 'true' : false;
   });
   });
+  // #2682: History renders progressively — start at one page, grow on demand.
+  // Reset happens only on a deliberate re-sort / filter change (below), NOT on
+  // the periodic queue poll, so an expanded view doesn't snap back mid-scroll.
+  const [historyVisibleCount, setHistoryVisibleCount] = useState(HISTORY_PAGE_SIZE);
   const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => {
   const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => {
     const saved = localStorage.getItem('queue.pendingSortBy');
     const saved = localStorage.getItem('queue.pendingSortBy');
     return (saved as 'position' | 'name' | 'printer' | 'time') || 'position';
     return (saved as 'position' | 'name' | 'printer' | 'time') || 'position';
@@ -1320,6 +1432,13 @@ export function QueuePage() {
     localStorage.setItem('queue.historySortAsc', String(historySortAsc));
     localStorage.setItem('queue.historySortAsc', String(historySortAsc));
   }, [historySortAsc]);
   }, [historySortAsc]);
 
 
+  // Collapse History back to a single page when the user re-sorts or changes
+  // the location filter (deliberate view changes). Intentionally excludes the
+  // queue poll so periodic refetches keep the expanded count.
+  useEffect(() => {
+    setHistoryVisibleCount(HISTORY_PAGE_SIZE);
+  }, [historySortBy, historySortAsc, filterLocation]);
+
   useEffect(() => {
   useEffect(() => {
     localStorage.setItem('queue.pendingSortBy', pendingSortBy);
     localStorage.setItem('queue.pendingSortBy', pendingSortBy);
   }, [pendingSortBy]);
   }, [pendingSortBy]);
@@ -1797,6 +1916,68 @@ export function QueuePage() {
     return rows;
     return rows;
   }, [pendingItems, t]);
   }, [pendingItems, t]);
 
 
+  // Mobile tap-to-reorder (#2667). The desktop drag handle is hidden on
+  // phones and touch-drag is unreliable, so pending rows get up/down arrows.
+  // Reordering only has a defined meaning in the manual "position" sort with
+  // SJF off — any other sort re-orders the list itself, so we don't offer it.
+  const canReorderManually =
+    hasPermission('queue:reorder') &&
+    pendingSortBy === 'position' &&
+    !settings?.queue_shortest_first;
+
+  const rowItemIds = (row: QueueRow): number[] =>
+    row.kind === 'item' ? [row.item.id] : row.items.map((i) => i.id);
+
+  // Move a block of items to sit immediately before (or after) an anchor item
+  // in the global pending order, then persist — the same remove-and-reinsert
+  // shape as handleDragEnd, so arrows and drag agree. Anchoring to a real item
+  // id keeps it correct in the printer-grouped layout, where a bucket's rows
+  // aren't contiguous in the global order.
+  const moveBlockRelativeTo = (
+    movingIds: number[],
+    anchorId: number,
+    placeAfter: boolean,
+  ) => {
+    const remaining = pendingItems.filter((i) => !movingIds.includes(i.id));
+    let insertAt = remaining.findIndex((i) => i.id === anchorId);
+    if (insertAt === -1) return;
+    if (placeAfter) insertAt += 1;
+    const movingItems = movingIds
+      .map((id) => pendingItems.find((i) => i.id === id))
+      .filter((x): x is PrintQueueItem => !!x);
+    const reordered = [
+      ...remaining.slice(0, insertAt),
+      ...movingItems,
+      ...remaining.slice(insertAt),
+    ];
+    reorderMutation.mutate(
+      reordered.map((item, index) => ({ id: item.id, position: index + 1 })),
+    );
+  };
+
+  // Build up/down thunks for the row at `idx` within its displayed sibling
+  // list (the flat list, or a single printer bucket). Undefined at a boundary
+  // (button rendered disabled) or when manual reorder isn't available.
+  const rowMovers = (
+    rows: QueueRow[],
+    idx: number,
+  ): { onMoveUp?: () => void; onMoveDown?: () => void } => {
+    if (!canReorderManually) return {};
+    const moving = rowItemIds(rows[idx]);
+    const onMoveUp =
+      idx > 0
+        ? () => moveBlockRelativeTo(moving, rowItemIds(rows[idx - 1])[0], false)
+        : undefined;
+    const onMoveDown =
+      idx < rows.length - 1
+        ? () => {
+            const next = rowItemIds(rows[idx + 1]);
+            moveBlockRelativeTo(moving, next[next.length - 1], true);
+          }
+        : undefined;
+    return { onMoveUp, onMoveDown };
+  };
+
   // SortableContext ID list.
   // SortableContext ID list.
   // - Standalone pending items: their numeric id.
   // - Standalone pending items: their numeric id.
   // - Batch parents: the synthetic `batch-<id>` string, always present so the
   // - Batch parents: the synthetic `batch-<id>` string, always present so the
@@ -2183,6 +2364,8 @@ export function QueuePage() {
         <HistorySection
         <HistorySection
           items={historyItems}
           items={historyItems}
           collapsed={false}
           collapsed={false}
+          visibleCount={historyVisibleCount}
+          onShowMore={() => setHistoryVisibleCount((c) => c + HISTORY_PAGE_SIZE)}
           sortBy={historySortBy}
           sortBy={historySortBy}
           sortAsc={historySortAsc}
           sortAsc={historySortAsc}
           onSortByChange={setHistorySortBy}
           onSortByChange={setHistorySortBy}
@@ -2335,7 +2518,7 @@ export function QueuePage() {
                 >
                 >
                   {activeLayout === 'position' ? (
                   {activeLayout === 'position' ? (
                     <div className="space-y-2 sm:space-y-3">
                     <div className="space-y-2 sm:space-y-3">
-                      {groupedRows.map((row) => (
+                      {groupedRows.map((row, idx) => (
                         <QueueRowRender
                         <QueueRowRender
                           key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
                           key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
                           row={row}
                           row={row}
@@ -2352,6 +2535,8 @@ export function QueuePage() {
                           canModify={canModify}
                           canModify={canModify}
                           t={t}
                           t={t}
                           aggregateForRows={aggregateForRows}
                           aggregateForRows={aggregateForRows}
+                          {...rowMovers(groupedRows, idx)}
+                          onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}
                         />
                         />
                       ))}
                       ))}
                     </div>
                     </div>
@@ -2371,7 +2556,7 @@ export function QueuePage() {
                               </span>
                               </span>
                             </div>
                             </div>
                             <div className="bg-bambu-dark/40 border border-t-0 border-bambu-dark-tertiary rounded-b-lg p-2 space-y-2">
                             <div className="bg-bambu-dark/40 border border-t-0 border-bambu-dark-tertiary rounded-b-lg p-2 space-y-2">
-                              {bucket.rows.map((row) => (
+                              {bucket.rows.map((row, idx) => (
                                 <QueueRowRender
                                 <QueueRowRender
                                   key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
                                   key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
                                   row={row}
                                   row={row}
@@ -2388,6 +2573,8 @@ export function QueuePage() {
                                   canModify={canModify}
                                   canModify={canModify}
                                   t={t}
                                   t={t}
                                   aggregateForRows={aggregateForRows}
                                   aggregateForRows={aggregateForRows}
+                                  {...rowMovers(bucket.rows, idx)}
+                                  onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}
                                 />
                                 />
                               ))}
                               ))}
                             </div>
                             </div>

+ 4 - 15
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -24,6 +24,7 @@ import { defaultFormData, validateForm } from '../../components/spool-form/types
 import {
 import {
   buildFilamentOptions,
   buildFilamentOptions,
   extractBrandsFromPresets,
   extractBrandsFromPresets,
+  fetchPrinterCalibrations,
   findPresetOption,
   findPresetOption,
   loadRecentColors,
   loadRecentColors,
   parsePresetName,
   parsePresetName,
@@ -521,21 +522,9 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
           const connected = status?.connected ?? false;
           const connected = status?.connected ?? false;
           let calibrations: PrinterWithCalibrations['calibrations'] = [];
           let calibrations: PrinterWithCalibrations['calibrations'] = [];
           if (connected) {
           if (connected) {
-            try {
-              const kRes = await api.getKProfiles(printer.id);
-              calibrations = kRes.profiles.map(p => ({
-                cali_idx: p.slot_id,
-                filament_id: p.filament_id,
-                setting_id: p.setting_id || '',
-                name: p.name,
-                k_value: parseFloat(p.k_value) || 0,
-                n_coef: parseFloat(p.n_coef) || 0,
-                extruder_id: p.extruder_id,
-                nozzle_diameter: p.nozzle_diameter,
-              }));
-            } catch {
-              // ignore per-printer unsupported profile endpoints
-            }
+            // Fetch across every installed nozzle so dual-nozzle printers
+            // surface both the 0.4mm and 0.6mm K-profiles, not just 0.4 (#2618).
+            calibrations = await fetchPrinterCalibrations(printer.id, status);
           }
           }
           results.push({ printer: { ...printer, connected }, calibrations });
           results.push({ printer: { ...printer, connected }, calibrations });
         }
         }

+ 26 - 0
frontend/src/utils/amsHelpers.ts

@@ -347,6 +347,32 @@ export function filterFilamentsByNozzle<T extends { extruderId?: number }>(
   );
   );
 }
 }
 
 
+/**
+ * List the distinct nozzle diameters the printer actually reports (#2618).
+ * Mirrors the backend `_installed_nozzle_diameters`: reads each
+ * `status.nozzles[].nozzle_diameter`, skips the empty-string / non-positive
+ * defaults that populate a NozzleInfo before MQTT fills it in, and dedupes.
+ *
+ * Returns e.g. `['0.4']` (single-nozzle) or `['0.4', '0.6']` (dual-nozzle). An
+ * empty array means "the printer hasn't told us its nozzle hardware" — callers
+ * that need to fetch per-nozzle should fall back to their own default rather
+ * than treating it as "no nozzles". Preserves the bare decimal string form the
+ * status carries so it can be passed straight to `getKProfiles`.
+ */
+export function installedNozzleDiameters(
+  status: { nozzles?: { nozzle_diameter?: string }[] } | null | undefined,
+): string[] {
+  const seen = new Set<string>();
+  const result: string[] = [];
+  for (const nozzle of status?.nozzles ?? []) {
+    const raw = (nozzle?.nozzle_diameter ?? '').trim();
+    if (!raw || !(parseFloat(raw) > 0) || seen.has(raw)) continue;
+    seen.add(raw);
+    result.push(raw);
+  }
+  return result;
+}
+
 /**
 /**
  * Resolve the installed nozzle diameter feeding a given AMS unit, so the
  * Resolve the installed nozzle diameter feeding a given AMS unit, so the
  * Configure-AMS-Slot picker filters filament presets by the nozzle actually on
  * Configure-AMS-Slot picker filters filament presets by the nozzle actually on

+ 18 - 3
frontend/src/utils/printer.ts

@@ -56,12 +56,19 @@ import type { PrintQueueItem } from '../api/client';
  * @param items - Array of queue items to filter
  * @param items - Array of queue items to filter
  * @param loadedFilamentTypes - Set of loaded filament types (e.g., "PLA", "PETG")
  * @param loadedFilamentTypes - Set of loaded filament types (e.g., "PLA", "PETG")
  * @param loadedFilaments - Set of loaded filament type+color pairs (e.g., "PLA:ffffff", "PETG:ff0000")
  * @param loadedFilaments - Set of loaded filament type+color pairs (e.g., "PLA:ffffff", "PETG:ff0000")
+ * @param loadedVariants - Set of loaded type+color+tray_info_idx triples
+ *   (e.g., "PLA:ffffff:GFA01"; the idx is "" for custom/third-party spools). Used to
+ *   distinguish Bambu PLA sub-variants (Basic GFA00 / Matte GFA01 / Silk GFA06) that
+ *   share a base type+colour, mirroring the backend _get_missing_force_color_slots (#2650).
+ *   When omitted, force matching falls back to type+colour so the hint is never stricter
+ *   than the data available.
  * @returns Array of compatible queue items
  * @returns Array of compatible queue items
  */
  */
 export function filterCompatibleQueueItems(
 export function filterCompatibleQueueItems(
   items: PrintQueueItem[],
   items: PrintQueueItem[],
   loadedFilamentTypes?: Set<string>,
   loadedFilamentTypes?: Set<string>,
-  loadedFilaments?: Set<string>
+  loadedFilaments?: Set<string>,
+  loadedVariants?: Set<string>
 ): PrintQueueItem[] {
 ): PrintQueueItem[] {
   return items.filter(item => {
   return items.filter(item => {
     // Type check: all required filament types must be loaded
     // Type check: all required filament types must be loaded
@@ -78,12 +85,20 @@ export function filterCompatibleQueueItems(
       const forceOverrides = item.filament_overrides.filter(o => o.force_color_match === true);
       const forceOverrides = item.filament_overrides.filter(o => o.force_color_match === true);
       const prefOverrides = item.filament_overrides.filter(o => o.force_color_match !== true);
       const prefOverrides = item.filament_overrides.filter(o => o.force_color_match !== true);
 
 
-      // All force-matched slots must have exact type+color on this printer
+      // All force-matched slots must have an exact type+color match — and, when the
+      // override carries a tray_info_idx, the same variant too (a loaded tray with a
+      // blank idx still satisfies it, matching the backend's type+colour fallback).
       if (forceOverrides.length > 0) {
       if (forceOverrides.length > 0) {
         const allForceMatch = forceOverrides.every(o => {
         const allForceMatch = forceOverrides.every(o => {
           const oType = (o.type || '').toUpperCase();
           const oType = (o.type || '').toUpperCase();
           const oColor = (o.color || '').replace('#', '').toLowerCase().slice(0, 6);
           const oColor = (o.color || '').replace('#', '').toLowerCase().slice(0, 6);
-          return loadedFilaments.has(`${oType}:${oColor}`);
+          const oIdx = o.tray_info_idx || '';
+          // No variant on the override, or no variant data supplied → type+colour only.
+          if (!oIdx || loadedVariants === undefined) {
+            return loadedFilaments.has(`${oType}:${oColor}`);
+          }
+          // Variant-specific: same idx, or a same-colour tray that reports no idx.
+          return loadedVariants.has(`${oType}:${oColor}:${oIdx}`) || loadedVariants.has(`${oType}:${oColor}:`);
         });
         });
         if (!allForceMatch) return false;
         if (!allForceMatch) return false;
       }
       }

Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
static/assets/index-Di24iyOw.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/index-FqCjQymn.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
static/assets/index-kl51qImb.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-ByvLwk1T.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-kl51qImb.css">
+    <script type="module" crossorigin src="/assets/index-FqCjQymn.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Неке датотеке нису приказане због велике количине промена