Sfoglia il codice sorgente

fix(notifications): reprint-from-archive sent original print's finish photo (#1707)

  Reprints reuse the source archive row via the expected-print promotion
  branch, but that branch never reset archive.timelapse_path. The stale
  path made _scan_for_timelapse_with_retries early-return (so the new
  run's MP4 was never downloaded), and _capture_finish_photo_from_timelapse
  then extracted the *original* run's last frame and shipped it to Telegram
  as the new run's finish photo. Surface was specific to the
  timelapse-prefer path (data.timelapse_was_active=true and no external
  camera) — fallback live-camera paths were unaffected, which is why this
  took a P2S reporter to surface.

  Clear archive.timelapse_path at promotion and unlink the stale on-disk
  MP4 (best-effort; missing files are logged and skipped). The orphan
  unlink also kills a long-standing file-leak: every reprint used to leave
  its predecessor's timelapse on disk forever. archive.photos is
  deliberately left alone — accumulating one finish photo per run is
  correct.
maziggy 2 mesi fa
parent
commit
bf781e0566

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


+ 27 - 0
backend/app/main.py

@@ -2216,6 +2216,33 @@ async def on_print_start(printer_id: int, data: dict):
                 # Update archive status to printing
                 # Update archive status to printing
                 archive.status = "printing"
                 archive.status = "printing"
                 archive.started_at = datetime.now(timezone.utc)
                 archive.started_at = datetime.now(timezone.utc)
+
+                # Reprint of an archive reuses the source row. Without resetting
+                # ``timelapse_path`` _scan_for_timelapse_with_retries early-returns
+                # ("already has timelapse") and _capture_finish_photo_from_timelapse
+                # extracts the *original* print's last frame, which then ships in
+                # the completion notification (#1707). Clear the path so the
+                # scanner runs fresh; also unlink the old video file so reprints
+                # don't accumulate orphans in the archive directory. Photos list
+                # is left alone — accumulating one finish photo per run is fine.
+                stale_timelapse_relpath = archive.timelapse_path
+                if stale_timelapse_relpath:
+                    archive.timelapse_path = None
+                    try:
+                        stale_path = app_settings.base_dir / stale_timelapse_relpath
+                        if stale_path.is_file():
+                            stale_path.unlink()
+                            logger.info(
+                                "Deleted stale timelapse %s on reprint of archive %s",
+                                stale_timelapse_relpath,
+                                expected_archive_id,
+                            )
+                    except OSError as e:
+                        logger.warning(
+                            "Failed to delete stale timelapse %s on reprint: %s",
+                            stale_timelapse_relpath,
+                            e,
+                        )
                 # Persist a restart-stable id so a later restart resumes this
                 # Persist a restart-stable id so a later restart resumes this
                 # archive by subtask_id instead of name-matching + duplicating
                 # archive by subtask_id instead of name-matching + duplicating
                 # it (#1485). The printer often hasn't echoed subtask_id back
                 # it (#1485). The printer often hasn't echoed subtask_id back

+ 326 - 0
backend/tests/unit/test_reprint_clears_stale_timelapse.py

@@ -0,0 +1,326 @@
+"""Regression for #1707: Telegram (and any image-bearing) notification on a
+reprint from archive showed the *original* print's finish photo because the
+expected-archive branch never reset ``archive.timelapse_path``.
+
+The source archive row is reused for reprints. With ``timelapse_path`` still
+pointing at the original run's downloaded MP4:
+  - ``_scan_for_timelapse_with_retries`` early-returns ("already has timelapse")
+    and never downloads the reprint's video.
+  - ``_capture_finish_photo_from_timelapse`` reads the stale path, extracts the
+    *original* last frame, and ships it as the reprint's finish photo.
+
+The fix clears ``archive.timelapse_path`` (and unlinks the stale file) at
+expected-archive promotion so the scan + photo path run fresh.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.core.config import settings as app_settings
+from backend.app.main import (
+    _active_prints,
+    _expected_print_creators,
+    _expected_print_registered_at,
+    _expected_prints,
+    _print_ams_mappings,
+    _timelapse_baselines,
+    register_expected_print,
+)
+
+
+@pytest.fixture(autouse=True)
+def _clear_dicts():
+    _expected_prints.clear()
+    _expected_print_registered_at.clear()
+    _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()
+
+
+def _patches():
+    """Common patches for driving on_print_start without side effects."""
+    return (
+        patch("backend.app.main.async_session"),
+        patch("backend.app.main.notification_service"),
+        patch("backend.app.main.smart_plug_manager"),
+        patch("backend.app.main.ws_manager"),
+        patch("backend.app.main.printer_manager"),
+        patch("backend.app.main.mqtt_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=([], "/timelapse")),
+        ),
+    )
+
+
+def _build_mocks(mock_printer, mock_archive):
+    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()
+    return mock_session
+
+
+@pytest.mark.asyncio
+async def test_reprint_clears_timelapse_path_and_unlinks_stale_file(tmp_path):
+    """On reprint promotion, timelapse_path must be reset to None and the old
+    on-disk video unlinked, so the completion-time scanner and finish-photo
+    extractor don't reuse the original run's frame."""
+    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 = "TestP2S"
+
+    # Lay down a fake stale timelapse under a tmp base_dir so the unlink
+    # actually has a file to remove.
+    relpath = "archives/42/timelapse/original.mp4"
+    stale_file = tmp_path / relpath
+    stale_file.parent.mkdir(parents=True, exist_ok=True)
+    stale_file.write_bytes(b"old timelapse bytes")
+    assert stale_file.exists()
+
+    mock_archive = MagicMock()
+    mock_archive.id = 42
+    mock_archive.filename = "MyModel.3mf"
+    mock_archive.subtask_id = None
+    mock_archive.print_time_seconds = None
+    mock_archive.created_by_id = None
+    mock_archive.printer_id = 1
+    mock_archive.print_name = "MyModel"
+    mock_archive.status = "archived"
+    mock_archive.file_path = "archives/42/MyModel.3mf"
+    mock_archive.energy_start_kwh = None
+    mock_archive.timelapse_path = relpath  # stale from the original run
+
+    register_expected_print(1, "MyModel.3mf", archive_id=42, ams_mapping=None)
+
+    mock_session = _build_mocks(mock_printer, mock_archive)
+
+    (
+        async_session_p,
+        notif_p,
+        plug_p,
+        ws_p,
+        pm_p,
+        relay_p,
+        _energy,
+        _load_obj,
+        _store_spoolman,
+        _send_start,
+        _list_tl,
+    ) = _patches()
+
+    with (
+        async_session_p as mock_session_maker,
+        notif_p as mock_notif,
+        plug_p as mock_plug,
+        ws_p as mock_ws,
+        pm_p as mock_pm,
+        relay_p as mock_relay,
+        _energy,
+        _load_obj,
+        _store_spoolman,
+        _send_start,
+        _list_tl,
+        patch.object(app_settings, "base_dir", tmp_path),
+    ):
+        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": "MyModel.3mf", "subtask_name": "MyModel"})
+
+    assert mock_archive.timelapse_path is None, (
+        "expected-archive branch must clear timelapse_path on reprint so "
+        "_scan_for_timelapse_with_retries doesn't early-return and "
+        "_capture_finish_photo_from_timelapse doesn't extract the original "
+        "run's last frame (#1707)"
+    )
+    assert not stale_file.exists(), (
+        "old timelapse file must be unlinked at reprint promotion to avoid orphans in the archive directory"
+    )
+
+
+@pytest.mark.asyncio
+async def test_reprint_with_no_timelapse_path_is_noop(tmp_path):
+    """When archive has no prior timelapse_path (first print, or already
+    cleared), promotion must still succeed and not raise on the unlink path."""
+    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 = "TestP2S"
+
+    mock_archive = MagicMock()
+    mock_archive.id = 99
+    mock_archive.filename = "FreshFile.3mf"
+    mock_archive.subtask_id = None
+    mock_archive.print_time_seconds = None
+    mock_archive.created_by_id = None
+    mock_archive.printer_id = 1
+    mock_archive.print_name = "FreshFile"
+    mock_archive.status = "archived"
+    mock_archive.file_path = "archives/99/FreshFile.3mf"
+    mock_archive.energy_start_kwh = None
+    mock_archive.timelapse_path = None  # nothing to clean up
+
+    register_expected_print(1, "FreshFile.3mf", archive_id=99, ams_mapping=None)
+
+    mock_session = _build_mocks(mock_printer, mock_archive)
+
+    (
+        async_session_p,
+        notif_p,
+        plug_p,
+        ws_p,
+        pm_p,
+        relay_p,
+        _energy,
+        _load_obj,
+        _store_spoolman,
+        _send_start,
+        _list_tl,
+    ) = _patches()
+
+    with (
+        async_session_p as mock_session_maker,
+        notif_p as mock_notif,
+        plug_p as mock_plug,
+        ws_p as mock_ws,
+        pm_p as mock_pm,
+        relay_p as mock_relay,
+        _energy,
+        _load_obj,
+        _store_spoolman,
+        _send_start,
+        _list_tl,
+        patch.object(app_settings, "base_dir", tmp_path),
+    ):
+        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": "FreshFile.3mf", "subtask_name": "FreshFile"})
+
+    assert mock_archive.timelapse_path is None
+    assert mock_archive.status == "printing"
+
+
+@pytest.mark.asyncio
+async def test_reprint_with_missing_stale_file_does_not_raise(tmp_path):
+    """If the stale file referenced by timelapse_path no longer exists on
+    disk (user deleted, archive purge, container rebuilt with bind-mount
+    drift), promotion must still clear the field cleanly without raising."""
+    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 = "TestP2S"
+
+    mock_archive = MagicMock()
+    mock_archive.id = 7
+    mock_archive.filename = "Ghost.3mf"
+    mock_archive.subtask_id = None
+    mock_archive.print_time_seconds = None
+    mock_archive.created_by_id = None
+    mock_archive.printer_id = 1
+    mock_archive.print_name = "Ghost"
+    mock_archive.status = "archived"
+    mock_archive.file_path = "archives/7/Ghost.3mf"
+    mock_archive.energy_start_kwh = None
+    # Path points at a file that doesn't exist under tmp_path.
+    mock_archive.timelapse_path = "archives/7/timelapse/vanished.mp4"
+
+    register_expected_print(1, "Ghost.3mf", archive_id=7, ams_mapping=None)
+
+    mock_session = _build_mocks(mock_printer, mock_archive)
+
+    (
+        async_session_p,
+        notif_p,
+        plug_p,
+        ws_p,
+        pm_p,
+        relay_p,
+        _energy,
+        _load_obj,
+        _store_spoolman,
+        _send_start,
+        _list_tl,
+    ) = _patches()
+
+    with (
+        async_session_p as mock_session_maker,
+        notif_p as mock_notif,
+        plug_p as mock_plug,
+        ws_p as mock_ws,
+        pm_p as mock_pm,
+        relay_p as mock_relay,
+        _energy,
+        _load_obj,
+        _store_spoolman,
+        _send_start,
+        _list_tl,
+        patch.object(app_settings, "base_dir", tmp_path),
+    ):
+        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": "Ghost.3mf", "subtask_name": "Ghost"})
+
+    assert mock_archive.timelapse_path is None
+    assert mock_archive.status == "printing"

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