Files
alembic/app/templates/genres/index.html
T
andrew 2075d6cf66 Add dedup_review_service and genre_review_service
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.
2026-07-08 14:17:20 -06:00

44 lines
1.5 KiB
HTML

{% extends "base.html" %}
{% block title %}Genres — alembic{% endblock %}
{% block content %}
<h1>Genre review</h1>
<p class="muted">
The weekly scheduled run still writes genres automatically (low-risk,
reversible, GENRE_LOCK-protected) -- this shows what the last run changed.
</p>
<form method="post" action="/genres/scan">
<label><input type="checkbox" name="force" value="true"> force (overwrite existing genres)</label>
<button type="submit">Preview now (dry run)</button>
</form>
<h2>Last run</h2>
{% if latest_run %}
<p class="muted">{{ latest_run.mode }} — {{ candidates | length }} change(s)</p>
<table>
<thead><tr><th>Artist</th><th>Title</th><th>Old genre</th><th>New genre</th><th></th></tr></thead>
<tbody>
{% for c in candidates %}
<tr>
<td>{{ c.artist }}</td>
<td>{{ c.title }}</td>
<td class="muted">{{ c.old_genre or '(empty)' }}</td>
<td>{{ c.new_genre }}</td>
<td>
<form method="post" action="/genres/lock" style="display:inline">
<input type="hidden" name="artist" value="{{ c.artist }}">
<input type="hidden" name="genre" value="{{ c.old_genre or '' }}">
<button type="submit" title="Revert to old genre and lock it">lock old genre</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">No changes in the last run.</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No runs yet.</p>
{% endif %}
{% endblock %}