fix: scan visibility, NC folder validation, watcher logging

- nc-scan: detailed INFO logging of every path found, subfolder
  contents and skip reasons; 502 now includes the exact folder and
  error so user sees a real message instead of a blank result
- band creation: if nc_base_path is explicitly given, verify the
  folder exists in Nextcloud before saving — returns 422 with a
  clear message to the user; auto-generated paths still do MKCOL
- songs search: add ?unattributed=true to return songs with no
  session_id (files not in a YYMMDD folder)
- BandPage: show "Unattributed Recordings" section below sessions
  so scanned files without a dated folder always appear
- watcher event_loop: promote all per-activity log lines from DEBUG
  to INFO so they're visible in default Docker Compose log output;
  log normalized path and skip reason for every activity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Steffen Schuhmann
2026-03-29 14:11:07 +02:00
parent 25502458d0
commit dc6dd9dcfd
6 changed files with 128 additions and 34 deletions

View File

@@ -51,6 +51,7 @@ class SongRepository(BaseRepository[Song]):
bpm_min: float | None = None,
bpm_max: float | None = None,
session_id: uuid.UUID | None = None,
unattributed: bool = False,
) -> list[Song]:
from sqlalchemy import cast, func
from sqlalchemy.dialects.postgresql import ARRAY
@@ -75,6 +76,8 @@ class SongRepository(BaseRepository[Song]):
stmt = stmt.where(Song.global_bpm <= bpm_max)
if session_id is not None:
stmt = stmt.where(Song.session_id == session_id)
if unattributed:
stmt = stmt.where(Song.session_id.is_(None))
result = await self.session.execute(stmt)
return list(result.scalars().all())

View File

@@ -34,6 +34,8 @@ async def create_band(
band = await svc.create_band(data, current_member.id, creator=current_member)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
except LookupError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
return BandRead.model_validate(band)

View File

@@ -60,6 +60,7 @@ async def search_songs(
bpm_min: float | None = Query(None, ge=0),
bpm_max: float | None = Query(None, ge=0),
session_id: uuid.UUID | None = Query(None),
unattributed: bool = Query(False, description="Only songs with no rehearsal session"),
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
@@ -78,6 +79,7 @@ async def search_songs(
bpm_min=bpm_min,
bpm_max=bpm_max,
session_id=session_id,
unattributed=unattributed,
)
return [
SongRead.model_validate(s, update={"version_count": len(s.versions)})
@@ -173,14 +175,20 @@ async def scan_nextcloud(
skipped_count = 0
band_folder = band.nc_folder_path or f"bands/{band.slug}/"
log.info("Starting NC scan for band '%s' in folder '%s'", band.slug, band_folder)
log.info("NC scan START — band='%s' folder='%s' nc_user='%s'", band.slug, band_folder, nc._auth[0])
try:
items = await nc.list_folder(band_folder)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Nextcloud unreachable: {exc}")
log.error("NC scan FAILED — could not list '%s': %s", band_folder, exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Cannot read Nextcloud folder '{band_folder}': {exc}",
)
log.info("Found %d top-level entries in '%s'", len(items), band_folder)
log.info("NC scan — found %d top-level entries in '%s'", len(items), band_folder)
for item in items:
log.info(" entry href=%s → rel=%s", item.path, relative(item.path))
# Collect (nc_file_path, nc_folder, song_title, rehearsal_label) tuples.
# nc_folder is the directory that groups versions of the same song.
@@ -195,27 +203,39 @@ async def scan_nextcloud(
try:
sub_items = await nc.list_folder(rel)
except Exception as exc:
log.warning("Could not list subfolder '%s': %s", rel, exc)
log.warning("NC scan — could not list subfolder '%s': %s", rel, exc)
continue
all_sub = [relative(s.path) for s in sub_items]
audio_files = [s for s in sub_items if Path(relative(s.path)).suffix.lower() in AUDIO_EXTENSIONS]
log.info("Subfolder '%s': %d audio files found", dir_name, len(audio_files))
log.info(
"NC scan — subfolder '%s': %d entries total, %d audio files",
dir_name, len(all_sub), len(audio_files),
)
for s in sub_items:
sr = relative(s.path)
ext = Path(sr).suffix.lower()
if ext and ext not in AUDIO_EXTENSIONS:
log.info(" skip (not audio ext=%s): %s", ext, sr)
for sub in audio_files:
sub_rel = relative(sub.path)
song_title = Path(sub_rel).stem
# Each file in a rehearsal folder is its own song,
# grouped under its own sub-subfolder path for version tracking.
song_folder = str(Path(sub_rel).parent) + "/"
rehearsal_label = dir_name # e.g. "231015" or "2023-10-15"
rehearsal_label = dir_name
log.info(" queue for import: %s → title='%s' folder='%s'", sub_rel, song_title, song_folder)
to_import.append((sub_rel, song_folder, song_title, rehearsal_label))
else:
if Path(rel).suffix.lower() in AUDIO_EXTENSIONS:
ext = Path(rel).suffix.lower()
if ext in AUDIO_EXTENSIONS:
folder = str(Path(rel).parent) + "/"
title = Path(rel).stem
log.info(" queue for import (root-level): %s → title='%s'", rel, title)
to_import.append((rel, folder, title, None))
elif ext:
log.info(" skip root-level (not audio ext=%s): %s", ext, rel)
log.info("NC scan: %d audio files to evaluate for import", len(to_import))
log.info("NC scan %d audio files queued for import", len(to_import))
song_repo = SongRepository(session)
from rehearsalhub.schemas.audio_version import AudioVersionCreate # noqa: PLC0415

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,22 +10,42 @@ from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.schemas.band import BandCreate, BandReadWithMembers
from rehearsalhub.storage.nextcloud import NextcloudClient
log = logging.getLogger(__name__)
class BandService:
def __init__(self, session: AsyncSession, storage: NextcloudClient | None = None) -> None:
self._repo = BandRepository(session)
self._storage = storage or NextcloudClient()
async def create_band(self, data: BandCreate, creator_id: uuid.UUID, creator: object | None = None) -> Band:
async def create_band(
self,
data: BandCreate,
creator_id: uuid.UUID,
creator: object | None = None,
) -> Band:
if await self._repo.get_by_slug(data.slug):
raise ValueError(f"Slug already taken: {data.slug}")
nc_folder = (data.nc_base_path or f"bands/{data.slug}/").strip("/") + "/"
storage = NextcloudClient.for_member(creator) if creator else self._storage
try:
await storage.create_folder(nc_folder)
except Exception:
pass # NC might not be reachable during tests; folder creation is best-effort
if data.nc_base_path:
# User explicitly specified a folder — verify it actually exists in NC.
log.info("Checking NC folder existence: %s", nc_folder)
try:
await storage.get_file_metadata(nc_folder.rstrip("/"))
except Exception as exc:
log.warning("NC folder '%s' not accessible: %s", nc_folder, exc)
raise LookupError(f"Nextcloud folder '{nc_folder}' not found or not accessible")
else:
# Auto-generated path — create it (idempotent MKCOL).
log.info("Creating NC folder: %s", nc_folder)
try:
await storage.create_folder(nc_folder)
except Exception as exc:
# Not fatal — NC may be temporarily unreachable during dev/test.
log.warning("Could not create NC folder '%s': %s", nc_folder, exc)
band = await self._repo.create(
name=data.name,
@@ -33,6 +54,7 @@ class BandService:
nc_folder_path=nc_folder,
)
await self._repo.add_member(band.id, creator_id, role="admin")
log.info("Created band '%s' (slug=%s, nc_folder=%s)", data.name, data.slug, nc_folder)
return band
async def get_band_with_members(self, band_id: uuid.UUID) -> Band | None: