0542654ca8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m6s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 48s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m25s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m33s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 19s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 45s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m58s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m9s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 57s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
AI Code Review / AI Code Review (pull_request) Successful in 6m27s
CI/CD Pipeline / Validate - Security (push) Successful in 6m41s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 6m16s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m48s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m39s
CI/CD Pipeline / Unit Tests (push) Successful in 11m34s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
199 lines
7.3 KiB
Python
199 lines
7.3 KiB
Python
"""手动查重任务(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()
|
||
# 超过重试上限:标记 failed 并返回失败结果,不再 retry
|
||
if "repo" in locals() and self.request.retries >= self.max_retries:
|
||
try:
|
||
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()
|
||
return {"ok": False, "record_id": record_id, "status": "failed", "error": str(e)}
|
||
# 未达上限:60 秒后重试
|
||
raise self.retry(exc=e, countdown=60) from e
|
||
return {"ok": False, "record_id": record_id, "status": "failed", "error": str(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)
|