feat(maintenance): retroactive video-dedup action — preview + apply (#871)
Phase 2 of #871: clean up the duplicate videos already in the library (the #859 "same video from multiple sources" clutter). Import-time dedup (Phase 1) only prevents NEW dups; this is the operator-triggered cleanup of existing ones. cleanup_service.dedup_videos(dry_run): - backfill_video_durations: re-probe NULL-duration videos (pre-#871 rows) so the existing library participates; idempotent (only NULL rows), writes a negative sentinel for un-probeable files so they're neither re-probed forever nor matched. - find_video_dup_groups: cluster same-artist videos by duration (±tol) + aspect, anchored per cluster to bound the span (no chain drift); keeper = highest pixel area then bytes. Reuses the importer's _VIDEO_DUP_* tolerances. - apply: re-point each loser's post links to the keeper (so no post loses the video) THEN delete the redundant records + files via delete_images (cascade). dry_run shares the same discovery predicate and returns the projection only (rule 93). Tags on a loser are NOT merged (noted; videos rarely hand-curated). - dedup_videos_task (maintenance queue; summary → task_run.metadata). - POST /maintenance/dedup-videos {dry_run} + GET /maintenance/task-result/<id> so the card shows the dry-run projection before the destructive apply. - VideoDedupCard: Preview → shows groups/redundant/reclaimable, then Apply behind a confirm dialog. Mounted in the Maintenance panel. Tests: dedup collapses + re-links the loser's post to the keeper + removes the file; dry-run deletes nothing; distinct durations aren't grouped; task registered. (Migration 0052 for duration_seconds already shipped with Phase 1.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -361,3 +361,34 @@ async def trigger_prune_missing_files():
|
||||
|
||||
async_result = prune_missing_file_records_task.delay()
|
||||
return jsonify({"task_id": async_result.id, "status": "queued"}), 202
|
||||
|
||||
|
||||
@admin_bp.route("/maintenance/dedup-videos", methods=["POST"])
|
||||
async def trigger_dedup_videos():
|
||||
"""Tier-1 video dedup (#871). Body {"dry_run": bool}: dry_run=true previews
|
||||
what would be removed (groups / redundant count / reclaimable bytes) WITHOUT
|
||||
deleting; dry_run=false applies it (re-link posts to the keeper, then delete
|
||||
the redundant copies). Either way it first re-probes NULL-duration videos so
|
||||
the existing library participates. Returns the Celery task id — poll
|
||||
/maintenance/task-result/<id> for the summary."""
|
||||
from ..tasks.admin import dedup_videos_task
|
||||
|
||||
body = await request.get_json(silent=True) or {}
|
||||
dry_run = bool(body.get("dry_run", True)) # default to the SAFE preview
|
||||
async_result = dedup_videos_task.delay(dry_run=dry_run)
|
||||
return jsonify({"task_id": async_result.id, "status": "queued"}), 202
|
||||
|
||||
|
||||
@admin_bp.route("/maintenance/task-result/<task_id>", methods=["GET"])
|
||||
async def maintenance_task_result(task_id: str):
|
||||
"""Poll a maintenance Celery task's result (the summary dict it returns).
|
||||
Used by the video-dedup card to show the dry-run projection before apply."""
|
||||
from ..celery_app import celery
|
||||
|
||||
res = celery.AsyncResult(task_id)
|
||||
ready = res.ready()
|
||||
return jsonify({
|
||||
"ready": ready,
|
||||
"successful": res.successful() if ready else None,
|
||||
"result": res.result if (ready and res.successful()) else None,
|
||||
})
|
||||
|
||||
@@ -32,9 +32,17 @@ from ..models import (
|
||||
from ..models.series_chapter import SeriesChapter
|
||||
from ..models.series_page import SeriesPage
|
||||
from ..models.tag import image_tag
|
||||
from ..utils import safe_probe
|
||||
from .importer import _VIDEO_DUP_ASPECT_TOL, _VIDEO_DUP_DURATION_TOL_SECONDS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel written to duration_seconds when a video was probed but ffprobe
|
||||
# reported no usable duration (missing/corrupt file) — distinct from NULL (never
|
||||
# probed) so the backfill doesn't re-probe it forever, and < 0 so it can never
|
||||
# match a real duration in the dedup grouping (#871).
|
||||
_VIDEO_DURATION_UNKNOWN = -1.0
|
||||
|
||||
|
||||
def project_artist_cascade(session: Session, *, slug: str) -> dict:
|
||||
"""Read-only projection of what delete_artist_cascade would touch.
|
||||
@@ -940,3 +948,193 @@ def reextract_archive_attachments(
|
||||
except Exception as exc:
|
||||
log.warning("re-extract enqueue failed for image %s: %s", img_id, exc)
|
||||
return summary
|
||||
|
||||
|
||||
# ---- Tier-1 video dedup (#871) ------------------------------------------
|
||||
|
||||
|
||||
def _aspect_matches(w, h, cw, ch) -> bool:
|
||||
"""Same aspect ratio within tolerance; missing dims don't block (duration is
|
||||
the primary signal). Mirrors Importer._video_aspect_matches."""
|
||||
if not (w and h and cw and ch):
|
||||
return True
|
||||
return abs((w / h) - (cw / ch)) <= _VIDEO_DUP_ASPECT_TOL
|
||||
|
||||
|
||||
def backfill_video_durations(session: Session) -> int:
|
||||
"""Populate image_record.duration_seconds for video rows imported before #871
|
||||
(NULL). Idempotent — only NULL rows are touched, so a re-run after a timeout
|
||||
naturally resumes. A probe that yields no duration writes the
|
||||
_VIDEO_DURATION_UNKNOWN sentinel so the file isn't re-probed forever (and can
|
||||
never match a real duration). Returns the count of rows given a real duration.
|
||||
"""
|
||||
populated = 0
|
||||
while True:
|
||||
rows = session.execute(
|
||||
select(ImageRecord.id, ImageRecord.path)
|
||||
.where(
|
||||
ImageRecord.mime.like("video/%"),
|
||||
ImageRecord.duration_seconds.is_(None),
|
||||
)
|
||||
.order_by(ImageRecord.id)
|
||||
.limit(500)
|
||||
).all()
|
||||
if not rows:
|
||||
break
|
||||
for rid, path in rows:
|
||||
probe = safe_probe.probe_video(Path(path))
|
||||
dur = probe.duration if probe.ok and probe.duration else None
|
||||
session.execute(
|
||||
update(ImageRecord)
|
||||
.where(ImageRecord.id == rid)
|
||||
.values(
|
||||
duration_seconds=dur if dur is not None else _VIDEO_DURATION_UNKNOWN
|
||||
)
|
||||
)
|
||||
if dur is not None:
|
||||
populated += 1
|
||||
session.commit()
|
||||
return populated
|
||||
|
||||
|
||||
def _video_dup_group(members: list) -> dict:
|
||||
"""Pick the keeper (highest pixel area, then largest bytes, then lowest id for
|
||||
stability) and describe the group."""
|
||||
keeper = max(
|
||||
members,
|
||||
key=lambda m: ((m.width or 0) * (m.height or 0), m.size_bytes or 0, -m.id),
|
||||
)
|
||||
losers = [m for m in members if m.id != keeper.id]
|
||||
return {
|
||||
"artist_id": keeper.artist_id,
|
||||
"keeper_id": keeper.id,
|
||||
"loser_ids": [m.id for m in losers],
|
||||
"duration": keeper.duration_seconds,
|
||||
"count": len(members),
|
||||
"reclaim_bytes": sum((m.size_bytes or 0) for m in losers),
|
||||
}
|
||||
|
||||
|
||||
def find_video_dup_groups(session: Session) -> list[dict]:
|
||||
"""Cluster videos that are the same content (#871): same artist, duration
|
||||
within tolerance, matching aspect ratio. Returns groups of >1 member. Greedy
|
||||
sweep over duration-sorted rows, anchored to each cluster's first member so the
|
||||
cluster's duration span never exceeds the tolerance (no chain drift)."""
|
||||
rows = session.execute(
|
||||
select(
|
||||
ImageRecord.id, ImageRecord.artist_id, ImageRecord.duration_seconds,
|
||||
ImageRecord.width, ImageRecord.height, ImageRecord.size_bytes,
|
||||
)
|
||||
.where(
|
||||
ImageRecord.mime.like("video/%"),
|
||||
ImageRecord.duration_seconds.is_not(None),
|
||||
ImageRecord.duration_seconds > 0,
|
||||
ImageRecord.artist_id.is_not(None),
|
||||
)
|
||||
.order_by(
|
||||
ImageRecord.artist_id, ImageRecord.duration_seconds, ImageRecord.id
|
||||
)
|
||||
).all()
|
||||
groups: list[dict] = []
|
||||
cluster: list = []
|
||||
anchor = None
|
||||
for r in rows:
|
||||
if (
|
||||
anchor is not None
|
||||
and r.artist_id == anchor.artist_id
|
||||
and (r.duration_seconds - anchor.duration_seconds)
|
||||
<= _VIDEO_DUP_DURATION_TOL_SECONDS
|
||||
and _aspect_matches(r.width, r.height, anchor.width, anchor.height)
|
||||
):
|
||||
cluster.append(r)
|
||||
else:
|
||||
if len(cluster) > 1:
|
||||
groups.append(_video_dup_group(cluster))
|
||||
cluster = [r]
|
||||
anchor = r
|
||||
if len(cluster) > 1:
|
||||
groups.append(_video_dup_group(cluster))
|
||||
return groups
|
||||
|
||||
|
||||
def _relink_provenance_to_keeper(
|
||||
session: Session, *, loser_id: int, keeper_id: int
|
||||
) -> int:
|
||||
"""Ensure the keeper has an ImageProvenance row for every post the loser was
|
||||
linked to, so deleting the loser never drops the video off a post. Returns the
|
||||
number of new keeper↔post links added."""
|
||||
rows = session.execute(
|
||||
select(ImageProvenance.post_id, ImageProvenance.source_id)
|
||||
.where(ImageProvenance.image_record_id == loser_id)
|
||||
).all()
|
||||
added = 0
|
||||
for post_id, source_id in rows:
|
||||
exists = session.execute(
|
||||
select(ImageProvenance.id).where(
|
||||
ImageProvenance.image_record_id == keeper_id,
|
||||
ImageProvenance.post_id == post_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is None:
|
||||
session.add(ImageProvenance(
|
||||
image_record_id=keeper_id, post_id=post_id, source_id=source_id,
|
||||
))
|
||||
session.flush()
|
||||
added += 1
|
||||
return added
|
||||
|
||||
|
||||
def dedup_videos(
|
||||
session: Session, *, images_root: Path, dry_run: bool = False
|
||||
) -> dict:
|
||||
"""Find and (unless dry_run) collapse Tier-1 video duplicates (#871).
|
||||
|
||||
Re-probes NULL-duration videos first so the existing library participates,
|
||||
then clusters by artist + duration + aspect and keeps the highest-res copy per
|
||||
cluster. On apply, each loser's post links are re-pointed to the keeper BEFORE
|
||||
the loser record + file are deleted, so no post loses the video. dry_run shares
|
||||
the same discovery predicate and returns the projection without deleting
|
||||
(rule 93).
|
||||
|
||||
NOTE: tags/curation on a loser are NOT merged onto the keeper — videos rarely
|
||||
carry hand-curation and merging would add FK-juggling risk. Flagged as a
|
||||
follow-up if it ever matters.
|
||||
"""
|
||||
backfill_video_durations(session)
|
||||
groups = find_video_dup_groups(session)
|
||||
redundant = sum(len(g["loser_ids"]) for g in groups)
|
||||
reclaim = sum(g["reclaim_bytes"] for g in groups)
|
||||
sample = [
|
||||
{
|
||||
"keeper_id": g["keeper_id"],
|
||||
"redundant": len(g["loser_ids"]),
|
||||
"duration": round(g["duration"], 1),
|
||||
}
|
||||
for g in groups[:50]
|
||||
]
|
||||
if dry_run:
|
||||
return {
|
||||
"groups": len(groups), "redundant": redundant,
|
||||
"reclaim_bytes": reclaim, "sample": sample,
|
||||
}
|
||||
|
||||
relinked = 0
|
||||
for g in groups:
|
||||
for loser_id in g["loser_ids"]:
|
||||
relinked += _relink_provenance_to_keeper(
|
||||
session, loser_id=loser_id, keeper_id=g["keeper_id"]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
loser_ids = [lid for g in groups for lid in g["loser_ids"]]
|
||||
deleted = delete_images(session, image_ids=loser_ids, images_root=images_root)
|
||||
log.info(
|
||||
"video dedup: %d group(s), %d redundant removed, %d post link(s) re-pointed",
|
||||
len(groups), deleted["images_deleted"], relinked,
|
||||
)
|
||||
return {
|
||||
"groups": len(groups), "redundant": redundant,
|
||||
"reclaim_bytes": reclaim, "deleted": deleted["images_deleted"],
|
||||
"files_deleted": deleted["files_deleted"],
|
||||
"relinked_posts": relinked, "sample": sample,
|
||||
}
|
||||
|
||||
@@ -122,6 +122,27 @@ def prune_missing_file_records_task(self) -> dict:
|
||||
return {"checked": checked, "missing": len(missing_ids), "deleted": deleted}
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.admin.dedup_videos_task",
|
||||
bind=True,
|
||||
autoretry_for=(OperationalError, DBAPIError),
|
||||
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
||||
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
|
||||
)
|
||||
def dedup_videos_task(self, dry_run: bool = False) -> dict:
|
||||
"""Tier-1 video dedup (#871): re-probe NULL-duration videos, cluster by
|
||||
artist + duration + aspect, keep the highest-res copy per cluster. dry_run
|
||||
returns the projection (groups/redundant/reclaimable bytes) WITHOUT deleting;
|
||||
apply re-points each loser's post links to the keeper then deletes the
|
||||
redundant records + files. Operator-triggered; the summary lands in
|
||||
task_run.metadata (FC-3i) for the Maintenance card to surface."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
return cleanup_service.dedup_videos(
|
||||
session, images_root=IMAGES_ROOT, dry_run=dry_run,
|
||||
)
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.admin.bulk_delete_images_task",
|
||||
bind=True,
|
||||
|
||||
Reference in New Issue
Block a user