mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 07:01:40 +02:00
fix(archives): scan_timelapse picked stale video at false offset (#1278)
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).
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user