From 4ee4bdb0d33ac247f1b60b05c9a7507bc0b3eb28 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 12 May 2026 09:20:53 +0200 Subject: [PATCH] fix(archives): scan_timelapse picked stale video at false offset (#1278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan_timelapse's Strategy 2 matched filename timestamps against both archive.started_at and archive.completed_at across seven hypothesised tz offsets. The filename is always print-START time, so the end-time branch was a semantic mistake — and the dense offset set [0, +-1, +-7, +-8] let an unrelated video coincidentally land within minutes of any later archive at some offset. Extract Strategy 2 into _match_timelapse_by_timestamp(): compare only against start time, and refuse to auto-pick when the next-best different video is within a 15-minute ambiguity margin. The route then returns available_files and the frontend's existing manual-selection dialog takes over — which is the fallback the reporter explicitly asked for. Surfaces in LAN-Only mode where the printer can't reach NTP and its clock drifts (e.g. P2S filenames in CST while server is in UTC, the 8h offset that exposed this bug). --- CHANGELOG.md | 2 + backend/app/api/routes/archives.py | 139 +++++++++------- backend/tests/unit/test_timelapse_match.py | 177 +++++++++++++++++++++ 3 files changed, 258 insertions(+), 60 deletions(-) create mode 100644 backend/tests/unit/test_timelapse_match.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 339bbd70d..9240bf94a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ All notable changes to Bambuddy will be documented in this file. - **Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks** ([#1234](https://github.com/maziggy/bambuddy/issues/1234), [PR #1246](https://github.com/maziggy/bambuddy/pull/1246) by @MiguelAngelLV) — Adds a copy button (`Copy` icon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existing `SpoolFormModal` pre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — except `weight_used` which is reset to 0 (since the new spool starts full) and the RFID identity fields (`tag_uid`, `tray_uuid`, `tag_type`, `data_origin`) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save calls `api.createSpool` (or `api.createSpoolmanInventorySpool` in Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. **Implementation shape:** `SpoolFormModalProps.mode: 'create' | 'edit' | 'copy'` (exported as `SpoolFormMode`) replaces the previous `isEditing = !!spool` heuristic — every existing call site in `InventoryPage.tsx` was updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key on `mode` directly. The `onCopy` callback is optional on `SpoolCard`, `SpoolTableRow`, and `SpoolTableGroup` (matches the existing `onPrintLabel?` pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. **Quick Add interaction:** the Quick Add toggle is gated `mode === 'create'` (was `!isEditing`), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies via `bulkCreateMutation`. **i18n:** new `inventory.copySpool` key across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). **Tests:** 3 new in `SpoolFormModal.test.tsx` (`SpoolFormModal copy mode` describe block — title shows "Copy Spool", save calls `createSpool` not `updateSpool`, `weight_used` reset to 0 in the create payload when copying a spool with non-zero usage), 2 new in `InventoryPageCopyButton.test.tsx` (table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. Existing `SpoolFormBulk.test.tsx` and `SpoolFormModal.test.tsx` renders that omitted the `mode` prop were updated with the explicit `mode="create"` so the tightened Quick Add gate doesn't hide the toggle from them. Both `InventoryPageCopyButton.test.tsx` and `InventoryPageDeepLink.test.tsx` gained MSW handlers for the modal's open-time fetches (`/api/v1/cloud/status`, `/api/v1/cloud/local-presets`, `/api/v1/cloud/builtin-filaments`, `/api/v1/inventory/color-catalog`, `/api/v1/inventory/spool-catalog`, `/api/v1/printers/`) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal's `setLoadingCloudPresets(false)` finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head). ### Fixed +- **Scan For Timelapse matched the wrong video when an older print's filename happened to land near a later archive's completion** ([#1278](https://github.com/maziggy/bambuddy/issues/1278), reported by @1000Delta) — Repro: P2S in LAN-Only mode (no NTP, so printer clock is drifted +8h from UTC), two prints on the same day. Archive 1 correctly attached `video_2026-05-08_09-41-29.mp4`. Archive 2 (started at 16:39:09 UTC, expected `video_2026-05-09_00-42-42.mp4`) reused Archive 1's video with a misleading `diff: 0:02:19`. **Cause:** `scan_timelapse`'s Strategy 2 matcher in `backend/app/api/routes/archives.py` had two compounding flaws. (1) It compared the filename timestamp against both `archive.started_at` **and** `archive.completed_at` with a 48 h tolerance — but the filename always represents the print's START time, never its end, so the end-time branch was a semantic mistake whose only effect was creating false positives. For Archive 2, the stale filename `09:41:29` shifted by hypothesis offset `-8h` → `17:41:29`, which happened to fall ~2 minutes before Archive 2's completion → "diff" 2m19s won. (2) The matcher tried seven hypothesised offsets `[0, ±1, ±7, ±8]`, which densely covers a wide span of the day. Even with the end-time branch removed, the wrong video at offset `-7` lands at `16:41:29` → 2m20s from Archive 2's start, beating the correct video's 3m33s at offset `+8`. **Fix:** extracted Strategy 2 into a pure `_match_timelapse_by_timestamp(video_files, archive_start)` helper that (a) only compares against print **start** time (end-time evidence is handled separately by Strategy 3 via file mtime, which actually does reflect when writing finished), and (b) requires the best (video, offset) pair to beat the next-best pair from a *different* video by at least 15 minutes. When the top two candidates from different videos are too close to call, the helper returns `None` so the route surfaces the existing `available_files` list and the frontend's manual-selection dialog kicks in — which is the fallback the reporter explicitly asked for ("at a minimum, we should support that can fall back to letting the user manually select"). Wide offset support is preserved so EU / JST / AEST users (offsets +1, +7, +9, +10, etc.) still get auto-match when there's no ambiguity. **Tests:** 17 new tests in `backend/tests/unit/test_timelapse_match.py` pin the bug case (`test_issue_1278_archive2_refuses_to_auto_pick_ambiguous`, `test_issue_1278_archive1_still_matches_unambiguously`), the resolution path once the stale video is cleaned up (`test_archive2_resolves_when_stale_video_removed`), each of the 7 supported offsets via parametrize, and the supporting invariants (no `started_at` → `None`, non-timestamp filenames are skipped, same-video different-offset is not ambiguous, well-separated different videos still auto-pick). **Known UX gap not in this PR:** if the matcher auto-picks a wrong match, the user must delete the attached timelapse first before re-scanning — `scan_timelapse` short-circuits with `status: "exists"` when `timelapse_path` is already set. Adding a force-rescan or "wrong match, pick from candidates" affordance is a separate change. + - **Docker image: pip upgraded to >=26.1 to close CVE-2026-6357 (medium)** — The `python:3.13-slim-trixie` base image ships pip 26.0.1, which runs its self-update check *after* installing wheels. A hostile wheel that included a module named like a deferred stdlib import (`urllib`, `ssl`, …) could therefore hijack imports inside the just-finished install step. The exploit path is theoretical for Bambuddy itself — we don't install user-supplied wheels at runtime — but the vulnerable pip version still ships inside the image, GitHub code-scanning flagged it (alert #778), and any downstream user who `pip install`s into the running container inherits the issue. **Fix:** Dockerfile now runs `pip install --upgrade 'pip>=26.1'` immediately before `pip install -r requirements.txt`, so the requirements install itself happens under the patched pip and the resulting `pip-*.dist-info/METADATA` Trivy reads from the layer is the fixed version. No `requirements.txt` change — the floor is enforced at the image-build layer where the vulnerable copy lived. (libexpat1 alert #795 also flagged by code-scanning is a DoS-only XML attribute-collision CVE with no patched Debian trixie package yet — left open as a tracking signal; next base-image rebuild after trixie ships libexpat 2.8.1 will close it automatically.) - **Gitea backups silently failed after the first run; Forgejo v15 token-scope quirk broke "Test Connection"; many failure paths surfaced cryptic one-word errors** ([#1224](https://github.com/maziggy/bambuddy/issues/1224) reported by @rtadams89, [#1239](https://github.com/maziggy/bambuddy/issues/1239) + [PR #1255](https://github.com/maziggy/bambuddy/pull/1255) by @BurntOutHylian) — Two intertwined problem clusters on the Git-backup path, fixed as one PR. **(1) Gitea backups quietly stopped after run #1.** The Git backup service used GitHub's Git Data API (`POST /git/blobs` → `/trees` → `/commits` → `PATCH /refs`) for every push. Gitea does not implement these write endpoints on modern versions, so every blob POST returned 404; the loop's `continue`-on-non-201 pattern left the change list empty and the route returned `{"status": "skipped"}` instead of committing — no toast, no log row, just "no changes" forever. The first run only worked because the empty-repo path already used the Contents API. **Fix:** `GiteaBackend.push_files` is overridden to use `POST /repos/{owner}/{repo}/contents` with a `files` array — every changed file is sent as `operation: "update"` (with its current blob SHA) or `operation: "create"`, the whole batch commits in a single round-trip, no partial-commit failure mode possible. `_create_branch_and_push` switched from the unimplemented `POST /git/refs` to `POST /branches` with `{new_branch_name, old_ref_name}`. **(2) Forgejo v15+ returns 404 (not 403) for private repos when the token lacks repository scope**, indistinguishable on the wire from "repo not found / token typo" — Test Connection's existing 404 branch said "Repository not found", which sent users chasing the wrong cause. **Fix:** new `ForgejoBackend` (inherits `GiteaBackend`) overrides `test_connection` to GET `/user` first; 401 = bad token, 403 = zero-scope token ("read:user scope missing"), 404 on the subsequent `/repos/` call surfaces the v15-specific "private repo with scope mismatch" hint instead of the generic message. **Hardening pass on the broader backup stack** (B18–B26 review round): every `response.json()[...]` indexing in `github.py` (9 sites: ref/commit/blob/tree/commit/ref across `push_files` + `_create_branch_and_push` + `_create_initial_commit`) now routes through a new `base.py::_read_sha(response, *path)` helper that returns `(sha, error_reason)` — a malformed body no longer bubbles `KeyError('object')` through the catch-all to surface as the cryptic one-word string `"'object'"` in `last_backup_message`. Tree-fetch failures (GitHub side, mirroring the Gitea side) now return `failed` with status code + truncated body instead of letting `existing_files` silently stay empty (which forced every file to re-upload and produced a downstream 422 with no hint at the real cause). GitHub's `_create_branch_and_push` failure message includes the HTTP status code (an empty-body 422 now produces a diagnostic message instead of `"Failed to create branch: "`). Both backends detect `truncated: true` on the tree-listing response (GitHub's tree API truncates at >7MB / >100k entries) and fail loudly asking the operator to rotate the backup repo — previously a truncated listing made the SHA-equality dedup miss and silently re-uploaded every file each run. `test_connection` failure messages now include `str(e)[:200]` alongside the exception class name, so the UI surfaces `"Connection failed: ConnectError: certificate verify failed: hostname mismatch"` instead of just `"ConnectError"`. Gitea's 409-on-`/contents` message was softened from "stale blob SHAs" (one possible cause) to "the branch likely advanced concurrently (web-UI edit, another backup run, or path-vs-tree collision)". Every status-code branch in `github.py` and `gitea.py` mid-push now emits a `logger.warning` with owner/repo context (previously only the outer `except` logged, so a 403/404/422 left a DB row with no application-log entry). Recursive `push_files` re-entry after branch create now logs `"Re-entering push_files after branch create owner/repo -> branch"` at info level so replication-lag second-pass failures are debuggable. **Tests:** +17 new unit tests in `test_git_providers.py` covering the GitHub robustness paths (tree-fetch failure, truncated tree, malformed JSON for ref/commit/blob, 403/422 on `_create_branch_and_push`), the Gitea round-2 hardening (truncated tree, status code in `get_current_commit` / `extract_tree_SHA` / `get_repo_info` failures, log marker emission), and the Forgejo connection-failure detail. Existing 86 → 103 tests, all pass; full backend suite + integration backup tests green; ruff clean. Tested by @BurntOutHylian against Gitea 1.24.7 / 1.25.4 / 1.26.1 and Forgejo v11 / v15 LTS. Companion wiki update at [maziggy/bambuddy-wiki#28](https://github.com/maziggy/bambuddy-wiki/pull/28). diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index cba4de636..a30279678 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -1,9 +1,10 @@ import io import json import logging +import re as _re import zipfile from collections import defaultdict -from datetime import date, datetime, time, timezone +from datetime import date, datetime, time, timedelta, timezone from decimal import ROUND_HALF_UP, Decimal from pathlib import Path @@ -48,6 +49,74 @@ def _safe_filename(filename: str) -> str: return Path(filename.replace("\\", "/")).name +_TIMELAPSE_FILENAME_TS_RE = _re.compile(r"(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})") +_DEFAULT_TIMELAPSE_OFFSETS_HOURS: tuple[int, ...] = (0, 8, -8, 7, -7, 1, -1) +_DEFAULT_TIMELAPSE_TOLERANCE = timedelta(hours=4) +_DEFAULT_TIMELAPSE_AMBIGUITY_MARGIN = timedelta(minutes=15) + + +def _match_timelapse_by_timestamp( + video_files: list[dict], + archive_start: datetime | None, + *, + tolerance: timedelta = _DEFAULT_TIMELAPSE_TOLERANCE, + ambiguity_margin: timedelta = _DEFAULT_TIMELAPSE_AMBIGUITY_MARGIN, + offsets_hours: tuple[int, ...] = _DEFAULT_TIMELAPSE_OFFSETS_HOURS, +) -> tuple[dict | None, timedelta | None]: + """Pick the timelapse whose filename timestamp best matches the print start time. + + Bambu timelapse filenames embed the printer-local START time (e.g. + "video_2026-05-08_09-41-29.mp4"). The printer's clock may be offset from the + server's — especially in LAN-Only mode where NTP is unreachable — so we try a + small set of common UTC offsets and keep the (video, offset) pair with the + smallest absolute distance from archive_start. We deliberately do NOT consider + archive_end here: the filename is start time, not end time, so comparing it to + completion is not a real signal (Strategy 3 handles end via file mtime). + + Because the offset list densely covers a wide span, an unrelated video's + filename can coincidentally land near a later print's start at some offset. + To avoid that false positive, we require the best (video, offset) pair to + beat the next-best pair *from a different video* by at least `ambiguity_margin`. + When the top two candidates from different videos are too close to call, + we return None and let the caller fall back to manual selection. + """ + if archive_start is None: + return None, None + + # (diff, video) for every (video, offset) pair within tolerance. + candidates: list[tuple[timedelta, dict]] = [] + + for f in video_files: + fname = f.get("name", "") + m = _TIMELAPSE_FILENAME_TS_RE.search(fname) + if not m: + continue + try: + file_time = datetime.strptime(m.group(1), "%Y-%m-%d_%H-%M-%S") + except ValueError: + continue + + for hour_offset in offsets_hours: + adjusted = file_time - timedelta(hours=hour_offset) + diff = abs(adjusted - archive_start) + if diff <= tolerance: + candidates.append((diff, f)) + + if not candidates: + return None, None + + candidates.sort(key=lambda c: c[0]) + best_diff, best_video = candidates[0] + best_name = best_video.get("name") + + for diff, video in candidates[1:]: + if video.get("name") != best_name and (diff - best_diff) < ambiguity_margin: + # Another video matches almost as well — refuse to auto-pick. + return None, None + + return best_video, best_diff + + def _validate_user_filter_permission(current_user: User | None, created_by_id: int | None): """Raise 403 if created_by_id filter is used without stats:filter_by_user permission.""" if created_by_id is None or current_user is None: @@ -1721,65 +1790,15 @@ async def scan_timelapse( matching_file = f break - # Strategy 2: Match by timestamp proximity - # Bambu timelapse filename uses the print START time (when recording began) - if not matching_file and (archive.started_at or archive.completed_at or archive.created_at): - import re - from datetime import datetime, timedelta - - # Prefer started_at since video filename is the print start time - # Fall back to completed_at or created_at if started_at is not available - archive_start = archive.started_at - archive_end = archive.completed_at or archive.created_at - best_match = None - best_diff = timedelta(hours=24) # Max 24 hour difference - - for f in video_files: - fname = f.get("name", "") - # Parse timestamp from filename like "video_2025-11-24_03-17-40.mp4" - match = re.search(r"(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})", fname) - if match: - try: - file_time = datetime.strptime(match.group(1), "%Y-%m-%d_%H-%M-%S") - - # Try multiple timezone offsets since printer timezone can vary - # Common cases: local time (0), CST/UTC+8 (+8), or UTC (-local offset) - for hour_offset in [0, 8, -8, 7, -7, 1, -1]: - adjusted_file_time = file_time - timedelta(hours=hour_offset) - - # Check against start time (video filename = print start) - if archive_start: - diff = abs(adjusted_file_time - archive_start) - if diff < best_diff: - best_diff = diff - best_match = f - logger.debug( - f"Timelapse match candidate: {fname} with offset {hour_offset}h, " - f"diff from start: {diff}" - ) - - # Also check against end time with a buffer - # (video timestamp should be BEFORE completion time) - if archive_end: - # The video timestamp should be within the print duration before completion - if adjusted_file_time < archive_end: - diff = archive_end - adjusted_file_time - # Reasonable print duration: up to 48 hours - if diff < timedelta(hours=48) and diff < best_diff: - best_diff = diff - best_match = f - logger.debug( - f"Timelapse match candidate (from end): {fname} with offset {hour_offset}h, " - f"diff: {diff}" - ) - - except ValueError: - continue - - # Accept match within 4 hours (more lenient for timezone issues) - if best_match and best_diff < timedelta(hours=4): - matching_file = best_match - logger.info("Matched timelapse by timestamp: %s (diff: %s)", best_match.get("name"), best_diff) + # Strategy 2: Match by timestamp proximity against print START time. + # Bambu timelapse filename embeds the print start time in printer-local clock. + # See _match_timelapse_by_timestamp for the offset-search rationale and why we + # intentionally don't try to match filename against end time here. + if not matching_file and archive.started_at: + candidate, diff = _match_timelapse_by_timestamp(video_files, archive.started_at) + if candidate is not None: + matching_file = candidate + logger.info("Matched timelapse by timestamp: %s (diff: %s)", candidate.get("name"), diff) # Strategy 3: Use file modification time from FTP listing # This handles cases where printer's filename timestamp is wrong but file mtime is correct diff --git a/backend/tests/unit/test_timelapse_match.py b/backend/tests/unit/test_timelapse_match.py new file mode 100644 index 000000000..69bd1d5cc --- /dev/null +++ b/backend/tests/unit/test_timelapse_match.py @@ -0,0 +1,177 @@ +"""Unit tests for the timelapse-by-timestamp matcher used by /archives/scan. + +Regression coverage for #1278: when the printer cannot reach NTP (LAN-Only mode), +its clock is offset from the server's, and an older video's filename can land just +before a later print's completion. The previous matcher: + +1. Treated the filename as either start- or end-time evidence — semantically wrong + for a filename that's always print-start. +2. Probed a dense set of timezone offsets, so an unrelated video could + coincidentally land within minutes of a later print at *some* offset. + +The new matcher matches only against start time and refuses to auto-pick when the +top two candidates (from different videos) are within an ambiguity margin — +forcing the manual-selection fallback the reporter explicitly asked for. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +import pytest + +from backend.app.api.routes.archives import _match_timelapse_by_timestamp + + +def _video(name: str, mtime: datetime | None = None) -> dict: + return { + "name": name, + "path": f"/timelapse/{name}", + "is_directory": False, + "size": 1024, + "mtime": mtime, + } + + +class TestMatchTimelapseByTimestamp: + """Cover the bug from issue #1278 plus baseline cases.""" + + def test_issue_1278_archive2_refuses_to_auto_pick_ambiguous(self): + """Archive 2 (start 16:39:09) used to wrongly attach the older 09-41-29 video. + + The wrong video matches at offset -7 (diff 2m20s), the correct video at + offset +8 (diff 3m33s). The two are within ~1 minute of each other — + too close to call. Matcher must return None so the route surfaces the + manual-selection list to the user. + """ + videos = [ + _video("video_2026-05-08_09-41-29.mp4"), # belongs to Archive 1 + _video("video_2026-05-09_00-42-42.mp4"), # belongs to Archive 2 — correct + ] + archive_start = datetime(2026, 5, 8, 16, 39, 9) + + match, diff = _match_timelapse_by_timestamp(videos, archive_start) + + assert match is None + assert diff is None + + def test_issue_1278_archive1_still_matches_unambiguously(self): + """Archive 1 (start 01:27:14) — only one candidate within tolerance, + so the matcher should still pick it cleanly.""" + videos = [ + _video("video_2026-05-08_09-41-29.mp4"), # correct + _video("video_2026-05-09_00-42-42.mp4"), # 15h+ away at any common offset + ] + archive_start = datetime(2026, 5, 8, 1, 27, 14) + + match, diff = _match_timelapse_by_timestamp(videos, archive_start) + + assert match is not None + assert match["name"] == "video_2026-05-08_09-41-29.mp4" + assert diff is not None + assert diff < timedelta(minutes=20) + + def test_archive2_resolves_when_stale_video_removed(self): + """If the user has cleaned up the stale Archive-1 video, Archive 2's correct + video is the only candidate and auto-match should succeed.""" + videos = [_video("video_2026-05-09_00-42-42.mp4")] + archive_start = datetime(2026, 5, 8, 16, 39, 9) + + match, diff = _match_timelapse_by_timestamp(videos, archive_start) + + assert match is not None + assert match["name"] == "video_2026-05-09_00-42-42.mp4" + assert diff is not None + assert diff < timedelta(minutes=5) + + def test_no_match_when_outside_tolerance(self): + """All candidates outside the 4h tolerance → no match.""" + videos = [_video("video_2026-05-08_09-41-29.mp4")] + # A week later, far beyond any offset's reach + archive_start = datetime(2026, 5, 15, 12, 0, 0) + + match, diff = _match_timelapse_by_timestamp(videos, archive_start) + + assert match is None + assert diff is None + + def test_returns_none_when_started_at_missing(self): + """No archive start time = no signal; should return None.""" + videos = [_video("video_2026-05-08_09-41-29.mp4")] + + match, diff = _match_timelapse_by_timestamp(videos, None) + + assert match is None + assert diff is None + + def test_zero_offset_when_clocks_agree(self): + """When printer and server clocks agree, offset=0 should pick the video cleanly.""" + videos = [_video("video_2026-05-08_16-40-00.mp4")] + archive_start = datetime(2026, 5, 8, 16, 39, 0) + + match, diff = _match_timelapse_by_timestamp(videos, archive_start) + + assert match is not None + assert match["name"] == "video_2026-05-08_16-40-00.mp4" + assert diff == timedelta(minutes=1) + + def test_skips_videos_without_timestamp_in_name(self): + """Non-standard names (e.g., manually uploaded) should be skipped, not crash.""" + videos = [ + _video("my_custom_video.mp4"), + _video("video_2026-05-08_16-40-00.mp4"), + ] + archive_start = datetime(2026, 5, 8, 16, 39, 0) + + match, _diff = _match_timelapse_by_timestamp(videos, archive_start) + + assert match is not None + assert match["name"] == "video_2026-05-08_16-40-00.mp4" + + def test_empty_video_list_returns_none(self): + match, diff = _match_timelapse_by_timestamp([], datetime(2026, 5, 8, 0, 0, 0)) + assert match is None + assert diff is None + + @pytest.mark.parametrize("offset_hours", [0, 1, -1, 7, -7, 8, -8]) + def test_supports_common_timezone_offsets_with_single_candidate(self, offset_hours: int): + """Each offset in the search list must be able to produce a match when + only one video exists (so ambiguity check is vacuous).""" + archive_start = datetime(2026, 5, 8, 12, 0, 0) + # Printer's filename reflects archive_start in printer-local time + printer_time = archive_start + timedelta(hours=offset_hours) + videos = [_video(printer_time.strftime("video_%Y-%m-%d_%H-%M-%S.mp4"))] + + match, diff = _match_timelapse_by_timestamp(videos, archive_start) + + assert match is not None + assert diff == timedelta(0) + + def test_returns_match_when_runner_up_is_same_video_different_offset(self): + """A single video matching at two offsets is not ambiguous — pick it.""" + videos = [_video("video_2026-05-08_09-41-29.mp4")] + # +7h adjusted = 02:41:29; +8h adjusted = 01:41:29. Both within 4h of 01:27:14. + archive_start = datetime(2026, 5, 8, 1, 27, 14) + + match, diff = _match_timelapse_by_timestamp(videos, archive_start) + + assert match is not None + assert match["name"] == "video_2026-05-08_09-41-29.mp4" + # Best is offset +8 → diff 14m15s + assert diff is not None + assert diff < timedelta(minutes=20) + + def test_unambiguous_when_runner_up_is_well_separated(self): + """If the next-best different video is comfortably outside the ambiguity + margin, auto-pick the winner.""" + videos = [ + _video("video_2026-05-08_09-41-29.mp4"), # +8h → 01:41:29, diff 14m15s + _video("video_2026-05-08_12-00-00.mp4"), # +8h → 04:00:00, diff 2h32m + ] + archive_start = datetime(2026, 5, 8, 1, 27, 14) + + match, diff = _match_timelapse_by_timestamp(videos, archive_start) + + assert match is not None + assert match["name"] == "video_2026-05-08_09-41-29.mp4" + assert diff is not None