Fix library filter, compact dashboard/credentials UI, add fuzzy audio dedup

- beets_service: filter params now use truthy checks instead of `is not
  None`, since a real <select> left on "(any)" submits an empty string,
  not an absent param -- the filter form silently matched zero rows for
  any real submission. Also match grouping tokens within "; "-joined
  multi-playlist values instead of requiring an exact string match.
- dashboard: credential status renders as compact dot indicators instead
  of badge+text chips, so long scope names (telegram) stay on one line.
- credentials page: two-column grid layout instead of one long column.
- find-fuzzy-dupes.py: add --json/--only-paths flags matching
  dedup-library.sh's convention, so it can plug into the same review
  queue.
- dedup_review_service: add scan_fuzzy() and route confirm_and_apply()
  to the correct underlying script (dedup-library.sh vs
  find-fuzzy-dupes.py) per candidate's pass_name, since tag-based passes
  miss duplicates whose tags differ even when the audio is identical
  (e.g. a remix credited to different artists between two copies).
- dedup page: add a "Scan for audio duplicates" trigger alongside the
  existing tag-based scan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
andrew
2026-07-08 16:25:52 -06:00
parent c87ddc2649
commit 02744f6654
8 changed files with 217 additions and 85 deletions
+27 -1
View File
@@ -141,8 +141,20 @@ def main() -> int:
ap.add_argument("--duration-tol", type=int, default=DURATION_TOLERANCE)
ap.add_argument("--no-cache", action="store_true",
help="ignore the scan cache and re-fingerprint everything")
ap.add_argument("--json", action="store_true",
help="additionally emit one JSON line per candidate deletion to stdout, "
"for dedup_review_service to parse -- same convention as "
"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.")
args = ap.parse_args()
only_paths = None
if args.only_paths:
with open(args.only_paths) as f:
only_paths = {line.strip() for line in f if line.strip()}
if not os.path.exists(FINGERPRINT_DB):
print(f"ERROR: fingerprint index missing at {FINGERPRINT_DB}", file=sys.stderr)
print("Run build-fingerprint-index.py first.", file=sys.stderr)
@@ -258,6 +270,14 @@ def main() -> int:
continue
print(f" DELETE [{loser_ext} {loser_size / 1024 / 1024:.1f}M sim={score:.3f}] {loser_path}")
delete_targets.append((beets_id, loser_path))
if args.json:
print(json.dumps({
"pass": "fuzzy_audio",
"keep_path": keeper_path,
"delete_path": loser_path,
"delete_size_bytes": loser_size,
"similarity": round(score, 3),
}))
print()
if skipped_transitive:
print(f"[fuzzy-dupes] skipped {skipped_transitive} transitive false-positives "
@@ -269,7 +289,12 @@ def main() -> int:
print(f"\n[fuzzy-dupes] APPLY: deleting {len(delete_targets)} files via beet remove -d -f")
failed = 0
skipped_unconfirmed = 0
for beets_id, path in delete_targets:
if only_paths is not None and path not in only_paths:
print(f" SKIP (not in --only-paths confirm list) {path}")
skipped_unconfirmed += 1
continue
# Escape regex metacharacters for path:: regex query
escaped = re.escape(path)
result = subprocess.run(
@@ -279,7 +304,8 @@ def main() -> int:
if result.returncode != 0:
print(f" FAILED: {path}{result.stderr.strip()}", file=sys.stderr)
failed += 1
print(f"[fuzzy-dupes] done. {len(delete_targets) - failed} deleted, {failed} failed.")
deleted = len(delete_targets) - failed - skipped_unconfirmed
print(f"[fuzzy-dupes] done. {deleted} deleted, {failed} failed, {skipped_unconfirmed} skipped (unconfirmed).")
return 1 if failed else 0