Files
xiaoxia-saas/apps/worker/worker_app/tasks/duplication_check.py
T
xiaoxia 2205adb8fb
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 29s
CI/CD Pipeline / Build Staging API Image (push) Successful in 31s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 31s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 33s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 39s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m49s
CI/CD Pipeline / CI Gate (pull_request) Successful in 11s
CI/CD Pipeline / Integration Tests (push) Successful in 2m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m25s
CI/CD Pipeline / Validate - Style (push) Successful in 2m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m11s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m58s
AI Code Review / AI Code Review (pull_request) Failing after 3m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m33s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m13s
CI/CD Pipeline / Validate - Security (push) Successful in 5m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m9s
CI/CD Pipeline / Unit Tests (push) Successful in 8m9s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 301h47m47s
CI/CD Pipeline / Deploy Production (push) Failing after 301h47m47s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 301h47m51s
CI/CD Pipeline / Build Production Web Image (push) Failing after 301h47m51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 301h53m8s
CI/CD Pipeline / Build Production API Image (push) Failing after 301h47m51s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 301h54m36s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 301h54m36s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 301h54m40s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 301h54m41s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 301h54m42s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 301h54m46s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 301h54m46s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 301h54m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 301h54m49s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 301h55m25s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 301h55m26s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 301h56m0s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 301h55m27s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 301h56m1s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 301h56m0s
CI/CD Pipeline / Frontend Lint (push) Failing after 301h56m2s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 301h56m1s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 301h56m7s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 301h56m1s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 301h56m8s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 301h56m8s
CI/CD Pipeline / PR Build API Image (push) Failing after 301h56m8s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 301h56m9s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 301h56m10s
CI/CD Pipeline / CI Gate (push) Failing after 302h22m16s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 302h29m2s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 302h29m8s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 302h29m13s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 302h29m50s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 302h30m25s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 302h30m33s
CI/CD Pipeline / PR Build Web Image (push) Failing after 302h30m33s
feat(worker): 手动查重 worker task + visual_similarity/match_count 字段 #1661 (#1679)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-04 01:28:35 +08:00

197 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""手动查重任务(Issue #1661)。
流程:
1. 从 OSS 下载用户上传的待查重视频
2. 动态抽帧计算指纹(复用 VideoDeduplicator.compute_fingerprint)
3. 跨项目与用户所有已有成片比对(compute_duplicate_rate + find_duplicate_segments)
4. 更新 DuplicationRecord:status / duplicate_rate / duplicate_count / segments
同时写入 visual_similarity / match_count
5. 失败重试 3 次、间隔 60 秒,最终失败标记 failed;临时文件始终清理
"""
import logging
import os
import shutil
import tempfile
from celery import Task
from celery.exceptions import Retry
from video_processing.dedup import (
VideoDeduplicator,
find_duplicate_segments,
)
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.duplication_repository import (
SQLAlchemyDuplicationRecordRepository,
)
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain.duplication import DuplicateSegment
from packages.shared.storage import get_storage_service
logger = logging.getLogger(__name__)
def _build_domain_segments(
fingerprint,
session,
deduplicator: VideoDeduplicator,
user_id: str,
) -> tuple[list[DuplicateSegment], int]:
"""对用户所有已有视频做分片级时序匹配,构建领域片段列表。
Returns:
(segments, duplicate_count) — segments 为 query 视频中的重复片段,
duplicate_count 为存在重复片段的匹配视频数。
"""
video_repo = SQLAlchemyGeneratedVideoRepository(session)
existing_videos = video_repo.list_by_user(user_id)
segments_out: list[DuplicateSegment] = []
duplicate_count = 0
for existing in existing_videos:
if not existing.video_fingerprint:
continue
chunk_data = deduplicator._get_existing_chunks(existing.id, session)
if not chunk_data:
# 老视频无分片数据,时序定位不可靠,跳过片段级匹配
continue
raw_segments = find_duplicate_segments(fingerprint.chunks, chunk_data)
if not raw_segments:
continue
duplicate_count += 1
for raw in raw_segments:
avg_sim = 1.0 - raw.avg_distance / 64.0
segments_out.append(
DuplicateSegment.create(
source_start=round(raw.query_start_ms / 1000.0, 2),
source_end=round(raw.query_end_ms / 1000.0, 2),
matched_video_id=existing.id,
matched_video_name=existing.name,
matched_start=round(raw.target_start_ms / 1000.0, 2),
matched_end=round(raw.target_end_ms / 1000.0, 2),
similarity=round(max(0.0, min(1.0, avg_sim)) * 100, 1),
)
)
# 按 query 起始时间排序,片段时间轴稳定
segments_out.sort(key=lambda s: (s.source_start, s.source_end))
return segments_out, duplicate_count
@celery_app.task(bind=True, max_retries=3, name="worker.process_duplication_check")
def process_duplication_check(self: Task, record_id: str) -> dict:
"""处理一次手动查重请求。
Args:
record_id: DuplicationRecord ID
Returns:
dict: {"ok": True, "record_id": ..., "duplicate_rate": ..., ...}
"""
session = None
temp_dir = None
try:
session = SessionLocal()
repo = SQLAlchemyDuplicationRecordRepository(session)
storage_service = get_storage_service()
deduplicator = VideoDeduplicator()
record = repo.get(record_id)
if record is None:
raise ValueError(f"Duplication record {record_id} not found")
if record.status not in ("pending", "processing"):
logger.info("Duplication record %s already %s, skip", record_id, record.status)
return {"ok": True, "record_id": record_id, "status": record.status, "skipped": True}
record.mark_processing()
repo.update(record)
session.commit()
temp_dir = tempfile.mkdtemp(prefix="dup_check_")
suffix = os.path.splitext(record.filename)[1] or ".mp4"
local_path = os.path.join(temp_dir, f"{record_id}{suffix}")
storage_service.download_file(record.storage_key, local_path)
fingerprint = deduplicator.compute_fingerprint(local_path)
record.duration_seconds = round(fingerprint.duration, 2) if fingerprint.duration else 0.0
record.video_fingerprint = fingerprint.to_dict()
# 跨项目与用户所有已有视频比对(current_video_id=None:上传视频不在成片表中)
rate_result = deduplicator.compute_duplicate_rate(
fingerprint,
project_id="",
current_video_id=None,
session=session,
scope="user",
user_id=record.user_id,
)
# 分片级时序匹配 → 重复片段
segments, segment_match_count = _build_domain_segments(fingerprint, session, deduplicator, record.user_id)
record.mark_completed(
duplicate_rate=rate_result["duplicate_rate"],
duplicate_count=segment_match_count,
segments=segments,
visual_similarity=rate_result["visual_similarity"],
match_count=rate_result["match_count"],
)
repo.update(record)
session.commit()
logger.info(
"Duplication check completed: record=%s rate=%.2f%% matches=%d segments=%d",
record_id,
record.duplicate_rate,
record.match_count,
len(segments),
)
return {
"ok": True,
"record_id": record_id,
"status": "completed",
"duplicate_rate": record.duplicate_rate,
"duplicate_count": record.duplicate_count,
"visual_similarity": record.visual_similarity,
"match_count": record.match_count,
"segments": len(segments),
}
except Retry:
raise
except Exception as e:
logger.error("Duplication check failed for record %s: %s", record_id, e, exc_info=True)
if session is not None:
session.rollback()
# 本次是最后一次执行机会(retries 从 0 计数,达到 max_retries 说明重试已耗尽),
# 标记 failed;否则保持 pending 由 Celery 60 秒后重试
try:
if "repo" in locals() and self.request.retries >= self.max_retries:
failed_record = repo.get(record_id)
if failed_record is not None and failed_record.status != "failed":
failed_record.mark_failed(f"查重失败(已重试{self.max_retries}次): {e}")
repo.update(failed_record)
session.commit()
except Exception as inner:
logger.error("Failed to mark duplication record %s as failed: %s", record_id, inner)
session.rollback()
raise self.retry(exc=e, countdown=60) from e
finally:
if session is not None:
session.close()
if temp_dir and os.path.isdir(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)