Background long-running actions; confirm destructive ones

- Run now (jobs and playlists), dedup scan, genre preview/apply, and manual
  import now dispatch via BackgroundTasks and redirect immediately, instead
  of awaiting a job that can run for the better part of an hour and hang the
  browser or reverse proxy. Progress shows in the Jobs runs table (which
  already polls); if the pipeline is busy the run records skipped_lock there.
- Fix the import banner, which claimed work was ongoing after the request
  had actually blocked to completion; it now reflects the backgrounded start.
- Add confirmation prompts to the dedup per-row and bulk delete and to
  "Fix genres now", matching the existing confirms on library and playlist
  deletes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
andrew
2026-07-09 14:31:54 -06:00
parent b135f11557
commit 8f1fc458f6
8 changed files with 61 additions and 43 deletions
+9 -8
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
@@ -53,14 +53,15 @@ async def dedup_index(request: Request, user: dict = Depends(require_auth)):
@router.post("/scan")
async def scan(user: dict = Depends(require_auth)):
async def scan(background: BackgroundTasks, 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)
# dedup_review_service.scan_all(). The acoustic pass can take a while on a
# large library, so run it in the background and return immediately; the
# candidates appear here once it finishes (reload), and the run shows up
# under Settings then Jobs. If the pipeline is busy it records skipped_lock
# there rather than blocking.
background.add_task(dedup_review_service.scan_all, triggered_by="manual")
return RedirectResponse(url="/dedup?started=1", status_code=303)
@router.post("/confirm")
+9 -12
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, Form, Request
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
@@ -28,22 +28,19 @@ async def genres_index(request: Request, user: dict = Depends(require_auth), db=
@router.post("/scan")
async def scan(user: dict = Depends(require_auth)):
async def scan(background: BackgroundTasks, user: dict = Depends(require_auth)):
# Always force=True: matches the weekly scheduled run's own behavior
# (see genre_review_service.run's docstring), so a preview accurately
# shows what "Fix genres now" would actually do.
result = await genre_review_service.run(apply=False, force=True, triggered_by="manual")
if result is None:
return RedirectResponse(url="/genres?skipped=1", status_code=303)
return RedirectResponse(url="/genres", status_code=303)
# shows what "Fix genres now" would actually do. Runs in the background;
# the last-run table below fills in when it finishes.
background.add_task(genre_review_service.run, apply=False, force=True, triggered_by="manual")
return RedirectResponse(url="/genres?started=1", status_code=303)
@router.post("/apply")
async def apply(user: dict = Depends(require_auth)):
result = await genre_review_service.run(apply=True, force=True, triggered_by="manual")
if result is None:
return RedirectResponse(url="/genres?skipped=1", status_code=303)
return RedirectResponse(url="/genres", status_code=303)
async def apply(background: BackgroundTasks, user: dict = Depends(require_auth)):
background.add_task(genre_review_service.run, apply=True, force=True, triggered_by="manual")
return RedirectResponse(url="/genres?started=1", status_code=303)
@router.post("/lock")
+18 -7
View File
@@ -1,12 +1,22 @@
from fastapi import APIRouter, Depends, Form, Request, UploadFile
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request, UploadFile
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from app.db import get_db
from app.db import SessionLocal, get_db
from app.models import Playlist
from app.security.deps import require_auth
from app.services import manual_import
async def _run_import_bg(source: str | None, playlist_name: str | None, imported_by: str) -> None:
"""Import in the background with its own DB session. The request's session
is closed once the response is sent, so we cannot reuse it here."""
db = SessionLocal()
try:
await manual_import.import_track(db, source, playlist_name, imported_by)
finally:
db.close()
router = APIRouter(prefix="/import", tags=["import"])
templates = Jinja2Templates(directory="app/templates")
@@ -37,13 +47,14 @@ async def upload(
@router.post("/run")
async def run_import(
background: BackgroundTasks,
source: str = Form(""),
playlist_name: str = Form(""),
user: dict = Depends(require_auth),
db=Depends(get_db),
):
# Importing (tag, prep, beets import, rescan) can take a while, so run it in
# the background and redirect immediately. Progress and the outcome show up
# under Settings then Jobs.
imported_by = user.get("email") or user.get("sub", "unknown")
record = await manual_import.import_track(
db, source or None, playlist_name or None, imported_by
)
return RedirectResponse(url=f"/import?run_id={record.id}", status_code=303)
background.add_task(_run_import_bg, source or None, playlist_name or None, imported_by)
return RedirectResponse(url="/import?started=1", status_code=303)
+10 -7
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
@@ -67,15 +67,18 @@ async def runs_table_partial(request: Request, user: dict = Depends(require_auth
@router.post("/{job_key:path}/run")
async def run_now(job_key: str, user: dict = Depends(require_auth)):
async def run_now(job_key: str, background: BackgroundTasks, user: dict = Depends(require_auth)):
scheduler = scheduler_service.get_scheduler()
if scheduler is None:
raise HTTPException(503, "scheduler not running")
try:
await scheduler_service.trigger_now(scheduler, job_key)
except ValueError as exc:
raise HTTPException(404, str(exc))
return RedirectResponse(url="/settings/jobs", status_code=303)
# Validate the job exists now (so a bad key still 404s), then run it in the
# background and redirect immediately. A playlist sync can take the better
# part of an hour; awaiting it here would hang the browser/reverse proxy.
# Progress shows up in the runs table below, which polls every few seconds.
if scheduler.get_job(job_key) is None:
raise HTTPException(404, f"no such registered job: {job_key}")
background.add_task(scheduler_service.trigger_now, scheduler, job_key)
return RedirectResponse(url="/settings/jobs?started=1", status_code=303)
@router.post("/{job_key:path}/toggle")