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:
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -13,13 +13,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if request.query_params.get('skipped') %}
|
||||
<div class="notice warning">One or both passes didn't run — something else was using the pipeline at that moment (check <a href="/settings/jobs">Jobs</a> for what). Try again in a moment.</div>
|
||||
{% if request.query_params.get('started') %}
|
||||
<div class="notice">Scan started. It runs in the background; reload this page in a bit to see new candidates, or watch its progress under <a href="/settings/jobs">Jobs</a>.</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Pending candidates</h2>
|
||||
<p class="muted" style="margin-top:-0.5rem;">Paths are relative to the library root. Hover a name for the full path.</p>
|
||||
<form id="bulk-delete-form" method="post" action="/dedup/confirm"></form>
|
||||
<form id="bulk-delete-form" method="post" action="/dedup/confirm"
|
||||
onsubmit="return confirm('Permanently delete every selected file from disk? This cannot be undone.')"></form>
|
||||
<div class="table-wrap scroll">
|
||||
<table class="dedup-table">
|
||||
<colgroup>
|
||||
@@ -39,7 +40,8 @@
|
||||
<td class="mono path-cell" title="{{ c.delete_path }}">{{ c.delete_display }}</td>
|
||||
<td class="muted">{{ (c.delete_size_bytes / 1024 / 1024) | round(1) if c.delete_size_bytes else '?' }} MB</td>
|
||||
<td class="actions-cell">
|
||||
<form method="post" action="/dedup/confirm" class="inline">
|
||||
<form method="post" action="/dedup/confirm" class="inline"
|
||||
onsubmit="return confirm('Permanently delete this file from disk?\n{{ c.delete_display }}')">
|
||||
<input type="hidden" name="candidate_id" value="{{ c.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if request.query_params.get('skipped') %}
|
||||
<div class="notice warning">Didn't run — something else was using the pipeline at that moment (check <a href="/settings/jobs">Jobs</a> for what). Nothing changed; try again in a moment.</div>
|
||||
{% if request.query_params.get('started') %}
|
||||
<div class="notice">Started in the background. Reload this page in a bit to see the result below, or watch its progress under <a href="/settings/jobs">Jobs</a>. If the pipeline is busy it will show as skipped there.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="panel" style="max-width:560px;">
|
||||
@@ -18,7 +18,8 @@
|
||||
<form method="post" action="/genres/scan" class="inline">
|
||||
<button type="submit" class="btn btn-ghost">Preview changes</button>
|
||||
</form>
|
||||
<form method="post" action="/genres/apply" class="inline">
|
||||
<form method="post" action="/genres/apply" class="inline"
|
||||
onsubmit="return confirm('Overwrite genres across your whole library with what Spotify reports? Locked artists are left alone. Preview first if unsure.')">
|
||||
<button type="submit" class="btn btn-primary">Fix genres now</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if request.query_params.get('run_id') %}
|
||||
<div class="notice success">Import started (run #{{ request.query_params.get('run_id') }}). Check <a href="/settings/jobs">Jobs</a> for progress.</div>
|
||||
{% if request.query_params.get('started') %}
|
||||
<div class="notice success">Import started in the background. Check <a href="/settings/jobs">Jobs</a> for progress and the result. If the pipeline is busy it will show as skipped there.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="panel dropzone-panel" x-data="{ over: false }">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{% extends "settings/_layout.html" %}
|
||||
{% block settings_title %}Jobs{% endblock %}
|
||||
{% block settings_content %}
|
||||
{% if request.query_params.get('started') %}
|
||||
<div class="notice">Started in the background. Watch the runs table below (it refreshes on its own). If the pipeline is already busy, this run shows as skipped.</div>
|
||||
{% endif %}
|
||||
<h2 style="margin-top:0;">Maintenance jobs</h2>
|
||||
<div class="table-wrap scroll">
|
||||
<table>
|
||||
|
||||
Reference in New Issue
Block a user