fix(import): decouple scan from HTTP connection, prevent duplicate imports

- Add scan_manager: background asyncio task + Redis event store so scans
  survive UI navigation; SSE stream reads from Redis and is reconnectable
- Replace SSE-tied scan endpoint with POST /nc-scan/start + GET /nc-scan/stream
- Fix frontend: AbortController + useEffect cleanup cancels stream on unmount
  without stopping the server-side scan
- Add unique constraint on audio_versions.nc_file_path (migration 0009) to
  prevent duplicate imports from concurrent scans; handle IntegrityError
  gracefully in nc_scan with rollback + skip
- Fix API health check: use plain python instead of uv (not in dev image)
- Optimize Taskfile: fix duplicate dev:restart, add dev:fresh/dev:rebuild/
  dev:status, migrate uses run --rm, check includes typecheck

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-04-12 22:35:55 +02:00
parent b2d6b4d113
commit 15bc51603b
8 changed files with 349 additions and 147 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client";
@@ -278,6 +278,11 @@ function StorageSection({ bandId, band, amAdmin }: { bandId: string; band: Band;
const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null);
const scanAbortRef = useRef<AbortController | null>(null);
// Cancel the stream reader on unmount so the component doesn't update after
// the user navigates away. The background scan on the server keeps running.
useEffect(() => () => { scanAbortRef.current?.abort(); }, []);
const { data: storageConfigs, isLoading: storageLoading } = useQuery({
queryKey: ["storage", bandId],
@@ -310,8 +315,33 @@ function StorageSection({ bandId, band, amAdmin }: { bandId: string; band: Band;
async function startScan() {
if (scanning) return;
setScanning(true); setScanMsg(null); setScanProgress("Starting scan…");
// Start the background scan on the server
try {
const resp = await fetch(`/api/v1/bands/${bandId}/nc-scan/stream`, { credentials: "include" });
const startResp = await fetch(`/api/v1/bands/${bandId}/nc-scan/start`, {
method: "POST",
credentials: "include",
});
if (startResp.status === 409) {
setScanProgress("Scan already running…");
// Fall through to stream — a scan is already in progress
} else if (!startResp.ok) {
throw new Error(`HTTP ${startResp.status}`);
}
} catch (err) {
setScanMsg(err instanceof Error ? err.message : "Failed to start scan");
setScanning(false); setScanProgress(null);
return;
}
// Connect to the event stream. Aborting this does NOT stop the server scan.
const controller = new AbortController();
scanAbortRef.current = controller;
try {
const resp = await fetch(`/api/v1/bands/${bandId}/nc-scan/stream`, {
credentials: "include",
signal: controller.signal,
});
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
@@ -338,8 +368,12 @@ function StorageSection({ bandId, band, amAdmin }: { bandId: string; band: Band;
} else if (ev.type === "error") setScanMsg(`Scan error: ${ev.message}`);
}
}
} catch (err) { setScanMsg(err instanceof Error ? err.message : "Scan failed"); }
finally { setScanning(false); setScanProgress(null); }
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return; // clean unmount, scan still running
setScanMsg(err instanceof Error ? err.message : "Scan failed");
} finally {
setScanning(false); setScanProgress(null);
}
}
const canConnect = ncUrl.trim() && ncUsername.trim() && ncPassword;