2075d6cf66
dedup-library.sh: additive --json (one NDJSON line per candidate deletion to stdout, alongside the unchanged human log) and --only-paths FILE (in --apply mode, only actually delete entries whose path is in FILE; without it, --apply deletes everything as before -- existing direct callers are unaffected). Without --only-paths the safety re-verification is free: --apply --only-paths re-runs all 4 passes from scratch on every invocation, so if a group's ranking changed since a scan (e.g. the old keep_path is gone), the fresh pass assigns the previously-"delete" path the KEEP role instead and the only-paths allowlist naming it is simply never consulted -- no duplicate ranking logic needed in the review service. spotify-genre.py: additive --json emitting one JSON line per genre change (dry-run or --apply) for genre_review_service to persist. pipeline_runner.run_job_capture(): like run_job() but captures stdout as text (still under the same shared lock, still writes a job_runs row) for callers that need to parse structured output rather than just log it. dedup_review_service.scan() persists dry-run candidates into dedup_runs/dedup_candidates. confirm_and_apply() re-checks confirmed candidates still exist before invoking --apply --only-paths, so nothing is ever deleted without an explicit confirm -- matches the false-negative- biased dedup preference. scheduler_service's maintenance:dedup job now goes through this (still dry-run only, every day). genre_review_service.run() wraps spotify-genre.py for both dry-run preview and the real scheduled --apply run, persisting every run's diff into genre_runs/genre_candidates either way -- genre writes keep their current auto-apply behavior (low-risk, reversible, GENRE_LOCK-protected) but are now reviewable after the fact. lock_artist_genre() gives a one-click revert path when a run gets something wrong. Added minimal routers+templates for /dedup (scan, review, confirm-and- delete) and /genres (preview, review, lock-old-genre). Verified end-to-end against REAL duplicate files (not mocked): built an actual FLAC+MP3 duplicate pair in a real beets library, ran dedup-library.sh --json and confirmed correct JSON output, verified --apply --only-paths with an empty confirm list deletes nothing and with the real confirmed path deletes exactly that file (DB + disk) while preserving the FLAC, and ran the full dedup_review_service scan->confirm->apply flow through the same fixture. genre_review_service and spotify-genre.py --json verified against mocked/direct output (spotify-genre.py's own artist-genre lookup needs a live Spotify API call, out of reach in this sandbox). Confirmed the full app boots with all five routers registered.
39 lines
1.1 KiB
HTML
39 lines
1.1 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Dedup — alembic{% endblock %}
|
|
{% block content %}
|
|
<h1>Dedup review</h1>
|
|
<p class="muted">
|
|
Scheduled dedup runs dry-run only, every day. Nothing is ever deleted
|
|
without an explicit confirm below.
|
|
</p>
|
|
|
|
<form method="post" action="/dedup/scan">
|
|
<button type="submit">Scan now</button>
|
|
</form>
|
|
|
|
<h2>Pending candidates</h2>
|
|
<form method="post" action="/dedup/confirm">
|
|
<table>
|
|
<thead>
|
|
<tr><th></th><th>Pass</th><th>Keep</th><th>Delete</th><th>Size</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for c in candidates %}
|
|
<tr>
|
|
<td><input type="checkbox" name="candidate_id" value="{{ c.id }}"></td>
|
|
<td>{{ c.pass_name }}</td>
|
|
<td class="muted">{{ c.keep_path }}</td>
|
|
<td>{{ c.delete_path }}</td>
|
|
<td>{{ (c.delete_size_bytes / 1024 / 1024) | round(1) if c.delete_size_bytes else '?' }} MB</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr><td colspan="5" class="muted">No pending candidates. Run a scan.</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% if candidates %}
|
|
<button type="submit">Delete selected</button>
|
|
{% endif %}
|
|
</form>
|
|
{% endblock %}
|