From dc6dd9dcfd1d81c0e13ad81aae29af65ab2a31cf Mon Sep 17 00:00:00 2001 From: Steffen Schuhmann Date: Sun, 29 Mar 2026 14:11:07 +0200 Subject: [PATCH] fix: scan visibility, NC folder validation, watcher logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/src/rehearsalhub/repositories/song.py | 3 ++ api/src/rehearsalhub/routers/bands.py | 2 + api/src/rehearsalhub/routers/songs.py | 40 +++++++++++++----- api/src/rehearsalhub/services/band.py | 32 +++++++++++--- watcher/src/watcher/event_loop.py | 34 +++++++-------- web/src/pages/BandPage.tsx | 51 ++++++++++++++++++++++- 6 files changed, 128 insertions(+), 34 deletions(-) diff --git a/api/src/rehearsalhub/repositories/song.py b/api/src/rehearsalhub/repositories/song.py index 1f79693..962d710 100644 --- a/api/src/rehearsalhub/repositories/song.py +++ b/api/src/rehearsalhub/repositories/song.py @@ -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()) diff --git a/api/src/rehearsalhub/routers/bands.py b/api/src/rehearsalhub/routers/bands.py index f727a84..1b8ba99 100644 --- a/api/src/rehearsalhub/routers/bands.py +++ b/api/src/rehearsalhub/routers/bands.py @@ -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) diff --git a/api/src/rehearsalhub/routers/songs.py b/api/src/rehearsalhub/routers/songs.py index 0f627f4..9d2a1e7 100644 --- a/api/src/rehearsalhub/routers/songs.py +++ b/api/src/rehearsalhub/routers/songs.py @@ -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 diff --git a/api/src/rehearsalhub/services/band.py b/api/src/rehearsalhub/services/band.py index d1a2315..ad08348 100644 --- a/api/src/rehearsalhub/services/band.py +++ b/api/src/rehearsalhub/services/band.py @@ -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: diff --git a/watcher/src/watcher/event_loop.py b/watcher/src/watcher/event_loop.py index abfad58..ae200ce 100644 --- a/watcher/src/watcher/event_loop.py +++ b/watcher/src/watcher/event_loop.py @@ -106,7 +106,7 @@ async def poll_once(nc_client: NextcloudWatcherClient, settings: WatcherSettings activities = await nc_client.get_activities(since_id=_last_activity_id) if not activities: - log.debug("No new activities since id=%d", _last_activity_id) + log.info("No new activities since id=%d", _last_activity_id) return log.info("Received %d activities (since id=%d)", len(activities), _last_activity_id) @@ -117,44 +117,42 @@ async def poll_once(nc_client: NextcloudWatcherClient, settings: WatcherSettings subject = activity.get("subject", "") raw_path = extract_nc_file_path(activity) - log.debug( + # Advance the cursor regardless of whether we act on this event + _last_activity_id = max(_last_activity_id, activity_id) + + log.info( "Activity id=%d type=%r subject=%r raw_path=%r", activity_id, activity_type, subject, raw_path, ) - # Advance the cursor regardless of whether we act on this event - _last_activity_id = max(_last_activity_id, activity_id) - if raw_path is None: - log.debug("Skipping activity %d: no file path in payload", activity_id) + log.info(" → skip: no file path in activity payload") continue nc_path = normalize_nc_path(raw_path, nc_client.username) + log.info(" → normalized path: %r", nc_path) # Only care about audio files — skip everything else immediately if not is_audio_file(nc_path, settings.audio_extensions): - log.debug( - "Skipping activity %d: '%s' is not an audio file (ext: %s)", - activity_id, nc_path, Path(nc_path).suffix.lower(), + log.info( + " → skip: not an audio file (ext=%s)", + Path(nc_path).suffix.lower() or "", ) continue if not is_band_audio_path(nc_path): - log.debug( - "Skipping activity %d: '%s' is not inside a band folder", - activity_id, nc_path, - ) + log.info(" → skip: path not inside a bands/ folder") continue if activity_type not in _UPLOAD_TYPES and subject not in _UPLOAD_SUBJECTS: - log.debug( - "Skipping activity %d: type=%r subject=%r — not a file upload event", - activity_id, activity_type, subject, + log.info( + " → skip: type=%r subject=%r is not a file upload event", + activity_type, subject, ) continue - log.info("Detected audio upload: %s (activity %d)", nc_path, activity_id) + log.info(" → MATCH — registering audio upload: %s", nc_path) etag = await nc_client.get_file_etag(nc_path) success = await register_version_with_api(nc_path, etag, settings.api_url) if not success: - log.warning("Failed to register upload for activity %d (%s)", activity_id, nc_path) + log.warning(" → FAILED to register upload for activity %d (%s)", activity_id, nc_path) diff --git a/web/src/pages/BandPage.tsx b/web/src/pages/BandPage.tsx index 493c850..1097a9c 100644 --- a/web/src/pages/BandPage.tsx +++ b/web/src/pages/BandPage.tsx @@ -86,6 +86,12 @@ export function BandPage() { enabled: !!bandId && tab === "dates", }); + const { data: unattributedSongs } = useQuery({ + queryKey: ["songs-unattributed", bandId], + queryFn: () => api.get(`/bands/${bandId}/songs/search?unattributed=true`), + enabled: !!bandId && tab === "dates", + }); + const { data: members } = useQuery({ queryKey: ["members", bandId], queryFn: () => api.get(`/bands/${bandId}/members`), @@ -121,6 +127,7 @@ export function BandPage() { mutationFn: () => api.post(`/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) { @@ -404,11 +411,53 @@ export function BandPage() { ))} - {sessions?.length === 0 && ( + {sessions?.length === 0 && !unattributedSongs?.length && (

No sessions yet. Scan Nextcloud to import from {band.nc_folder_path ?? `bands/${band.slug}/`}.

)} + + {/* Songs not linked to any dated session */} + {!!unattributedSongs?.length && ( +
+
+ UNATTRIBUTED RECORDINGS +
+
+ {unattributedSongs.map((song) => ( + +
+
{song.title}
+
+ {song.tags.map((t) => ( + {t} + ))} +
+
+ + {song.status} + {song.version_count} version{song.version_count !== 1 ? "s" : ""} + + + ))} +
+
+ )} )}