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:
Steffen Schuhmann
2026-03-29 15:09:42 +02:00
parent dc6dd9dcfd
commit 7cad3e544a
8 changed files with 393 additions and 204 deletions

View File

@@ -81,6 +81,19 @@ class BandRepository(BaseRepository[Band]):
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_by_nc_folder_prefix(self, path: str) -> Band | None:
"""Return the band whose nc_folder_path is a prefix of path."""
stmt = select(Band).where(Band.nc_folder_path.is_not(None))
result = await self.session.execute(stmt)
bands = result.scalars().all()
# Longest match wins (most specific prefix)
best: Band | None = None
for band in bands:
folder = band.nc_folder_path # type: ignore[union-attr]
if path.startswith(folder) and (best is None or len(folder) > len(best.nc_folder_path)): # type: ignore[arg-type]
best = band
return best
async def list_for_member(self, member_id: uuid.UUID) -> list[Band]:
stmt = (
select(Band)

View File

@@ -40,11 +40,19 @@ class RehearsalSessionRepository(BaseRepository[RehearsalSession]):
existing = await self.get_by_band_and_date(band_id, session_date)
if existing is not None:
return existing
return await self.create(
band_id=band_id,
date=datetime(session_date.year, session_date.month, session_date.day),
nc_folder_path=nc_folder_path,
)
try:
async with self.session.begin_nested():
return await self.create(
band_id=band_id,
date=datetime(session_date.year, session_date.month, session_date.day),
nc_folder_path=nc_folder_path,
)
except Exception:
# Another request raced us — fetch the row that now exists
existing = await self.get_by_band_and_date(band_id, session_date)
if existing is not None:
return existing
raise
async def list_for_band(self, band_id: uuid.UUID) -> list[tuple[RehearsalSession, int]]:
"""Return (session, recording_count) tuples, newest date first."""