feat(import): Tier-1 video near-dup by duration+aspect (#871)
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 3m13s

Videos deduped on sha256 only (pHash is images-only), so a different encode/remux
of the same clip imported as a distinct record — the "same video from multiple
sources" clutter surfaced by #859.

Tier-1 metadata fingerprint: identity = container duration (±1.0s) + matching
aspect ratio, scoped to the same artist; quality axis = pixel dimensions (mirrors
image pHash: larger_exists→skip+link, smaller_exists→supersede). Codec/bitrate
are deliberately NOT part of identity (the point is matching across re-encodes).
Tight tolerances because a wrong video merge is destructive.

- image_record.duration_seconds (Float, nullable; migration 0052). NULL for images.
- safe_probe.probe_video also reads format=duration (one extra ffprobe field on the
  call that already runs); ProbeResult.duration.
- _find_similar_video(duration,w,h,artist) shared by both import pipelines.
- _import_media (filesystem/archive path): captures duration, video near-dup
  branch, persists duration.
- attach_in_place (download path — handles #859's videos, previously didn't probe
  video at all): best-effort probe for dims+duration (LENIENT — never newly rejects
  a downloaded video on probe failure), video near-dup branch, persists duration.
- _supersede carries duration onto the kept row.

Reuses SkipReason.duplicate_phash so the existing download/external dup-cleanup
(path-safe unlink, #859) applies unchanged. Tests: skip-smaller, supersede-larger
(+ duration adopted), and distinct-durations-not-merged (false-merge guard).

Follow-up (Phase 2, #871): a backfill to re-probe NULL-duration existing videos so
the current library participates in dedup; retroactive merge of existing dups is a
separate destructive maintenance action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 22:17:36 -04:00
parent b48ba60830
commit f154603811
5 changed files with 267 additions and 6 deletions
+5
View File
@@ -13,6 +13,7 @@ from sqlalchemy import (
BigInteger,
DateTime,
Enum,
Float,
ForeignKey,
Integer,
String,
@@ -39,6 +40,10 @@ class ImageRecord(Base):
mime: Mapped[str] = mapped_column(String(64), nullable=False)
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Video container duration (seconds); NULL for images. The Tier-1 video
# near-dup key (#871): two videos of the same artist with matching duration
# (+ aspect) are the same content across re-encodes — dedup like image pHash.
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
# Integrity verification status. FC-2e populates this; FC-2a leaves rows at 'unknown'.
# Values: 'unknown' (default), 'ok', 'corrupt', 'failed_verification'.
+117 -3
View File
@@ -85,6 +85,15 @@ ALL_EXTS = IMAGE_EXTS | VIDEO_EXTS
# deep-nested archive can't recurse forever; the bomb-probe re-runs per level.
_ARCHIVE_MAX_DEPTH = 3
# Tier-1 video near-dup (#871). pHash is images-only, so videos deduped on sha256
# alone — a different encode/remux of the same clip imported as a distinct record.
# Identity = container duration within a tight tolerance + matching aspect ratio,
# scoped to the same artist (codec/bitrate are NOT part of identity — the point is
# to match ACROSS re-encodes). Quality axis for supersede = pixel dimensions, same
# as image pHash. Tight tolerances because a wrong video merge is destructive.
_VIDEO_DUP_DURATION_TOL_SECONDS = 1.0
_VIDEO_DUP_ASPECT_TOL = 0.02
def is_supported(path: Path) -> bool:
return path.suffix.lower() in ALL_EXTS
@@ -604,6 +613,48 @@ class Importer:
archive_path.name, depth, exc,
)
@staticmethod
def _video_aspect_matches(w, h, cw, ch) -> bool:
"""True when two (w,h) pairs share an aspect ratio within tolerance.
Missing dims → don't block (duration is the primary signal)."""
if not (w and h and cw and ch):
return True
return abs((w / h) - (cw / ch)) <= _VIDEO_DUP_ASPECT_TOL
def _find_similar_video(
self, duration: float | None, width: int | None, height: int | None,
artist_id: int | None,
) -> tuple[str, int | None]:
"""Tier-1 video near-dup (#871). Find a same-artist video whose duration
matches within tolerance AND whose aspect ratio matches — treat it as the
same content (a different encode/remux). Returns find_similar's contract:
("none"|"larger_exists"|"smaller_exists", id), quality judged by pixel
dimensions so a higher-res copy supersedes a smaller one."""
if duration is None or artist_id is None:
return ("none", None)
lo = duration - _VIDEO_DUP_DURATION_TOL_SECONDS
hi = duration + _VIDEO_DUP_DURATION_TOL_SECONDS
rows = self.session.execute(
select(ImageRecord.id, ImageRecord.width, ImageRecord.height)
.where(
ImageRecord.artist_id == artist_id,
ImageRecord.mime.like("video/%"),
ImageRecord.duration_seconds.is_not(None),
ImageRecord.duration_seconds >= lo,
ImageRecord.duration_seconds <= hi,
)
).all()
w, h = width or 0, height or 0
for cid, cw, ch in rows:
if not self._video_aspect_matches(width, height, cw, ch):
continue
cw0, ch0 = cw or 0, ch or 0
if cw0 >= w and ch0 >= h:
return ("larger_exists", cid)
if w > cw0 or h > ch0:
return ("smaller_exists", cid)
return ("none", None)
def _import_media(
self, source: Path, attribution_path: Path,
*, explicit_source: Source | None = None,
@@ -622,13 +673,13 @@ class Importer:
# Compute file dimensions (images only) and apply filters.
width = height = None
duration = None # video container duration (Tier-1 near-dup key, #871)
has_alpha = False
if is_video(source):
# Layer-3 isolation: validate the container via ffprobe (a
# separate process) before the rest of the pipeline touches
# it. A corrupt video that would crash a decoder is rejected
# cleanly here, and we capture width/height for free (the
# importer didn't previously record video dimensions).
# cleanly here, and we capture width/height + duration for free.
probe = safe_probe.probe_video(source)
if not probe.ok:
if probe.crashed:
@@ -640,7 +691,7 @@ class Importer:
status="skipped", skip_reason=SkipReason.invalid_image,
error=probe.reason,
)
width, height = probe.width, probe.height
width, height, duration = probe.width, probe.height, probe.duration
else:
try:
with Image.open(source) as im:
@@ -752,6 +803,32 @@ class Importer:
return ImportResult(
status="superseded", image_id=match_id
)
elif duration is not None:
# Tier-1 video near-dup (#871): same-artist, matching duration+aspect
# → same content across re-encodes. Mirror the image flow.
rel, match_id = self._find_similar_video(
duration, width, height,
path_artist.id if path_artist else None,
)
if rel == "larger_exists":
larger = self.session.get(ImageRecord, match_id)
if larger is not None:
self._apply_sidecar(
larger, attribution_path, path_artist,
explicit_source=explicit_source,
)
self.session.commit()
return ImportResult(
status="skipped", skip_reason=SkipReason.duplicate_phash,
image_id=match_id,
error="video near-duplicate (duration+aspect) of existing equal/larger video",
)
if rel == "smaller_exists":
target = self.session.get(ImageRecord, match_id)
self._supersede(
target, source, sha, None, width, height, duration=duration,
)
return ImportResult(status="superseded", image_id=match_id)
dest = self._copy_to_library(source, sha, attribution_path)
@@ -763,6 +840,7 @@ class Importer:
mime=_mime_for(source),
width=width,
height=height,
duration_seconds=duration,
origin="imported_filesystem",
integrity_status="unknown",
)
@@ -956,6 +1034,7 @@ class Importer:
# Format / dimension / transparency filters (mirror _import_media).
width = height = None
duration = None # video container duration (Tier-1 near-dup key, #871)
has_alpha = False
if not is_video(path):
try:
@@ -987,6 +1066,14 @@ class Importer:
status="skipped", skip_reason=SkipReason.too_transparent,
error=f"{pct:.2%} transparent",
)
else:
# Best-effort probe for dims + duration so downloaded videos can dedup
# (#871). LENIENT: unlike _import_media this path does not reject on a
# probe failure — preserve the existing "import the downloaded video"
# behavior; we just skip dedup when we can't read the duration.
vp = safe_probe.probe_video(path)
if vp.ok:
width, height, duration = vp.width, vp.height, vp.duration
# Hash dedup
sha = _sha256_of(path)
@@ -1045,6 +1132,30 @@ class Importer:
new_path=path, artist=artist, source_row=source,
)
return ImportResult(status="superseded", image_id=match_id)
elif duration is not None:
# Tier-1 video near-dup (#871): same content re-downloaded from another
# source/encode. artist is the subscription artist (passed explicitly).
rel, match_id = self._find_similar_video(
duration, width, height, artist.id if artist else None,
)
if rel == "larger_exists":
larger = self.session.get(ImageRecord, match_id)
if larger is not None:
self._apply_sidecar(larger, path, artist, explicit_source=source)
self.session.commit()
return ImportResult(
status="skipped", skip_reason=SkipReason.duplicate_phash,
image_id=match_id,
error="video near-duplicate (duration+aspect) of existing equal/larger video",
)
if rel == "smaller_exists":
target = self.session.get(ImageRecord, match_id)
self._supersede(
target, path, sha, None, width, height,
new_path=path, artist=artist, source_row=source,
duration=duration,
)
return ImportResult(status="superseded", image_id=match_id)
# Create record — the path IS where the file lives.
record = ImageRecord(
@@ -1055,6 +1166,7 @@ class Importer:
mime=_mime_for(path),
width=width,
height=height,
duration_seconds=duration,
origin="downloaded",
integrity_status="unknown",
)
@@ -1270,6 +1382,7 @@ class Importer:
*, new_path: Path | None = None,
artist: Artist | None = None,
source_row: Source | None = None,
duration: float | None = None,
) -> None:
"""Replace `existing`'s file with the larger `source`, keeping the
row id (so tags/series/curation stay attached). ML is cleared so
@@ -1302,6 +1415,7 @@ class Importer:
existing.mime = _mime_for(source)
existing.width = width
existing.height = height
existing.duration_seconds = duration # #871: keep the kept copy's duration
existing.thumbnail_path = None
existing.integrity_status = "unknown"
existing.tagger_model_version = None
+16 -2
View File
@@ -60,6 +60,9 @@ class ProbeResult:
reason: str | None = None
width: int | None = None
height: int | None = None
# Container duration in seconds (videos only) — the Tier-1 video near-dup
# key (#871). None when not a video / ffprobe didn't report it.
duration: float | None = None
def probe_video(path: Path, *, timeout: float = VIDEO_PROBE_TIMEOUT_SECONDS) -> ProbeResult:
@@ -69,7 +72,10 @@ def probe_video(path: Path, *, timeout: float = VIDEO_PROBE_TIMEOUT_SECONDS) ->
[
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
# format=duration is the reliable container duration (stream
# duration is often N/A for remuxed/streamed mp4) — the #871
# video near-dup key.
"-show_entries", "stream=width,height:format=duration",
"-of", "json", str(path),
],
capture_output=True, text=True, timeout=timeout,
@@ -87,13 +93,21 @@ def probe_video(path: Path, *, timeout: float = VIDEO_PROBE_TIMEOUT_SECONDS) ->
reason=f"ffprobe rejected the file: {out.stderr.strip()[:200]}",
)
try:
streams = (json.loads(out.stdout) or {}).get("streams") or []
parsed = json.loads(out.stdout) or {}
except json.JSONDecodeError as exc:
return ProbeResult(ok=False, crashed=False, reason=f"ffprobe output parse failed: {exc}")
streams = parsed.get("streams") or []
if not streams:
return ProbeResult(ok=False, crashed=False, reason="no decodable video stream")
duration = None
raw_dur = (parsed.get("format") or {}).get("duration")
try:
duration = float(raw_dur) if raw_dur is not None else None
except (TypeError, ValueError):
duration = None
return ProbeResult(
ok=True, width=streams[0].get("width"), height=streams[0].get("height"),
duration=duration,
)