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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user