mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 12:46:49 +02:00
Timelapse was on, the video never reached the archive, and Scan for Timelapse found nothing afterwards. Across 247 support bundles this was the norm, not an edge case: 457 automatic scans scheduled, 262 attached. The scan looked four times over ~65s. The attempt that found the video was #1 272 times, then 17 / 13 / 13 — flat against the cutoff, not decaying, i.e. files were still arriving when we stopped. What ran afterwards searched for the print name inside the filename; Bambu only writes "video_<timestamp>", so it fired 159 times and matched zero. The manual Scan had no baseline at all and matched on filename timestamp, FTP mtime, or "there is only one video" — all reading a clock a LAN-only printer cannot sync. The reporter's P1S was six and a half days out. - Poll for minutes instead of ~65s; drop the name-match fallback. - Persist the print-start baseline on the archive, so the diff survives a restart mid-print and the manual Scan runs the same comparison. With a baseline present the clock-based strategies are skipped entirely — they can only turn an honest "pick one" into a confident wrong answer. - When several files are new (a previous print's video landing late), exclude the ones already attached to another archive instead of ordering the candidates. Ordering could only be done on the printer's clock. - Delete the video from the printer once archived. Keeps /timelapse to unclaimed files, which is what makes the diff unambiguous, and stops P1S cards filling with AVIs. - Gate that delete on a verified transfer: download_file now compares against the size from the listing. An FTPS connection closing early does not always raise, so a partial buffer was being attached as a complete video — which would also have been the one case where deleting the source lost data. Bounded twice on purpose: wall-clock deadline plus a derived round cap, since the deadline stops bounding the loop as soon as the sleeps are shortened. Per-round logging only speaks when the listing changed — 31 rounds of full listings would bury the interesting line in the support bundle. Migration adds print_archives.timelapse_baseline as JSON, spelled the same on both dialects so a migrated database matches a fresh one. ----------- fix(finish-photo): add the timelapse frame to the archive after the notification (#2704) When a print records a timelapse, its last frame is the better finish photo: the firmware stops recording with the toolhead parked and before the end G-code drops the bed, where a live grab at that moment catches a lowered plate. Bambuddy waited 60s for the video and then gave up, because the print-complete notification blocks on that photo and holding a notification for minutes is worse than sending it with the live grab. P1-series printers write MJPEG AVI rather than H.264 MP4 and serve it slowly. Measured over 261 attaches in the support bundles: P1S median 33s, p90 167s, worst 546s, while every other model finished inside 26s. So the printers that most needed the better framing were the ones that never got it. Keep the notification on the same bound, and keep waiting off to the side. _capture_finish_photo_from_timelapse now reports whether it ran out of time or concluded — a video that landed and failed extraction is not worth retrying, one that never arrived is. On the first, schedule a background task that waits up to 15 minutes and inserts the extracted frame at the front of the archive's photo list, where the gallery opens. The live grab stays on disk: the notification already links to that exact file, so removing it would leave a broken image in Discord or Telegram. The length check proves we received what the listing said, not that the file was finished. The first look happens ~5s after the print ends, while the printer may still be writing, so a growing file can be listed short, served short, and pass. Re-list after the download and only accept the video once its size has stopped changing — a failed re-list counts as not settled, since "could not check" must not mean "safe to delete".
145 lines
6.2 KiB
Python
145 lines
6.2 KiB
Python
"""Regression tests for the #2572 timelapse-scan session-boundary refactor.
|
|
|
|
``POST /archives/{id}/timelapse/scan`` used to hold its ``Depends(get_db)``
|
|
session open across the FTP directory listing *and* the multi-MB video
|
|
download. It now (1) reads the archive + printer in a short session and
|
|
releases the pooled connection *before* the FTP work, then (2) re-opens a
|
|
fresh short session only to attach the downloaded file.
|
|
|
|
Two things that refactor could have broken, one test each:
|
|
|
|
* The matching logic reads ``archive.filename/started_at/completed_at/
|
|
created_at`` and ``printer.ip_address/...`` AFTER the read session has
|
|
closed. If any were a lazy-loaded relationship (or an expired column) that
|
|
would raise ``DetachedInstanceError``. The not-found test drives every
|
|
match strategy, exercising all of those detached reads.
|
|
|
|
* The attach write runs in a *fresh* ``async_session()``, which — unlike
|
|
``get_db`` — does NOT auto-commit on block exit. If ``attach_timelapse``
|
|
didn't commit internally the write would be silently dropped. The attach
|
|
test asserts the row is actually persisted.
|
|
|
|
FTP is fully mocked, so no printer is contacted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_timelapse_no_match_reads_detached_archive_scalars(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Two non-matching videos → 200 not_found, driving every match strategy.
|
|
|
|
Strategies 2-4 read archive.started_at/completed_at/created_at after the
|
|
read session closed; this fails with DetachedInstanceError if the refactor
|
|
left one of those as a lazy load.
|
|
"""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
|
|
|
|
# Two videos, neither matching by name, no mtime, and the archive has no
|
|
# started_at — so strategy 1 (name) misses, 2 (start time) and 3 (mtime)
|
|
# are skipped, and 4 (single-file fallback) is disqualified by len == 2.
|
|
listing = [
|
|
{"name": "clip_a.mp4", "path": "/timelapse/clip_a.mp4", "is_directory": False, "size": 10, "mtime": None},
|
|
{"name": "clip_b.mp4", "path": "/timelapse/clip_b.mp4", "is_directory": False, "size": 20, "mtime": None},
|
|
]
|
|
|
|
with (
|
|
patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=listing)),
|
|
patch(
|
|
"backend.app.services.bambu_ftp.get_ftp_retry_settings",
|
|
AsyncMock(return_value=(False, 3, 2.0, 30.0)),
|
|
),
|
|
patch(
|
|
"backend.app.services.bambu_ftp.download_file_bytes_async",
|
|
AsyncMock(return_value=b"should-not-be-called"),
|
|
) as mock_download,
|
|
):
|
|
response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
|
|
|
|
assert response.status_code == 200, response.text
|
|
data = response.json()
|
|
assert data["status"] == "not_found"
|
|
assert {f["name"] for f in data["available_files"]} == {"clip_a.mp4", "clip_b.mp4"}
|
|
# No match → we never download.
|
|
mock_download.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
|
|
async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path, monkeypatch
|
|
):
|
|
"""A name-matched video is downloaded and the attach PERSISTS.
|
|
|
|
Guards the fresh-session write boundary: attach_timelapse runs in a new
|
|
async_session that does not auto-commit on exit, so this only passes if
|
|
the service commits internally.
|
|
"""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
|
|
|
|
# attach_timelapse writes into settings.base_dir / archive.file_path's
|
|
# parent, then stores a base_dir-relative timelapse_path. Point base_dir at
|
|
# tmp and stage the archive dir so the real write succeeds (mirrors
|
|
# test_attach_timelapse_safe_path).
|
|
monkeypatch.setattr(
|
|
"backend.app.services.archive.settings",
|
|
MagicMock(base_dir=tmp_path),
|
|
)
|
|
archive_dir = tmp_path / "archives" / "test"
|
|
archive_dir.mkdir(parents=True)
|
|
|
|
# base_name = Path("test_print.gcode.3mf").stem = "test_print.gcode", so this
|
|
# video matches by name (strategy 1). .mp4 → no background conversion task.
|
|
video_bytes = b"fake-timelapse-video-bytes"
|
|
matched = {
|
|
"name": "test_print.gcode.mp4",
|
|
"path": "/timelapse/test_print.gcode.mp4",
|
|
"is_directory": False,
|
|
# Must equal len(video_bytes): the download is checked against the
|
|
# listing, and the file is re-listed afterwards to confirm the printer
|
|
# has stopped writing it (#2704).
|
|
"size": len(video_bytes),
|
|
"mtime": None,
|
|
}
|
|
|
|
with (
|
|
patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[matched])),
|
|
patch(
|
|
"backend.app.services.bambu_ftp.get_ftp_retry_settings",
|
|
AsyncMock(return_value=(False, 3, 2.0, 30.0)),
|
|
),
|
|
patch(
|
|
"backend.app.services.bambu_ftp.download_file_bytes_async",
|
|
AsyncMock(return_value=video_bytes),
|
|
) as mock_download,
|
|
# A successful attach now removes the printer's copy (#2704); without
|
|
# this the endpoint would open a real FTP connection to the fixture IP.
|
|
patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()) as mock_delete,
|
|
):
|
|
response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
|
|
|
|
assert response.status_code == 200, response.text
|
|
data = response.json()
|
|
assert data["status"] == "attached"
|
|
assert data["filename"] == "test_print.gcode.mp4"
|
|
mock_download.assert_awaited_once()
|
|
mock_delete.assert_awaited_once()
|
|
|
|
# The write happened in the route's fresh session; confirm it was committed
|
|
# by re-reading the row on the separate test session.
|
|
await db_session.refresh(archive)
|
|
assert archive.timelapse_path is not None
|
|
assert archive.timelapse_path.endswith("test_print.gcode.mp4")
|
|
# And the bytes actually landed on disk under the staged archive dir.
|
|
assert (archive_dir / "test_print.gcode.mp4").read_bytes() == video_bytes
|