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.
This commit is contained in:
maziggy
2026-05-20 08:12:17 +02:00
parent 74f759468c
commit bfd3fc755d
3 changed files with 145 additions and 10 deletions
@@ -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"
)