59 lines
2.9 KiB
Python
59 lines
2.9 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from .acoustic import chromaprint, similarity
|
|
from .arbitration import choose_winner, is_version_conflict, score
|
|
from .exact import hardlink_duplicate
|
|
from .db import connect
|
|
from .constants import QUARANTINE_SUBDIR, TRASH_SUBDIR
|
|
|
|
MIN_SIMILARITY = 0.90
|
|
HIGH_SIMILARITY = 0.95
|
|
REVIEW_SIMILARITY = 0.80
|
|
DURATION_DELTA = 3.0
|
|
|
|
|
|
def _set_meta(conn, row, fp):
|
|
conn.execute("UPDATE audio_tracks SET chromaprint=?,score=?,updated_at=CURRENT_TIMESTAMP WHERE id=?", (fp,score(row),row["id"]))
|
|
|
|
|
|
def find_candidates(conn,row):
|
|
d=row["duration"]
|
|
if d is None: return []
|
|
return conn.execute("SELECT * FROM audio_tracks WHERE id!=? AND status IN ('active','hardlinked') AND duration IS NOT NULL AND duration BETWEEN ? AND ?", (row["id"],d-DURATION_DELTA,d+DURATION_DELTA)).fetchall()
|
|
|
|
|
|
def acoustic_dedupe(db_path: Path, root: Path, dry_run: bool = False) -> dict:
|
|
stats={"checked":0,"equivalent":0,"review":0,"unique":0,"errors":0}
|
|
with connect(db_path) as conn:
|
|
rows=conn.execute("SELECT * FROM audio_tracks WHERE status='active' ORDER BY id").fetchall()
|
|
for row in rows:
|
|
if row["chromaprint"]: continue
|
|
fp=chromaprint(Path(row["file_path"])); stats["checked"]+=1
|
|
if not fp: stats["errors"]+=1; continue
|
|
_set_meta(conn,row,fp)
|
|
candidates=find_candidates(conn,row); best=None
|
|
for c in candidates:
|
|
if not c["chromaprint"]:
|
|
cfp=chromaprint(Path(c["file_path"]))
|
|
if cfp: _set_meta(conn,c,cfp); c=dict(c); c["chromaprint"]=cfp
|
|
sim=similarity(fp,c["chromaprint"] or "")
|
|
if sim>=REVIEW_SIMILARITY and (best is None or sim>best[0]): best=(sim,c)
|
|
if not best or best[0]<REVIEW_SIMILARITY:
|
|
stats["unique"]+=1; continue
|
|
sim,c=best
|
|
if sim<MIN_SIMILARITY or is_version_conflict(row,c):
|
|
conn.execute("UPDATE audio_tracks SET status='review',review_status=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(f"similarity={sim:.3f}",row["id"]))
|
|
stats["review"]+=1; continue
|
|
winner=choose_winner([row,c]); loser=c if winner["id"]==row["id"] else row
|
|
lp=Path(loser["file_path"]); wp=Path(winner["file_path"])
|
|
if lp.exists() and wp.exists():
|
|
# Confirmed same-version acoustic match keeps the user's logical
|
|
# path. The physical data is shared via a hard link.
|
|
if lp.stat().st_ino != wp.stat().st_ino:
|
|
if not dry_run:
|
|
hardlink_duplicate(lp, wp)
|
|
conn.execute("UPDATE audio_tracks SET status='hardlinked',review_status=?,updated_at=CURRENT_TIMESTAMP WHERE id=?", (f"acoustic={sim:.3f};winner={wp}", loser["id"]))
|
|
stats["equivalent"]+=1
|
|
return stats
|