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.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.security.deps import require_auth
|
||||
from app.services import dedup_review_service
|
||||
|
||||
router = APIRouter(prefix="/dedup", tags=["dedup"])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def dedup_index(request: Request, user: dict = Depends(require_auth)):
|
||||
candidates = dedup_review_service.list_pending_candidates()
|
||||
return templates.TemplateResponse(request, "dedup/index.html", {"candidates": candidates})
|
||||
|
||||
|
||||
@router.post("/scan")
|
||||
async def scan(user: dict = Depends(require_auth)):
|
||||
await dedup_review_service.scan(triggered_by="manual")
|
||||
return RedirectResponse(url="/dedup", status_code=303)
|
||||
|
||||
|
||||
@router.post("/confirm")
|
||||
async def confirm(request: Request, user: dict = Depends(require_auth)):
|
||||
form = await request.form()
|
||||
candidate_ids = [int(v) for k, v in form.multi_items() if k == "candidate_id"]
|
||||
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)
|
||||
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import get_db
|
||||
from app.models import GenreCandidate, GenreRun
|
||||
from app.security.deps import require_auth
|
||||
from app.services import genre_review_service
|
||||
|
||||
router = APIRouter(prefix="/genres", tags=["genres"])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def genres_index(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
|
||||
latest_run = db.execute(select(GenreRun).order_by(GenreRun.id.desc())).scalars().first()
|
||||
candidates = []
|
||||
if latest_run is not None:
|
||||
candidates = list(
|
||||
db.execute(
|
||||
select(GenreCandidate).where(GenreCandidate.genre_run_id == latest_run.id)
|
||||
).scalars()
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request, "genres/index.html", {"latest_run": latest_run, "candidates": candidates}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/scan")
|
||||
async def scan(force: bool = Form(False), user: dict = Depends(require_auth)):
|
||||
await genre_review_service.run(apply=False, force=force, triggered_by="manual")
|
||||
return RedirectResponse(url="/genres", status_code=303)
|
||||
|
||||
|
||||
@router.post("/lock")
|
||||
async def lock(
|
||||
artist: str = Form(...),
|
||||
genre: str = Form(...),
|
||||
user: dict = Depends(require_auth),
|
||||
):
|
||||
changed_by = user.get("email") or user.get("sub", "unknown")
|
||||
await genre_review_service.lock_artist_genre(artist, genre, changed_by)
|
||||
return RedirectResponse(url="/genres", status_code=303)
|
||||
Reference in New Issue
Block a user