Browse Source

Fix wrong timelapse attached to archive and skip calibration prints (#315)

Timelapse: Replace mtime-based "most recent file" approach with a
snapshot-diff strategy — baseline existing MP4 filenames before waiting,
then detect the new file that appears after encoding. Fixes wrong
timelapse in LAN-only mode where printer clock is unsynchronized.
Falls back to print-name matching if no new file appears after retries.

Calibration: Skip archiving internal printer files under /usr/ (e.g.
/usr/etc/print/auto_cali_for_user.gcode) so calibration prints don't
appear in the archive.
maziggy 3 tháng trước cách đây
mục cha
commit
c3018f06b2
3 tập tin đã thay đổi với 650 bổ sung70 xóa
  1. 2 0
      CHANGELOG.md
  2. 162 70
      backend/app/main.py
  3. 486 0
      backend/tests/unit/test_archive_filtering.py

+ 2 - 0
CHANGELOG.md

@@ -28,6 +28,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **Virtual Printer FTP Transfer Fails With Connection Reset** ([#58](https://github.com/maziggy/bambuddy/issues/58)) — Large 3MF uploads to the virtual printer intermittently failed with `[Errno 104] Connection reset by peer` while the small verify_job always succeeded. The `_handle_data_connection` callback returned immediately, allowing the asyncio server-handler task to complete while the data connection was still in active use. The passive port listener also stayed open during transfers, risking duplicate data connections. Fixed by keeping the callback alive until the transfer completes (`_transfer_done` event), closing the passive listener after accepting the connection, and rejecting duplicate data connections. Also added a 5-second drain timeout to MQTT status pushes to prevent blocking when the slicer is busy uploading.
 - **Virtual Printer IP Override for Server Mode** ([#52](https://github.com/maziggy/bambuddy/issues/52)) — The `remote_interface_ip` setting (network interface override) was only used in proxy mode, but users with multiple network interfaces (LAN + Tailscale, Docker bridges) also needed it in server modes (immediate/review/print_queue). Auto-detected IP from `_get_local_ip()` followed the OS default route, causing wrong IP in TLS certificate SAN (handshake failures) and SSDP broadcasts (slicer can't discover printer). Now the interface override applies to all modes: included in certificate SAN, passed to SSDP server as advertise IP, and triggers service restart on change. UI dropdown shown for all modes when enabled (not just proxy).
 - **Wrong Thumbnail When Reprinting Same Project** ([#314](https://github.com/maziggy/bambuddy/issues/314)) — Reprinting a project with the same name but a different bed layout showed the old thumbnail during printing. The cover image cache was keyed by `subtask_name` and never invalidated between prints, so a cache hit returned the stale first-print thumbnail. Now the cover cache is cleared on every print start.
+- **Wrong Timelapse Attached to Archive** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — After a print, the archive could receive a timelapse from a previous print instead of the just-completed one. The auto-scan sorted MP4 files by mtime and grabbed the "most recent," but in LAN-only mode (no NTP) the printer's clock is wrong, making mtime unreliable. Replaced with a snapshot-diff approach: baseline existing files before waiting, then detect the new file that appears after encoding. Falls back to print-name matching if no new file is found after retries.
+- **Calibration Prints Archived** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — Standalone calibration prints (flow, vibration, bed leveling) were being archived as regular prints. The calibration gcode (`/usr/etc/print/auto_cali_for_user.gcode`) and other internal printer files under `/usr/` are now detected and skipped during print start.
 - **Camera Stop 401 When Auth Enabled** — Camera stop requests (`sendBeacon`) failed with 401 Unauthorized when authentication was enabled because `sendBeacon` cannot send auth headers. Replaced with `fetch` + `keepalive: true` which supports Authorization headers while remaining reliable during page unload.
 - **Spoolman Creates Duplicate Spools on Startup** ([#295](https://github.com/maziggy/bambuddy/pull/295)) — Each AMS tray independently fetched all spools from Spoolman, causing redundant API calls and duplicate spool creation with large databases (300+ spools). Now fetches spools once and reuses cached data across all tray operations. Added retry logic (3 attempts, 500ms delay) with connection recreation for transient network errors.
 - **Filament Usage Charts Inflated by Quantity Multiplier** ([#229](https://github.com/maziggy/bambuddy/issues/229)) — Daily, weekly, and filament-type charts were multiplying `filament_used_grams` by print quantity, even though the value already represents the total for the entire job. A 26-object print using 126g was counted as 3,276g. Removed the erroneous multiplier from three aggregations in `FilamentTrends.tsx`.

+ 162 - 70
backend/app/main.py

@@ -873,6 +873,14 @@ async def on_print_start(printer_id: int, data: dict):
 
         logger.info("[CALLBACK] Print start detected - filename: %s, subtask: %s", filename, subtask_name)
 
+        # Skip calibration prints — internal printer files should not be archived
+        # Bambu calibration gcode lives under /usr/ (e.g. /usr/etc/print/auto_cali_for_user.gcode)
+        if filename and filename.startswith("/usr/"):
+            logger.info("[CALLBACK] Skipping archive — internal printer file detected: %s", filename)
+            if not notification_sent:
+                await _send_print_start_notification(printer_id, data, logger=logger)
+            return
+
         if not filename and not subtask_name:
             # Send notification without archive data (no filename)
             logger.info("[CALLBACK] Skipping archive - no filename or subtask_name")
@@ -1401,33 +1409,105 @@ async def on_print_start(printer_id: int, data: dict):
                 temp_path.unlink()
 
 
+async def _list_timelapse_mp4s(printer) -> tuple[list[dict], str | None]:
+    """List MP4 files from printer's timelapse directory.
+
+    Returns (mp4_files, found_path) where mp4_files is a list of file dicts
+    and found_path is the directory where they were found, or ([], None).
+    """
+    from backend.app.services.bambu_ftp import list_files_async
+
+    logger = logging.getLogger(__name__)
+
+    for timelapse_path in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
+        try:
+            found_files = await list_files_async(
+                printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
+            )
+            if found_files:
+                mp4_files = [f for f in found_files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
+                if mp4_files:
+                    return mp4_files, timelapse_path
+        except Exception as e:
+            logger.debug("[TIMELAPSE] Path %s failed: %s", timelapse_path, e)
+            continue
+
+    return [], None
+
+
 async def _scan_for_timelapse_with_retries(archive_id: int):
     """
-    Scan for timelapse with retries.
+    Scan for timelapse with retries using a snapshot-diff approach.
 
-    The printer encodes the timelapse quickly after print completion.
-    We just need a short delay then grab the most recent file.
+    Instead of picking the "most recent by mtime" (unreliable when the printer
+    clock is wrong in LAN-only mode), we snapshot existing MP4 filenames BEFORE
+    waiting, then look for any NEW filename that appears after each delay.
 
-    Since we KNOW timelapse was active (from MQTT ipcam data), the most recent
-    file in /timelapse is our target. Retries handle FTP connection issues.
+    Falls back to name-matching (print name contained in MP4 filename) if no
+    new file appears after all retries.
     """
+    from pathlib import Path
+
     logger = logging.getLogger(__name__)
 
-    # Short delays - printer usually finishes encoding within seconds
-    retry_delays = [5, 10, 20]
+    # --- Phase 1: Take baseline snapshot of existing timelapse files ---
+    try:
+        async with async_session() as db:
+            from backend.app.models.printer import Printer
+
+            service = ArchiveService(db)
+            archive = await service.get_archive(archive_id)
+
+            if not archive:
+                logger.warning("[TIMELAPSE] Archive %s not found, aborting", archive_id)
+                return
+            if archive.timelapse_path:
+                logger.info("[TIMELAPSE] Archive %s already has timelapse attached", archive_id)
+                return
+            if not archive.printer_id:
+                logger.warning("[TIMELAPSE] Archive %s has no printer, aborting", archive_id)
+                return
+
+            result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
+            printer = result.scalar_one_or_none()
+            if not printer:
+                logger.warning("[TIMELAPSE] Printer not found for archive %s, aborting", archive_id)
+                return
+
+            # Snapshot current MP4 filenames as baseline
+            baseline_files, _ = await _list_timelapse_mp4s(printer)
+            baseline_names: set[str] = {f.get("name", "") for f in baseline_files}
+            logger.info(
+                "[TIMELAPSE] Baseline snapshot: %s existing MP4 files for archive %s", len(baseline_names), archive_id
+            )
+
+            # Derive base_name for name-matching fallback
+            base_name = Path(archive.filename).stem if archive.filename else ""
+            if base_name.endswith(".gcode"):
+                base_name = base_name[:-6]
+
+    except Exception as e:
+        logger.warning("[TIMELAPSE] Failed to take baseline snapshot for archive %s: %s", archive_id, e)
+        return
+
+    # --- Phase 2: Retry loop — look for NEW files that weren't in baseline ---
+    retry_delays = [5, 10, 20, 30]
 
     for attempt, delay in enumerate(retry_delays, 1):
         logger.info(
-            f"[TIMELAPSE] Attempt {attempt}/{len(retry_delays)}: waiting {delay}s before scanning for archive {archive_id}"
+            "[TIMELAPSE] Attempt %s/%s: waiting %ss before scanning for archive %s",
+            attempt,
+            len(retry_delays),
+            delay,
+            archive_id,
         )
         await asyncio.sleep(delay)
 
         try:
             async with async_session() as db:
                 from backend.app.models.printer import Printer
-                from backend.app.services.bambu_ftp import download_file_bytes_async, list_files_async
+                from backend.app.services.bambu_ftp import download_file_bytes_async
 
-                # Get archive (ArchiveService from module-level import)
                 service = ArchiveService(db)
                 archive = await service.get_archive(archive_id)
 
@@ -1437,87 +1517,99 @@ async def _scan_for_timelapse_with_retries(archive_id: int):
                 if archive.timelapse_path:
                     logger.info("[TIMELAPSE] Archive %s already has timelapse attached, stopping retries", archive_id)
                     return
-                if not archive.printer_id:
-                    logger.warning("[TIMELAPSE] Archive %s has no printer, stopping retries", archive_id)
-                    return
 
-                # Get printer
                 result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
                 printer = result.scalar_one_or_none()
-
                 if not printer:
                     logger.warning("[TIMELAPSE] Printer not found for archive %s, stopping retries", archive_id)
                     return
 
-                # Scan timelapse directory on printer
-                # H2D may store in different locations than X1C
-                files = []
-                found_path = None
-                for timelapse_path in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
-                    try:
-                        found_files = await list_files_async(
-                            printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
-                        )
-                        if found_files:
-                            files = found_files
-                            found_path = timelapse_path
-                            logger.info(
-                                "[TIMELAPSE] Attempt %s: Found %s files in %s", attempt, len(files), timelapse_path
-                            )
-                            break
-                    except Exception as e:
-                        logger.debug("[TIMELAPSE] Path %s failed: %s", timelapse_path, e)
-                        continue
-
-                if not files:
-                    logger.info("[TIMELAPSE] Attempt %s: No timelapse files found on printer, will retry", attempt)
-                    continue
-
-                mp4_files = [f for f in files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
-
-                # Log ALL mp4 files found for debugging
-                logger.info("[TIMELAPSE] Attempt %s: Found %s MP4 files in %s", attempt, len(mp4_files), found_path)
-                for f in mp4_files[:5]:  # Log first 5
-                    logger.info("[TIMELAPSE]   - %s, mtime=%s", f.get("name"), f.get("mtime"))
+                mp4_files, found_path = await _list_timelapse_mp4s(printer)
 
                 if not mp4_files:
                     logger.info("[TIMELAPSE] Attempt %s: No MP4 files found, will retry", attempt)
                     continue
 
-                # Sort by mtime descending to get most recent file
-                mp4_files_with_mtime = [f for f in mp4_files if f.get("mtime")]
-                if not mp4_files_with_mtime:
-                    logger.info("[TIMELAPSE] Attempt %s: No MP4 files with mtime found, will retry", attempt)
-                    continue
-
-                mp4_files_with_mtime.sort(key=lambda x: x.get("mtime"), reverse=True)
-                most_recent = mp4_files_with_mtime[0]
+                logger.info("[TIMELAPSE] Attempt %s: Found %s MP4 files in %s", attempt, len(mp4_files), found_path)
+                for f in mp4_files[:5]:
+                    logger.info("[TIMELAPSE]   - %s", f.get("name"))
 
-                file_name = most_recent.get("name")
-                logger.info("[TIMELAPSE] Attempt %s: Most recent file: %s", attempt, file_name)
+                # Find files that are NEW (not in baseline snapshot)
+                new_files = [f for f in mp4_files if f.get("name", "") not in baseline_names]
 
-                # Since we KNOW timelapse was active (from MQTT), just grab the most recent file
-                remote_path = most_recent.get("path") or f"/timelapse/{file_name}"
-                logger.info("[TIMELAPSE] Downloading %s for archive %s", file_name, archive_id)
-                timelapse_data = await download_file_bytes_async(
-                    printer.ip_address, printer.access_code, remote_path, printer_model=printer.model
-                )
+                if new_files:
+                    # Pick the first new file (there should typically be exactly one)
+                    target = new_files[0]
+                    file_name = target.get("name")
+                    remote_path = target.get("path") or f"/timelapse/{file_name}"
+                    logger.info(
+                        "[TIMELAPSE] Attempt %s: New file detected: %s (downloading for archive %s)",
+                        attempt,
+                        file_name,
+                        archive_id,
+                    )
 
-                if timelapse_data:
-                    success = await service.attach_timelapse(archive_id, timelapse_data, file_name)
-                    if success:
-                        logger.info("[TIMELAPSE] Successfully attached timelapse to archive %s", archive_id)
-                        await ws_manager.send_archive_updated({"id": archive_id, "timelapse_attached": True})
-                        return  # Success!
+                    timelapse_data = await download_file_bytes_async(
+                        printer.ip_address, printer.access_code, remote_path, printer_model=printer.model
+                    )
+                    if timelapse_data:
+                        success = await service.attach_timelapse(archive_id, timelapse_data, file_name)
+                        if success:
+                            logger.info("[TIMELAPSE] Successfully attached timelapse to archive %s", archive_id)
+                            await ws_manager.send_archive_updated({"id": archive_id, "timelapse_attached": True})
+                            return
+                        else:
+                            logger.warning("[TIMELAPSE] Failed to attach timelapse to archive %s", archive_id)
                     else:
-                        logger.warning("[TIMELAPSE] Failed to attach timelapse to archive %s", archive_id)
+                        logger.warning("[TIMELAPSE] Attempt %s: Failed to download new file, will retry", attempt)
                 else:
-                    logger.warning("[TIMELAPSE] Attempt %s: Failed to download, will retry", attempt)
+                    logger.info("[TIMELAPSE] Attempt %s: No new files since baseline, will retry", attempt)
 
         except Exception as e:
             logger.warning("[TIMELAPSE] Attempt %s failed with error: %s", attempt, e)
 
-    logger.warning("[TIMELAPSE] All %s attempts exhausted for archive %s, giving up", len(retry_delays), archive_id)
+    # --- Phase 3: Fallback — try name matching against all files ---
+    if base_name:
+        logger.info("[TIMELAPSE] Retries exhausted, trying name-match fallback for '%s'", base_name)
+        try:
+            async with async_session() as db:
+                from backend.app.models.printer import Printer
+                from backend.app.services.bambu_ftp import download_file_bytes_async
+
+                service = ArchiveService(db)
+                archive = await service.get_archive(archive_id)
+                if not archive or archive.timelapse_path:
+                    return
+
+                result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
+                printer = result.scalar_one_or_none()
+                if not printer:
+                    return
+
+                mp4_files, found_path = await _list_timelapse_mp4s(printer)
+                for f in mp4_files:
+                    fname = f.get("name", "")
+                    if base_name.lower() in fname.lower():
+                        remote_path = f.get("path") or f"/timelapse/{fname}"
+                        logger.info("[TIMELAPSE] Name-match fallback: '%s' matches '%s'", base_name, fname)
+
+                        timelapse_data = await download_file_bytes_async(
+                            printer.ip_address, printer.access_code, remote_path, printer_model=printer.model
+                        )
+                        if timelapse_data:
+                            success = await service.attach_timelapse(archive_id, timelapse_data, fname)
+                            if success:
+                                logger.info(
+                                    "[TIMELAPSE] Name-match fallback attached timelapse to archive %s", archive_id
+                                )
+                                await ws_manager.send_archive_updated({"id": archive_id, "timelapse_attached": True})
+                                return
+                        break  # Only try the first name match
+
+        except Exception as e:
+            logger.warning("[TIMELAPSE] Name-match fallback failed: %s", e)
+
+    logger.warning("[TIMELAPSE] All attempts exhausted for archive %s, giving up", archive_id)
 
 
 async def on_print_complete(printer_id: int, data: dict):

+ 486 - 0
backend/tests/unit/test_archive_filtering.py

@@ -0,0 +1,486 @@
+"""
+Unit tests for archive filtering and timelapse snapshot-diff logic.
+
+Tests:
+1. Calibration print filtering — /usr/ prefix skips archive creation
+2. Timelapse snapshot-diff — _list_timelapse_mp4s and _scan_for_timelapse_with_retries
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+# Patch paths for lazy imports inside functions
+_FTP_MODULE = "backend.app.services.bambu_ftp"
+
+
+class TestCalibrationPrintFiltering:
+    """Test that internal printer files under /usr/ are not archived."""
+
+    @pytest.mark.asyncio
+    async def test_usr_prefix_skips_archive(self, capture_logs):
+        """Calibration gcode (/usr/etc/print/auto_cali_for_user.gcode) should skip archiving."""
+        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,
+        ):
+            mock_notif.on_print_start = AsyncMock()
+            mock_plug.on_print_start = AsyncMock()
+            mock_ws.send_print_start = AsyncMock()
+            mock_relay.on_print_start = AsyncMock()
+            mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+            # Mock printer with auto_archive enabled
+            mock_printer = MagicMock()
+            mock_printer.auto_archive = True
+            mock_printer.id = 1
+
+            mock_session = AsyncMock()
+            mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+            mock_session.__aexit__ = AsyncMock()
+            mock_session.execute = AsyncMock(
+                return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
+            )
+            mock_session_maker.return_value = mock_session
+
+            # Mock _send_print_start_notification
+            with patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock) as mock_notif_send:
+                from backend.app.main import on_print_start
+
+                await on_print_start(
+                    1,
+                    {
+                        "filename": "/usr/etc/print/auto_cali_for_user.gcode",
+                        "subtask_name": "auto_cali_for_user",
+                    },
+                )
+
+                # Notification should still be sent
+                mock_notif_send.assert_called_once()
+
+        # Verify the skip was logged
+        info_messages = [r.message for r in capture_logs.records if r.levelno >= 20]
+        skip_msgs = [m for m in info_messages if "internal printer file" in str(m)]
+        assert skip_msgs, "Should log that internal printer file was skipped"
+
+    @pytest.mark.asyncio
+    async def test_usr_prefix_various_paths(self, capture_logs):
+        """Various /usr/ paths should all be skipped."""
+        test_paths = [
+            "/usr/etc/print/auto_cali_for_user.gcode",
+            "/usr/etc/print/some_other_calibration.gcode",
+            "/usr/bin/firmware_test.gcode",
+        ]
+
+        for path in test_paths:
+            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._send_print_start_notification", new_callable=AsyncMock),
+            ):
+                mock_notif.on_print_start = AsyncMock()
+                mock_plug.on_print_start = AsyncMock()
+                mock_ws.send_print_start = AsyncMock()
+                mock_relay.on_print_start = AsyncMock()
+                mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+                mock_printer = MagicMock()
+                mock_printer.auto_archive = True
+                mock_printer.id = 1
+
+                mock_session = AsyncMock()
+                mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+                mock_session.__aexit__ = AsyncMock()
+                mock_session.execute = AsyncMock(
+                    return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
+                )
+                mock_session_maker.return_value = mock_session
+
+                from backend.app.main import on_print_start
+
+                await on_print_start(1, {"filename": path, "subtask_name": "test"})
+
+            skip_msgs = [r for r in capture_logs.records if "internal printer file" in str(r.message)]
+            assert skip_msgs, f"Path {path} should be skipped"
+            capture_logs.clear()
+
+    @pytest.mark.asyncio
+    async def test_normal_gcode_not_skipped(self, capture_logs):
+        """User gcode files under /data/ should NOT be skipped."""
+        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,
+        ):
+            mock_notif.on_print_start = AsyncMock()
+            mock_plug.on_print_start = AsyncMock()
+            mock_ws.send_print_start = AsyncMock()
+            mock_relay.on_print_start = AsyncMock()
+            mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+            mock_printer = MagicMock()
+            mock_printer.auto_archive = True
+            mock_printer.id = 1
+
+            mock_session = AsyncMock()
+            mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+            mock_session.__aexit__ = AsyncMock()
+            mock_session.execute = AsyncMock(
+                return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
+            )
+            mock_session_maker.return_value = mock_session
+
+            from backend.app.main import on_print_start
+
+            await on_print_start(
+                1,
+                {
+                    "filename": "/data/Metadata/benchy.gcode.3mf",
+                    "subtask_name": "benchy",
+                },
+            )
+
+        # Should NOT see "internal printer file" skip message
+        skip_msgs = [r for r in capture_logs.records if "internal printer file" in str(r.message)]
+        assert not skip_msgs, "User gcode should not be skipped"
+
+
+class TestListTimelapseMp4s:
+    """Test the _list_timelapse_mp4s helper function."""
+
+    @pytest.mark.asyncio
+    async def test_finds_mp4_files_in_timelapse_dir(self):
+        """Should return MP4 files found in /timelapse directory."""
+        mock_printer = MagicMock()
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "X1C"
+
+        mock_files = [
+            {"name": "video1.mp4", "is_directory": False, "size": 1000, "path": "/timelapse/video1.mp4"},
+            {"name": "video2.mp4", "is_directory": False, "size": 2000, "path": "/timelapse/video2.mp4"},
+            {"name": "thumbs", "is_directory": True, "size": 0, "path": "/timelapse/thumbs"},
+            {"name": "video3.avi", "is_directory": False, "size": 500, "path": "/timelapse/video3.avi"},
+        ]
+
+        with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
+            mock_list.return_value = mock_files
+
+            from backend.app.main import _list_timelapse_mp4s
+
+            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+
+        assert len(mp4s) == 2
+        assert path == "/timelapse"
+        assert all(f["name"].endswith(".mp4") for f in mp4s)
+
+    @pytest.mark.asyncio
+    async def test_tries_multiple_directories(self):
+        """Should try /timelapse, /timelapse/video, /record, /recording."""
+        mock_printer = MagicMock()
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "H2D"
+
+        async def mock_list_files(ip, code, path, printer_model=None):
+            if path == "/record":
+                return [{"name": "clip.mp4", "is_directory": False, "size": 500, "path": "/record/clip.mp4"}]
+            return []
+
+        with patch(f"{_FTP_MODULE}.list_files_async", side_effect=mock_list_files):
+            from backend.app.main import _list_timelapse_mp4s
+
+            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+
+        assert len(mp4s) == 1
+        assert path == "/record"
+        assert mp4s[0]["name"] == "clip.mp4"
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_no_files(self):
+        """Should return ([], None) when no MP4 files exist."""
+        mock_printer = MagicMock()
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "X1C"
+
+        with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
+            mock_list.return_value = []
+
+            from backend.app.main import _list_timelapse_mp4s
+
+            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+
+        assert mp4s == []
+        assert path is None
+
+    @pytest.mark.asyncio
+    async def test_skips_directories(self):
+        """Should filter out directory entries even if named .mp4."""
+        mock_printer = MagicMock()
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "X1C"
+
+        mock_files = [
+            {"name": "fake.mp4", "is_directory": True, "size": 0, "path": "/timelapse/fake.mp4"},
+            {"name": "real.mp4", "is_directory": False, "size": 1000, "path": "/timelapse/real.mp4"},
+        ]
+
+        with patch(f"{_FTP_MODULE}.list_files_async", new_callable=AsyncMock) as mock_list:
+            mock_list.return_value = mock_files
+
+            from backend.app.main import _list_timelapse_mp4s
+
+            mp4s, path = await _list_timelapse_mp4s(mock_printer)
+
+        assert len(mp4s) == 1
+        assert mp4s[0]["name"] == "real.mp4"
+
+
+class TestScanForTimelapseWithRetries:
+    """Test the snapshot-diff timelapse scan logic."""
+
+    def _make_mocks(self, archive_filename="benchy.gcode.3mf", timelapse_path=None):
+        """Create standard mock archive and printer."""
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.timelapse_path = timelapse_path
+        mock_archive.printer_id = 1
+        mock_archive.filename = archive_filename
+
+        mock_printer = MagicMock()
+        mock_printer.id = 1
+        mock_printer.ip_address = "192.168.1.100"
+        mock_printer.access_code = "12345678"
+        mock_printer.model = "X1C"
+
+        return mock_archive, mock_printer
+
+    def _make_session_mock(self, mock_printer):
+        """Create a mock async session that returns the given printer."""
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock()
+        mock_session.execute = AsyncMock(
+            return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=mock_printer))
+        )
+        return mock_session
+
+    @pytest.mark.asyncio
+    async def test_detects_new_file_after_baseline(self):
+        """Should detect a file that wasn't in the baseline snapshot."""
+        mock_archive, mock_printer = self._make_mocks()
+
+        baseline_files = [
+            {"name": "old_video.mp4", "is_directory": False, "size": 1000, "path": "/timelapse/old_video.mp4"},
+        ]
+        new_files = baseline_files + [
+            {"name": "new_video.mp4", "is_directory": False, "size": 2000, "path": "/timelapse/new_video.mp4"},
+        ]
+
+        call_count = 0
+
+        async def mock_list_mp4s(printer):
+            nonlocal call_count
+            call_count += 1
+            if call_count == 1:
+                return baseline_files, "/timelapse"
+            return new_files, "/timelapse"
+
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=mock_archive)
+        mock_service.attach_timelapse = AsyncMock(return_value=True)
+        mock_session = self._make_session_mock(mock_printer)
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+            patch(f"{_FTP_MODULE}.download_file_bytes_async", new_callable=AsyncMock) as mock_download,
+        ):
+            mock_ws.send_archive_updated = AsyncMock()
+            mock_download.return_value = b"fake video data"
+
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(1)
+
+        # Should have attached the NEW file, not the old one
+        mock_service.attach_timelapse.assert_called_once()
+        attached_filename = mock_service.attach_timelapse.call_args[0][2]
+        assert attached_filename == "new_video.mp4", f"Expected new_video.mp4, got {attached_filename}"
+
+    @pytest.mark.asyncio
+    async def test_ignores_old_files_with_wrong_mtime(self):
+        """Should not pick old files even if they'd sort first by mtime."""
+        mock_archive, mock_printer = self._make_mocks()
+
+        # Both old files exist at baseline — neither should be picked
+        baseline_files = [
+            {"name": "old_video1.mp4", "is_directory": False, "size": 1000, "path": "/timelapse/old_video1.mp4"},
+            {"name": "old_video2.mp4", "is_directory": False, "size": 2000, "path": "/timelapse/old_video2.mp4"},
+        ]
+
+        # Always return same files — no new file ever appears
+        async def mock_list_mp4s(printer):
+            return baseline_files, "/timelapse"
+
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=mock_archive)
+        mock_service.attach_timelapse = AsyncMock(return_value=True)
+        mock_session = self._make_session_mock(mock_printer)
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+            patch(f"{_FTP_MODULE}.download_file_bytes_async", new_callable=AsyncMock) as mock_download,
+        ):
+            mock_ws.send_archive_updated = AsyncMock()
+            mock_download.return_value = b"fake video data"
+
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(1)
+
+        # "benchy" not in "old_video1.mp4" or "old_video2.mp4" — no match at all
+        mock_service.attach_timelapse.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_name_match_fallback(self):
+        """When no new file appears, should fall back to name matching."""
+        mock_archive, mock_printer = self._make_mocks()
+
+        baseline_files = [
+            {"name": "old_video.mp4", "is_directory": False, "size": 1000, "path": "/timelapse/old_video.mp4"},
+            {
+                "name": "benchy_20240101.mp4",
+                "is_directory": False,
+                "size": 2000,
+                "path": "/timelapse/benchy_20240101.mp4",
+            },
+        ]
+
+        async def mock_list_mp4s(printer):
+            return baseline_files, "/timelapse"
+
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=mock_archive)
+        mock_service.attach_timelapse = AsyncMock(return_value=True)
+        mock_session = self._make_session_mock(mock_printer)
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock),
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+            patch(f"{_FTP_MODULE}.download_file_bytes_async", new_callable=AsyncMock) as mock_download,
+        ):
+            mock_ws.send_archive_updated = AsyncMock()
+            mock_download.return_value = b"fake video data"
+
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(1)
+
+        # Name-match fallback: "benchy" is in "benchy_20240101.mp4"
+        mock_service.attach_timelapse.assert_called_once()
+        attached_filename = mock_service.attach_timelapse.call_args[0][2]
+        assert attached_filename == "benchy_20240101.mp4"
+
+    @pytest.mark.asyncio
+    async def test_stops_when_archive_already_has_timelapse(self):
+        """Should stop immediately if archive already has a timelapse."""
+        mock_archive, _ = self._make_mocks(timelapse_path="/some/existing/timelapse.mp4")
+
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=mock_archive)
+
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock()
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_mp4s", new_callable=AsyncMock) as mock_list,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+        ):
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(1)
+
+        # Should not have tried to list files or sleep
+        mock_list.assert_not_called()
+        mock_sleep.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_stops_when_archive_not_found(self):
+        """Should stop immediately if archive doesn't exist."""
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=None)
+
+        mock_session = AsyncMock()
+        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+        mock_session.__aexit__ = AsyncMock()
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_mp4s", new_callable=AsyncMock) as mock_list,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+        ):
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(999)
+
+        mock_list.assert_not_called()
+        mock_sleep.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_retries_four_times(self):
+        """Should retry with delays [5, 10, 20, 30]."""
+        mock_archive, mock_printer = self._make_mocks(archive_filename="test.gcode.3mf")
+
+        # Never find any files
+        async def mock_list_mp4s(printer):
+            return [], None
+
+        mock_service = MagicMock()
+        mock_service.get_archive = AsyncMock(return_value=mock_archive)
+        mock_session = self._make_session_mock(mock_printer)
+
+        with (
+            patch("backend.app.main.async_session", return_value=mock_session),
+            patch("backend.app.main._list_timelapse_mp4s", side_effect=mock_list_mp4s),
+            patch("backend.app.main.ws_manager") as mock_ws,
+            patch("backend.app.main.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
+            patch("backend.app.main.ArchiveService", return_value=mock_service),
+        ):
+            mock_ws.send_archive_updated = AsyncMock()
+
+            from backend.app.main import _scan_for_timelapse_with_retries
+
+            await _scan_for_timelapse_with_retries(1)
+
+        # Should have slept 4 times with delays [5, 10, 20, 30]
+        assert mock_sleep.call_count == 4
+        sleep_args = [call.args[0] for call in mock_sleep.call_args_list]
+        assert sleep_args == [5, 10, 20, 30]