Browse Source

Fix: capture timelapse baseline on expected-archive on_print_start branch (#1403 follow-up)

  The snapshot-diff strategy in _scan_for_timelapse_with_retries needs
  _timelapse_baselines[printer_id] populated at print start so the
  completion-time scan can find the new MP4 by set-difference (mtime is
  unreliable — LAN-only printers don't sync NTP).

  The baseline-capture call was only in on_print_start's new-archive branch.
  Queue / VP-dispatched / reprinted jobs take the expected-archive branch
  which returns earlier, so the dict stayed empty and the completion-time
  scan fell into the "take baseline now" fallback that snapshots after the
  new file has already landed — no diff ever matches.

  Extract the snapshot into _capture_timelapse_baseline_at_start and call
  it from both branches.
maziggy 1 week ago
parent
commit
bfd3fc755d

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


+ 35 - 10
backend/app/main.py

@@ -2121,6 +2121,14 @@ async def on_print_start(printer_id: int, data: dict):
                 except Exception as e:
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
 
+                # Capture timelapse file baseline for snapshot-diff on completion
+                # (mirrors the new-archive branch). Queue / VP-dispatched prints
+                # hit this branch — without the baseline the completion-time scan
+                # falls into its "take baseline now" fallback, which snapshots
+                # AFTER the new MP4 already exists and never matches a diff
+                # (#1403 follow-up — see pwostran's 2026-05-18 support bundle).
+                await _capture_timelapse_baseline_at_start(printer, printer_id, logger)
+
             return  # Skip creating a new archive
 
         # Check if there's already a "printing" archive for this printer/file
@@ -2725,16 +2733,7 @@ async def on_print_start(printer_id: int, data: dict):
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
 
                 # Capture timelapse file baseline for snapshot-diff on completion
-                try:
-                    baseline_files, _ = await _list_timelapse_videos(printer)
-                    _timelapse_baselines[printer_id] = {f.get("name", "") for f in baseline_files}
-                    logger.info(
-                        "[TIMELAPSE] Baseline at print start: %s video files for printer %s",
-                        len(_timelapse_baselines[printer_id]),
-                        printer_id,
-                    )
-                except Exception as e:
-                    logger.warning("[TIMELAPSE] Failed to capture baseline at print start: %s", e)
+                await _capture_timelapse_baseline_at_start(printer, printer_id, logger)
         finally:
             # Keep temp_path around until print completes so the cover endpoint
             # can reuse it (#972). Cache eviction in on_print_complete deletes
@@ -2779,6 +2778,32 @@ async def _list_timelapse_videos(printer) -> tuple[list[dict], str | None]:
     return [], None
 
 
+async def _capture_timelapse_baseline_at_start(printer, printer_id: int, logger: logging.Logger) -> None:
+    """Snapshot the printer's timelapse directory at print start so the
+    completion-time scan can pick the new file by set-difference.
+
+    Must be called from every on_print_start path that proceeds to a real
+    print — both the new-archive branch and the expected-archive branch (which
+    queue / VP-dispatched prints take). Without a baseline,
+    _scan_for_timelapse_with_retries falls into its "take baseline now"
+    fallback that runs AFTER the new MP4 has already landed on the SD card,
+    so the new file ends up in the "baseline" set and no diff ever matches.
+
+    Bambu printers in LAN-only mode don't sync NTP, so mtime ordering is
+    unreliable — the snapshot-diff approach sidesteps that entirely.
+    """
+    try:
+        baseline_files, _ = await _list_timelapse_videos(printer)
+        _timelapse_baselines[printer_id] = {f.get("name", "") for f in baseline_files}
+        logger.info(
+            "[TIMELAPSE] Baseline at print start: %s video files for printer %s",
+            len(_timelapse_baselines[printer_id]),
+            printer_id,
+        )
+    except Exception as e:
+        logger.warning("[TIMELAPSE] Failed to capture baseline at print start: %s", e)
+
+
 async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[str] | None = None):
     """
     Scan for timelapse with retries using a snapshot-diff approach.

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

@@ -22,6 +22,7 @@ from backend.app.main import (
     _expected_print_registered_at,
     _expected_prints,
     _print_ams_mappings,
+    _timelapse_baselines,
     register_expected_print,
 )
 
@@ -33,12 +34,14 @@ def _clear_dicts():
     _expected_print_creators.clear()
     _print_ams_mappings.clear()
     _active_prints.clear()
+    _timelapse_baselines.clear()
     yield
     _expected_prints.clear()
     _expected_print_registered_at.clear()
     _expected_print_creators.clear()
     _print_ams_mappings.clear()
     _active_prints.clear()
+    _timelapse_baselines.clear()
 
 
 @pytest.mark.asyncio
@@ -205,3 +208,107 @@ async def test_expected_archive_path_preserves_existing_printer_id():
 
         assert mock_archive.printer_id == 7
         assert mock_archive.status == "printing"
+
+
+@pytest.mark.asyncio
+async def test_expected_archive_path_captures_timelapse_baseline():
+    """VP-queue prints hit the expected-archive branch, which used to skip the
+    timelapse baseline-capture step that the new-archive branch did. Without a
+    baseline, _scan_for_timelapse_with_retries falls into its "take baseline
+    now" fallback that snapshots the SD card AFTER the new MP4 has landed —
+    the new file ends up in the baseline set and no diff ever matches, so
+    auto-attach never picks the right file.
+
+    Regression: at print start the global _timelapse_baselines dict must
+    contain the snapshot of existing video filenames for this printer_id,
+    so the completion-time scan can set-diff against it.
+    """
+    mock_printer = MagicMock()
+    mock_printer.id = 1
+    mock_printer.auto_archive = True
+    mock_printer.external_camera_enabled = False
+    mock_printer.external_camera_url = None
+    mock_printer.name = "TestP1S"
+
+    mock_archive = MagicMock()
+    mock_archive.id = 42
+    mock_archive.filename = "bambu_lab_a1_tool_plate_3.gcode.3mf"
+    mock_archive.subtask_id = None
+    mock_archive.print_time_seconds = None
+    mock_archive.created_by_id = None
+    mock_archive.printer_id = None
+    mock_archive.print_name = "A1 Tool Plate 3"
+    mock_archive.status = "archived"
+    mock_archive.file_path = "/tmp/fake.3mf"  # nosec B108 — mock path; nothing ever writes to it
+    mock_archive.energy_start_kwh = None
+
+    register_expected_print(1, "bambu_lab_a1_tool_plate_3.gcode.3mf", archive_id=42, ams_mapping=None)
+
+    # Two pre-existing files on the printer's SD card before this print starts.
+    # The fake completion scan would diff against this set.
+    existing_videos = [
+        {"name": "earlier_print_a.mp4", "is_directory": False, "path": "/timelapse/earlier_print_a.mp4"},
+        {"name": "earlier_print_b.mp4", "is_directory": False, "path": "/timelapse/earlier_print_b.mp4"},
+    ]
+
+    def execute_router(stmt, *args, **kwargs):
+        sql = str(stmt).lower()
+        if "from printers" in sql or "from printer " in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_printer),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
+            )
+        if "from print_archives" in sql or "from print_archive" in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_archive),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
+            )
+        return MagicMock(
+            scalar_one_or_none=MagicMock(return_value=None),
+            scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+        )
+
+    mock_session = AsyncMock()
+    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+    mock_session.__aexit__ = AsyncMock()
+    mock_session.execute = AsyncMock(side_effect=execute_router)
+    mock_session.commit = AsyncMock()
+
+    with (
+        patch("backend.app.main.async_session") as mock_session_maker,
+        patch("backend.app.main.notification_service") as mock_notif,
+        patch("backend.app.main.smart_plug_manager") as mock_plug,
+        patch("backend.app.main.ws_manager") as mock_ws,
+        patch("backend.app.main.printer_manager") as mock_pm,
+        patch("backend.app.main.mqtt_relay") as mock_relay,
+        patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
+        patch("backend.app.main._load_objects_from_archive"),
+        patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
+        patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
+        patch(
+            "backend.app.main._list_timelapse_videos",
+            new=AsyncMock(return_value=(existing_videos, "/timelapse")),
+        ),
+    ):
+        mock_session_maker.return_value = mock_session
+        mock_notif.on_print_start = AsyncMock()
+        mock_plug.on_print_start = AsyncMock()
+        mock_ws.send_print_start = AsyncMock()
+        mock_ws.send_archive_updated = AsyncMock()
+        mock_relay.on_print_start = AsyncMock()
+        mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(
+            1,
+            {
+                "filename": "bambu_lab_a1_tool_plate_3.gcode.3mf",
+                "subtask_name": "bambu_lab_a1_tool_plate_3",
+            },
+        )
+
+        assert _timelapse_baselines.get(1) == {"earlier_print_a.mp4", "earlier_print_b.mp4"}, (
+            "expected-archive branch must capture the printer's existing-videos "
+            "baseline so completion-time scan can set-diff to find the new file"
+        )

Some files were not shown because too many files changed in this diff