Add "keep both" option to dedup review

Lets a candidate be marked ignored instead of confirmed/applied; future
scans skip re-flagging the same path pair once it's been ignored, since
a rescan can flip which side ranks as keep/delete without changing the
user's earlier decision that the pair is fine as two copies. Also adds
--apply-pairs to find-fuzzy-dupes.py to apply pre-confirmed pairs
without a full rescan.
This commit is contained in:
andrew
2026-07-09 08:42:14 -06:00
parent 69804a9eb2
commit 9d1eee3337
6 changed files with 215 additions and 19 deletions
+3
View File
@@ -115,6 +115,9 @@ class DedupCandidate(Base):
confirmed_by = Column(String, nullable=True)
confirmed_at = Column(Float, nullable=True)
applied = Column(Boolean, nullable=False, default=False)
ignored = Column(Boolean, nullable=False, default=False)
ignored_by = Column(String, nullable=True)
ignored_at = Column(Float, nullable=True)
class GenreRun(Base):
+34 -13
View File
@@ -19,21 +19,25 @@ def _display_path(path: str) -> str:
return path[len(_LIBRARY_PREFIX):] if path.startswith(_LIBRARY_PREFIX) else path
def _to_row(c) -> dict:
return {
"id": c.id,
"pass_name": c.pass_name,
"keep_path": c.keep_path,
"delete_path": c.delete_path,
"keep_display": _display_path(c.keep_path),
"delete_display": _display_path(c.delete_path),
"delete_size_bytes": c.delete_size_bytes,
}
@router.get("")
async def dedup_index(request: Request, user: dict = Depends(require_auth)):
candidates = [
{
"id": c.id,
"pass_name": c.pass_name,
"keep_path": c.keep_path,
"delete_path": c.delete_path,
"keep_display": _display_path(c.keep_path),
"delete_display": _display_path(c.delete_path),
"delete_size_bytes": c.delete_size_bytes,
}
for c in dedup_review_service.list_pending_candidates()
]
return templates.TemplateResponse(request, "dedup/index.html", {"candidates": candidates})
candidates = [_to_row(c) for c in dedup_review_service.list_pending_candidates()]
ignored = [_to_row(c) for c in dedup_review_service.list_ignored_candidates()]
return templates.TemplateResponse(
request, "dedup/index.html", {"candidates": candidates, "ignored": ignored}
)
@router.post("/scan")
@@ -55,3 +59,20 @@ async def confirm(request: Request, user: dict = Depends(require_auth)):
confirmed_by = user.get("email") or user.get("sub", "unknown")
await dedup_review_service.confirm_and_apply(candidate_ids, confirmed_by)
return RedirectResponse(url="/dedup", status_code=303)
@router.post("/ignore")
async def ignore(request: Request, user: dict = Depends(require_auth)):
form = await request.form()
ignored_by = user.get("email") or user.get("sub", "unknown")
for v in form.getlist("candidate_id"):
dedup_review_service.ignore_candidate(int(v), ignored_by)
return RedirectResponse(url="/dedup", status_code=303)
@router.post("/unignore")
async def unignore(request: Request, user: dict = Depends(require_auth)):
form = await request.form()
for v in form.getlist("candidate_id"):
dedup_review_service.unignore_candidate(int(v))
return RedirectResponse(url="/dedup", status_code=303)
+73 -1
View File
@@ -2,7 +2,7 @@ import json
import time
from pathlib import Path
from sqlalchemy import select
from sqlalchemy import or_, select
from app.db import SessionLocal
from app.models import DedupCandidate, DedupRun
@@ -18,6 +18,21 @@ def _script_for_pass(pass_name: str) -> str:
return _FUZZY_SCRIPT if pass_name == _FUZZY_PASS else _SCRIPT
def _is_pair_ignored(db, path_a: str, path_b: str) -> bool:
"""True if this path pair was ever marked 'keep both', regardless of
which path was on the keep/delete side that time -- a later scan can
flip the ranking (e.g. file sizes changed) without changing the fact
that the user already decided this pair is fine as two copies."""
query = select(DedupCandidate.id).where(
DedupCandidate.ignored == True, # noqa: E712
or_(
(DedupCandidate.keep_path == path_a) & (DedupCandidate.delete_path == path_b),
(DedupCandidate.keep_path == path_b) & (DedupCandidate.delete_path == path_a),
),
).limit(1)
return db.execute(query).first() is not None
def _parse_json_lines(output: str) -> list[dict]:
candidates = []
for line in output.splitlines():
@@ -53,7 +68,11 @@ async def _run_scan(job_key: str, script_name: str, triggered_by: str) -> DedupR
db.commit()
db.refresh(dedup_run)
skipped_ignored = 0
for c in candidates:
if _is_pair_ignored(db, c["keep_path"], c["delete_path"]):
skipped_ignored += 1
continue
db.add(
DedupCandidate(
dedup_run_id=dedup_run.id,
@@ -65,6 +84,10 @@ async def _run_scan(job_key: str, script_name: str, triggered_by: str) -> DedupR
)
db.commit()
db.refresh(dedup_run)
if skipped_ignored:
dedup_run.kept = (dedup_run.kept or 0) + skipped_ignored
db.commit()
db.refresh(dedup_run)
return dedup_run
finally:
db.close()
@@ -177,9 +200,58 @@ def list_pending_candidates(dedup_run_id: int | None = None) -> list[DedupCandid
query = select(DedupCandidate).where(
DedupCandidate.applied == False, # noqa: E712
DedupCandidate.confirmed == False, # noqa: E712
DedupCandidate.ignored == False, # noqa: E712
)
if dedup_run_id is not None:
query = query.where(DedupCandidate.dedup_run_id == dedup_run_id)
return list(db.execute(query).scalars())
finally:
db.close()
def list_ignored_candidates() -> list[DedupCandidate]:
db = SessionLocal()
try:
query = select(DedupCandidate).where(DedupCandidate.ignored == True).order_by( # noqa: E712
DedupCandidate.ignored_at.desc()
)
return list(db.execute(query).scalars())
finally:
db.close()
def ignore_candidate(candidate_id: int, ignored_by: str) -> DedupCandidate | None:
"""Mark a pending candidate 'keep both' -- it drops off the pending
list immediately, and future scans skip re-creating a candidate for
the same path pair (see _is_pair_ignored)."""
db = SessionLocal()
try:
c = db.get(DedupCandidate, candidate_id)
if c is None or c.applied:
return None
c.ignored = True
c.ignored_by = ignored_by
c.ignored_at = time.time()
db.commit()
db.refresh(c)
return c
finally:
db.close()
def unignore_candidate(candidate_id: int) -> DedupCandidate | None:
"""Undo a 'keep both' -- the candidate returns to the pending list and
future scans are free to re-flag the same pair again."""
db = SessionLocal()
try:
c = db.get(DedupCandidate, candidate_id)
if c is None:
return None
c.ignored = False
c.ignored_by = None
c.ignored_at = None
db.commit()
db.refresh(c)
return c
finally:
db.close()
+40 -4
View File
@@ -22,11 +22,11 @@
<div class="table-wrap scroll">
<table class="dedup-table">
<colgroup>
<col style="width:2.2rem"><col style="width:7rem"><col style="width:33%">
<col style="width:33%"><col style="width:5rem"><col style="width:5rem">
<col style="width:2.2rem"><col style="width:7rem"><col style="width:30%">
<col style="width:30%"><col style="width:5rem"><col style="width:9rem">
</colgroup>
<thead>
<tr><th></th><th>Pass</th><th>Keep</th><th>Delete</th><th>Size</th><th></th></tr>
<tr><th></th><th>Pass</th><th>Keep</th><th>Delete</th><th>Size</th><th>Action</th></tr>
</thead>
<tbody>
{% for c in candidates %}
@@ -36,11 +36,15 @@
<td class="muted mono path-cell" title="{{ c.keep_path }}">{{ c.keep_display }}</td>
<td class="mono path-cell" title="{{ c.delete_path }}">{{ c.delete_display }}</td>
<td class="muted">{{ (c.delete_size_bytes / 1024 / 1024) | round(1) if c.delete_size_bytes else '?' }} MB</td>
<td>
<td class="actions-cell">
<form method="post" action="/dedup/confirm" class="inline">
<input type="hidden" name="candidate_id" value="{{ c.id }}">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
<form method="post" action="/dedup/ignore" class="inline">
<input type="hidden" name="candidate_id" value="{{ c.id }}">
<button type="submit" class="btn btn-sm btn-ghost" title="Keep both copies and never flag this pair again">Keep both</button>
</form>
</td>
</tr>
{% else %}
@@ -57,4 +61,36 @@
{% if candidates %}
<button type="submit" form="bulk-delete-form" class="btn btn-danger">Delete selected</button>
{% endif %}
{% if ignored %}
<h2>Kept both</h2>
<p class="muted" style="margin-top:-0.5rem;">Marked as legitimate alt copies -- future scans skip these pairs.</p>
<div class="table-wrap scroll">
<table class="dedup-table">
<colgroup>
<col style="width:7rem"><col style="width:34%">
<col style="width:34%"><col style="width:5rem"><col style="width:7rem">
</colgroup>
<thead>
<tr><th>Pass</th><th>Keep</th><th>Delete</th><th>Size</th><th></th></tr>
</thead>
<tbody>
{% for c in ignored %}
<tr>
<td><span class="badge badge-muted">{{ c.pass_name }}</span></td>
<td class="muted mono path-cell" title="{{ c.keep_path }}">{{ c.keep_display }}</td>
<td class="muted mono path-cell" title="{{ c.delete_path }}">{{ c.delete_display }}</td>
<td class="muted">{{ (c.delete_size_bytes / 1024 / 1024) | round(1) if c.delete_size_bytes else '?' }} MB</td>
<td>
<form method="post" action="/dedup/unignore" class="inline">
<input type="hidden" name="candidate_id" value="{{ c.id }}">
<button type="submit" class="btn btn-sm btn-ghost">Un-ignore</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
+57 -1
View File
@@ -134,6 +134,54 @@ class UnionFind:
self.parent[ra] = rb
def _apply_pairs(pairs_file: str, emit_json: bool) -> int:
"""Apply already-confirmed keep/delete pairs (JSON lines: {"keep_path":
..., "delete_path": ...}) without re-scanning the library for
duplicates. A full rescan recomputes Chromaprint similarity for every
pair in the library (10+ minutes on a ~4k track library, longer
whenever the scan cache is cold e.g. right after fingerprints.db gets
rewritten) -- wildly disproportionate for applying a decision a human
already reviewed. The one thing that actually needs re-checking here
is whether the keep/delete ranking flipped since confirmation (e.g. the
delete_path got upgraded to FLAC in the meantime); that's a cheap,
local, filesystem-only check via rank_file(), no fingerprinting
involved."""
pairs = []
with open(pairs_file) as f:
for line in f:
line = line.strip()
if line:
pairs.append(json.loads(line))
print(f"[fuzzy-dupes] applying {len(pairs)} pre-confirmed pair(s), no rescan")
deleted = failed = skipped = 0
for pair in pairs:
keep_path, delete_path = pair["keep_path"], pair["delete_path"]
if not os.path.exists(delete_path):
print(f" SKIP (already gone) {delete_path}")
skipped += 1
continue
if not os.path.exists(keep_path) or rank_file(delete_path) < rank_file(keep_path):
print(f" SKIP (ranking flipped or keep_path missing since confirm) {delete_path}")
skipped += 1
continue
escaped = re.escape(delete_path)
result = subprocess.run(
["beet", "remove", "-d", "-f", f"path::{escaped}"],
capture_output=True, text=True,
)
if result.returncode != 0:
print(f" FAILED: {delete_path}{result.stderr.strip()}", file=sys.stderr)
failed += 1
continue
print(f" DELETE {delete_path}")
deleted += 1
if emit_json:
print(json.dumps({"pass": "fuzzy_audio", "keep_path": keep_path, "delete_path": delete_path}))
print(f"[fuzzy-dupes] done. {deleted} deleted, {failed} failed, {skipped} skipped.")
return 1 if failed else 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--apply", action="store_true", help="actually delete losers")
@@ -147,9 +195,17 @@ def main() -> int:
"dedup-library.sh --json. Purely additive.")
ap.add_argument("--only-paths", metavar="FILE",
help="in --apply mode, only delete a candidate if its path is listed "
"(one per line) in FILE -- same convention as dedup-library.sh.")
"(one per line) in FILE -- same convention as dedup-library.sh. "
"Still re-scans the whole library first; prefer --apply-pairs for "
"applying already-confirmed pairs one at a time.")
ap.add_argument("--apply-pairs", metavar="FILE",
help="apply already-confirmed keep/delete pairs (JSON lines) without "
"re-scanning the library -- see _apply_pairs().")
args = ap.parse_args()
if args.apply_pairs:
return _apply_pairs(args.apply_pairs, args.json)
only_paths = None
if args.only_paths:
with open(args.only_paths) as f:
+8
View File
@@ -0,0 +1,8 @@
-- Adds a "keep both" path for dedup review: a candidate can be marked
-- ignored instead of confirmed/applied, and future scans skip re-creating
-- a pending candidate for the same path pair once it's been ignored.
ALTER TABLE dedup_candidates ADD COLUMN ignored BOOLEAN NOT NULL DEFAULT 0;
ALTER TABLE dedup_candidates ADD COLUMN ignored_by TEXT;
ALTER TABLE dedup_candidates ADD COLUMN ignored_at REAL;
UPDATE schema_version SET version = 2;