Fix playlist status page, dedup delete-by-path bug, duplicate scan rows; add public IP dashboard card

Playlist detail page's status section (and the new two-column compare
view) was never rendering: the router awaited status_service.playlist_status(),
but that function is plain sync, so awaiting its dict return raised
"object dict can't be used in 'await' expression" on every load, silently
caught and shown as a generic fetch error. Removed the erroneous await.

find-fuzzy-dupes.py's --apply and --apply-pairs both deleted by a `path::`
regex query even though the beets id was already available in scope --
same mixed-path-storage issue (pre/post beets-2.11-upgrade items store
absolute vs. library-relative paths) dedup-library.sh already worked
around by deleting via id instead. This silently failed to match for most
confirmed fuzzy-audio deletions (verified: 19 of 20 in one run). Added a
delete_id column to dedup_candidates, threaded through the scan/apply
pipeline, and switched both delete call sites to `id:`.

Dedup scans also never checked whether a pair was already sitting in the
pending list, so every re-scan (including the daily schedule) added a new
row for the same unreviewed duplicate -- cleaned up 38 redundant rows
already in production and added a check so future scans skip a pair
that's already pending.

Dashboard gets a Public IP stat card (cached 10 min, fetched via ipify)
as a quick confidence check that outbound traffic is actually routed
through gluetun's VPN and not the home connection.
This commit is contained in:
andrew
2026-07-09 11:13:58 -06:00
parent 9a116f3da4
commit 59c369c9e2
8 changed files with 118 additions and 21 deletions
+30 -16
View File
@@ -136,16 +136,20 @@ class UnionFind:
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."""
..., "delete_path": ..., "delete_id": ...}) 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.
Deletes by beets id, not path -- see the id-vs-path comment on the
--apply loop in main() below for why a path:: query silently fails to
match for a large fraction of this library."""
pairs = []
with open(pairs_file) as f:
for line in f:
@@ -156,7 +160,7 @@ def _apply_pairs(pairs_file: str, emit_json: bool) -> int:
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"]
keep_path, delete_path, delete_id = pair["keep_path"], pair["delete_path"], pair.get("delete_id")
if not os.path.exists(delete_path):
print(f" SKIP (already gone) {delete_path}")
skipped += 1
@@ -165,9 +169,14 @@ def _apply_pairs(pairs_file: str, emit_json: bool) -> int:
print(f" SKIP (ranking flipped or keep_path missing since confirm) {delete_path}")
skipped += 1
continue
escaped = re.escape(delete_path)
if delete_id is not None:
query = f"id:{delete_id}"
else:
# Pre-existing candidate confirmed before delete_id started being
# stored -- fall back to the old (less reliable) path query.
query = f"path::{re.escape(delete_path)}"
result = subprocess.run(
["beet", "remove", "-d", "-f", f"path::{escaped}"],
["beet", "remove", "-d", "-f", query],
capture_output=True, text=True,
)
if result.returncode != 0:
@@ -331,6 +340,7 @@ def main() -> int:
"pass": "fuzzy_audio",
"keep_path": keeper_path,
"delete_path": loser_path,
"delete_id": beets_id,
"delete_size_bytes": loser_size,
"similarity": round(score, 3),
}))
@@ -351,10 +361,14 @@ def main() -> int:
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)
# id, not path:: -- the beets 2.11 upgrade left the DB with mixed
# path storage (pre-upgrade items store absolute /music/... paths,
# post-upgrade imports store library-relative paths), so no single
# path query form matches both populations. Same lesson
# dedup-library.sh's process_group() already learned. Ids are
# storage-format-proof.
result = subprocess.run(
["beet", "remove", "-d", "-f", f"path::{escaped}"],
["beet", "remove", "-d", "-f", f"id:{beets_id}"],
capture_output=True, text=True,
)
if result.returncode != 0: