2a19f84575
genre_review_service.run() and dedup_review_service._run_scan() both unconditionally created a new run row even when the underlying job never actually executed (skipped_lock, or any other non-success status). For genres this was directly user-visible: the review page always shows the most recent run, so a skipped click created an empty run that displaced the real previous preview, making it look like every pending change had vanished. Both now return None (persisting nothing) when the job didn't succeed, and both routers surface a "didn't run, something else was using the pipeline" notice instead of silently redirecting. Cleaned up the one phantom empty genre_runs row already sitting in production, restoring the real 20-change preview. Also added a "Run now" button to each row on the Playlists list page (previously only on a playlist's own detail page), for the common case of adding new tracks and wanting to sync immediately.
90 lines
3.6 KiB
Python
90 lines
3.6 KiB
Python
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
|
|
from app.settings import settings
|
|
|
|
router = APIRouter(prefix="/dedup", tags=["dedup"])
|
|
templates = Jinja2Templates(directory="app/templates")
|
|
|
|
# Every library track lives under this prefix -- showing it on every row of
|
|
# every candidate adds nothing but width, so strip it for display (the full
|
|
# path is still available in the title attribute on hover).
|
|
_LIBRARY_PREFIX = str(settings.music_data_dir / "Library") + "/"
|
|
|
|
# dedup-library.sh's four tag-based passes (numbered_sibling, case_insensitive,
|
|
# normalized, cross_album_fuzzy) all match on filenames/tags; find-fuzzy-dupes.py's
|
|
# one pass (fuzzy_audio) matches on acoustic fingerprint. The specific pass
|
|
# name is an implementation detail -- the table just needs to say which of
|
|
# the two matching methods caught it.
|
|
_ACOUSTIC_PASS = "fuzzy_audio"
|
|
|
|
|
|
def _pass_label(pass_name: str) -> str:
|
|
return "Acoustically Similar" if pass_name == _ACOUSTIC_PASS else "File Naming"
|
|
|
|
|
|
def _display_path(path: str) -> str:
|
|
return path[len(_LIBRARY_PREFIX):] if path.startswith(_LIBRARY_PREFIX) else path
|
|
|
|
|
|
def _to_row(c) -> dict:
|
|
return {
|
|
"id": c.id,
|
|
"pass_name": c.pass_name,
|
|
"pass_label": _pass_label(c.pass_name),
|
|
"keep_path": c.keep_path,
|
|
"delete_path": c.delete_path,
|
|
"keep_display": _display_path(c.keep_path),
|
|
"delete_display": _display_path(c.delete_path),
|
|
"delete_size_bytes": c.delete_size_bytes,
|
|
}
|
|
|
|
|
|
@router.get("")
|
|
async def dedup_index(request: Request, user: dict = Depends(require_auth)):
|
|
candidates = [_to_row(c) for c in dedup_review_service.list_pending_candidates()]
|
|
ignored = [_to_row(c) for c in dedup_review_service.list_ignored_candidates()]
|
|
return templates.TemplateResponse(
|
|
request, "dedup/index.html", {"candidates": candidates, "ignored": ignored}
|
|
)
|
|
|
|
|
|
@router.post("/scan")
|
|
async def scan(user: dict = Depends(require_auth)):
|
|
# Runs both the file-naming and acoustic passes -- see
|
|
# dedup_review_service.scan_all(). The candidates table shows which
|
|
# pass caught each one instead of needing two separate buttons.
|
|
tag_run, fuzzy_run = await dedup_review_service.scan_all(triggered_by="manual")
|
|
if tag_run is None or fuzzy_run is None:
|
|
return RedirectResponse(url="/dedup?skipped=1", status_code=303)
|
|
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)
|
|
|
|
|
|
@router.post("/ignore")
|
|
async def ignore(request: Request, user: dict = Depends(require_auth)):
|
|
form = await request.form()
|
|
ignored_by = user.get("email") or user.get("sub", "unknown")
|
|
for v in form.getlist("candidate_id"):
|
|
dedup_review_service.ignore_candidate(int(v), ignored_by)
|
|
return RedirectResponse(url="/dedup", status_code=303)
|
|
|
|
|
|
@router.post("/unignore")
|
|
async def unignore(request: Request, user: dict = Depends(require_auth)):
|
|
form = await request.form()
|
|
for v in form.getlist("candidate_id"):
|
|
dedup_review_service.unignore_candidate(int(v))
|
|
return RedirectResponse(url="/dedup", status_code=303)
|