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

View file

@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b1] - Unreleased
### Fixed
- **Archives: timelapse auto-attach now works for VP-queue / dispatch prints (#1403 follow-up, reported by @pwostran)** — Bambuddy uses a snapshot-diff strategy to pick the right MP4 off the printer's SD card after a print (Bambu printers in LAN-only mode don't sync NTP, so file mtimes are unreliable — `_scan_for_timelapse_with_retries` snapshots existing video filenames at print start and looks for any NEW filename at completion). The baseline-capture call was inline at the bottom of `on_print_start`'s **new-archive branch only** — the **expected-archive branch** (which queue / VP-dispatched / reprinted jobs take, anything registered via `register_expected_print`) exited at its own `return` without ever snapshotting. So queue prints had `_timelapse_baselines[printer_id]` unset; the completion-time scan fell into its "take baseline now" fallback that snapshots the SD card *after* the new MP4 has already landed → the new file sits in the "baseline" set → no diff ever matches → auto-attach silently does nothing. The reporter's `bambuddy-support-20260518-185935.zip` shows the failure verbatim: `Using expected archive 3 for print (skipping duplicate)` at `18:41:10`, `Timelapse was active during print, scheduling auto-scan for archive 3` at `18:58:55`, and `[TIMELAPSE] Archive 3 has no printer, aborting` (a separate bug already fixed by the printer_id-assignment commit in this same train) — and `grep -i baseline` across both his bundles returns zero hits, confirming the snapshot never ran. Fix extracts the inline baseline-capture into `_capture_timelapse_baseline_at_start(printer, printer_id, logger)` and calls it from BOTH branches of `on_print_start` (mirroring the existing site in the new-archive branch with a matching call just before the expected-archive branch's `return`). Helper is best-effort with a `try / except Exception` wrapping `_list_timelapse_videos`, so a transient FTP failure at print-start logs `[TIMELAPSE] Failed to capture baseline at print start: …` and the print proceeds — the completion-time fallback still kicks in (with its known limitation), behaviour matching what the new-archive branch had all along. **Tests:** new `test_expected_archive_path_captures_timelapse_baseline` in the existing `test_print_start_assigns_printer_id_to_vp_archive.py` patches `_list_timelapse_videos` to return two pre-existing videos, runs `on_print_start` through the expected-archive branch, and asserts `_timelapse_baselines[1] == {"earlier_print_a.mp4", "earlier_print_b.mp4"}` — a future refactor that removes the call from one of the branches now fails CI. The existing 2 regressions (`test_expected_archive_path_assigns_printer_id_when_unset`, `test_expected_archive_path_preserves_existing_printer_id`) plus 50 adjacent expected-archive / layer-timelapse / archive-filtering tests still green. The fixture clearing `_expected_prints` etc also clears `_timelapse_baselines` now so test isolation holds.
## [0.2.4.1] - 2026-05-16
### Added

View file

@ -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.

View file

@ -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"
)