Files
bambuddy/backend/app/models/archive.py
maziggy 5a67dffe4f fix(timelapse): poll longer, diff without a clock, delete once archived (#2704)
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".
2026-07-29 11:51:30 +02:00

131 lines
7.3 KiB
Python

from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class PrintArchive(Base):
__tablename__ = "print_archives"
id: Mapped[int] = mapped_column(primary_key=True)
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
# Which library file this run was dispatched from (#1897). Set by the queue
# scheduler when it archives a library-file print; older rows are matched by
# content_hash/filename instead. SET NULL so deleting a file keeps history.
library_file_id: Mapped[int | None] = mapped_column(
ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
)
# File info
filename: Mapped[str] = mapped_column(String(255))
file_path: Mapped[str] = mapped_column(String(500))
file_size: Mapped[int] = mapped_column(Integer)
content_hash: Mapped[str | None] = mapped_column(String(64)) # SHA256 hash for duplicate detection
thumbnail_path: Mapped[str | None] = mapped_column(String(500))
timelapse_path: Mapped[str | None] = mapped_column(String(500))
# True when Bambuddy forced timelapse recording on for this print so the
# finish-photo extractor (#1397) could pull the post-park-pre-drop frame.
# The cleanup path uses this to know the timelapse should be deleted
# both locally and on the printer's SD after extraction — the user
# didn't opt in to a timelapse recording.
bambuddy_forced_timelapse: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
# Video filenames present in the printer's /timelapse directory when this
# print started (#2704). The printer writes its video only at print end, so
# anything not in this list belongs to this print — a comparison that needs
# no clock, which matters because a LAN-only printer can't reach Bambu's NTP
# server and its filename timestamps are arbitrarily wrong. Persisted (not
# just held in memory) so the diff survives a restart and so the manual
# "Scan for Timelapse" button can use it instead of guessing from
# timestamps. NULL for archives predating this, and for baselines taken at
# completion time, which are useless by construction.
timelapse_baseline: Mapped[list | None] = mapped_column(JSON, nullable=True)
source_3mf_path: Mapped[str | None] = mapped_column(String(500)) # Original project 3MF from slicer
f3d_path: Mapped[str | None] = mapped_column(String(500)) # Fusion 360 design file
# Print details from 3MF / printer
print_name: Mapped[str | None] = mapped_column(String(255))
print_time_seconds: Mapped[int | None] = mapped_column(Integer)
filament_used_grams: Mapped[float | None] = mapped_column(Float)
filament_type: Mapped[str | None] = mapped_column(String(50))
filament_color: Mapped[str | None] = mapped_column(String(200))
layer_height: Mapped[float | None] = mapped_column(Float)
total_layers: Mapped[int | None] = mapped_column(Integer)
nozzle_diameter: Mapped[float | None] = mapped_column(Float)
bed_temperature: Mapped[int | None] = mapped_column(Integer)
bed_type: Mapped[str | None] = mapped_column(String(64)) # e.g. "Cool Plate", "Textured PEI Plate"
nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
# Printer model this file was sliced for (extracted from 3MF metadata)
sliced_for_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Print result
status: Mapped[str] = mapped_column(String(20), default="completed")
started_at: Mapped[datetime | None] = mapped_column(DateTime)
completed_at: Mapped[datetime | None] = mapped_column(DateTime)
# Printer-assigned subtask identifier from MQTT. Used to resume the same
# archive row across a backend restart during a long-running print (#972):
# if the same subtask_id reappears after restart, we know it's the same
# print and keep the original row instead of cancel-then-create.
subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Which plate of a multi-plate 3MF this print was for (1-based), copied from
# the queue item at dispatch (#2603). A whole multi-plate 3MF is uploaded
# under one filename with no plate suffix, so the parser can't recover the
# selected plate and extra_data holds all-plates aggregate metadata; without
# this the history UI can't tell which plate was printed and falls back to
# Plate 1. NULL for archives with no specific selected plate.
plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Extended metadata (JSON blob for flexibility)
extra_data: Mapped[dict | None] = mapped_column(JSON)
# MakerWorld info (auto-extracted from 3MF)
makerworld_url: Mapped[str | None] = mapped_column(String(500))
designer: Mapped[str | None] = mapped_column(String(255))
# User-defined external link (Printables, Thingiverse, etc.)
external_url: Mapped[str | None] = mapped_column(String(500))
# User additions
is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
tags: Mapped[str | None] = mapped_column(Text)
notes: Mapped[str | None] = mapped_column(Text)
cost: Mapped[float | None] = mapped_column(Float)
photos: Mapped[list | None] = mapped_column(JSON) # List of photo filenames
failure_reason: Mapped[str | None] = mapped_column(String(100)) # For failed prints
quantity: Mapped[int] = mapped_column(Integer, default=1) # Number of items printed
# Energy tracking
energy_kwh: Mapped[float | None] = mapped_column(Float) # Energy consumed in kWh
energy_cost: Mapped[float | None] = mapped_column(Float) # Cost of energy consumed
# Plug lifetime counter captured at print start; delta at print end becomes energy_kwh.
# Persisted so per-print tracking survives backend restarts mid-print (#941).
energy_start_kwh: Mapped[float | None] = mapped_column(Float)
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
# Soft-delete sentinel (#1343). When non-null, the UI hides this archive
# from listings (its files have already been removed from disk) but the
# stats endpoint keeps counting it — deleting nine of ten Benchies no
# longer wipes their filament / time / cost contribution from Quick Stats.
# The opt-in "Also remove from statistics" checkbox in the delete dialog
# bypasses the soft-delete path and hard-deletes the row.
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None, index=True)
# User tracking (who uploaded/created this archive)
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
# Relationships
printer: Mapped["Printer | None"] = relationship(back_populates="archives")
project: Mapped["Project | None"] = relationship(back_populates="archives")
created_by: Mapped["User | None"] = relationship()
from backend.app.models.printer import Printer # noqa: E402, F811
from backend.app.models.project import Project # noqa: E402, F811
from backend.app.models.user import User # noqa: E402, F811