feat: incremental SSE scan, recursive NC traversal, custom folder support
- nc_scan.py: recursive collect_audio_files (fixes depth-1 bug); scan_band_folder yields ndjson events (progress/song/session/skipped/done) for streaming - songs.py: replace old flat scan with scan_band_folder; add GET nc-scan/stream endpoint using _member_from_request so ?token= auth works for fetch-based SSE - BandPage.tsx: scan button now consumes ndjson stream via fetch+ReadableStream; sessions/unattributed invalidated as each song/session event arrives - session.py: add extract_session_folder() for YYMMDD path extraction - rehearsal_session.py: get_or_create uses begin_nested() savepoint to handle races - band.py: add get_by_nc_folder_prefix() for custom nc_folder_path band lookup - internal.py: nc-upload falls back to prefix match when slug lookup fails - event_loop.py: remove hardcoded bands/ guard; let internal API handle filtering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,8 @@ export function BandPage() {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanProgress, setScanProgress] = useState<string | null>(null);
|
||||
const [scanMsg, setScanMsg] = useState<string | null>(null);
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [editingFolder, setEditingFolder] = useState(false);
|
||||
@@ -123,22 +125,65 @@ export function BandPage() {
|
||||
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create song"),
|
||||
});
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: () => api.post<NcScanResult>(`/bands/${bandId}/nc-scan`, {}),
|
||||
onSuccess: (result) => {
|
||||
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
|
||||
qc.invalidateQueries({ queryKey: ["songs-unattributed", bandId] });
|
||||
if (result.imported > 0) {
|
||||
setScanMsg(`Imported ${result.imported} new song${result.imported !== 1 ? "s" : ""} from ${result.folder} (${result.skipped} already registered).`);
|
||||
} else if (result.files_found === 0) {
|
||||
setScanMsg(`No audio files found in ${result.folder}.`);
|
||||
} else {
|
||||
setScanMsg(`All ${result.files_found} file${result.files_found !== 1 ? "s" : ""} in ${result.folder} already registered.`);
|
||||
async function startScan() {
|
||||
if (scanning || !bandId) return;
|
||||
setScanning(true);
|
||||
setScanMsg(null);
|
||||
setScanProgress("Starting scan…");
|
||||
|
||||
const token = localStorage.getItem("rh_token");
|
||||
const url = `/api/v1/bands/${bandId}/nc-scan/stream${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok || !resp.body) {
|
||||
const text = await resp.text().catch(() => resp.statusText);
|
||||
throw new Error(text || `HTTP ${resp.status}`);
|
||||
}
|
||||
setTimeout(() => setScanMsg(null), 6000);
|
||||
},
|
||||
onError: (err) => setScanMsg(err instanceof Error ? err.message : "Scan failed"),
|
||||
});
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let event: Record<string, unknown>;
|
||||
try { event = JSON.parse(line); } catch { continue; }
|
||||
|
||||
if (event.type === "progress") {
|
||||
setScanProgress(event.message as string);
|
||||
} else if (event.type === "song" || event.type === "session") {
|
||||
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
|
||||
qc.invalidateQueries({ queryKey: ["songs-unattributed", bandId] });
|
||||
} else if (event.type === "done") {
|
||||
const s = event.stats as { found: number; imported: number; skipped: number };
|
||||
if (s.imported > 0) {
|
||||
setScanMsg(`Imported ${s.imported} new song${s.imported !== 1 ? "s" : ""} (${s.skipped} already registered).`);
|
||||
} else if (s.found === 0) {
|
||||
setScanMsg("No audio files found.");
|
||||
} else {
|
||||
setScanMsg(`All ${s.found} file${s.found !== 1 ? "s" : ""} already registered.`);
|
||||
}
|
||||
setTimeout(() => setScanMsg(null), 6000);
|
||||
} else if (event.type === "error") {
|
||||
setScanMsg(`Scan error: ${event.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setScanMsg(err instanceof Error ? err.message : "Scan failed");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
setScanProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: () => api.post<BandInvite>(`/bands/${bandId}/invites`, {}),
|
||||
@@ -306,11 +351,11 @@ export function BandPage() {
|
||||
<h2 style={{ color: "var(--text)", margin: 0, fontSize: 16 }}>Recordings</h2>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
onClick={() => scanMutation.mutate()}
|
||||
disabled={scanMutation.isPending}
|
||||
onClick={startScan}
|
||||
disabled={scanning}
|
||||
style={{ background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--teal)", cursor: "pointer", padding: "6px 14px", fontSize: 12 }}
|
||||
>
|
||||
{scanMutation.isPending ? "Scanning…" : "⟳ Scan Nextcloud"}
|
||||
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCreate(!showCreate); setError(null); }}
|
||||
@@ -321,6 +366,11 @@ export function BandPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scanning && scanProgress && (
|
||||
<div style={{ background: "var(--bg-subtle)", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", fontSize: 12, padding: "8px 14px", marginBottom: 8, fontFamily: "monospace" }}>
|
||||
{scanProgress}
|
||||
</div>
|
||||
)}
|
||||
{scanMsg && (
|
||||
<div style={{ background: "var(--teal-bg)", border: "1px solid var(--teal)", borderRadius: 6, color: "var(--teal)", fontSize: 12, padding: "8px 14px", marginBottom: 12 }}>
|
||||
{scanMsg}
|
||||
|
||||
Reference in New Issue
Block a user