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:

View File

@@ -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 "<none>",
)
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)

View File

@@ -86,6 +86,12 @@ export function BandPage() {
enabled: !!bandId && tab === "dates",
});
const { data: unattributedSongs } = useQuery({
queryKey: ["songs-unattributed", bandId],
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?unattributed=true`),
enabled: !!bandId && tab === "dates",
});
const { data: members } = useQuery({
queryKey: ["members", bandId],
queryFn: () => api.get<BandMember[]>(`/bands/${bandId}/members`),
@@ -121,6 +127,7 @@ export function BandPage() {
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) {
@@ -404,11 +411,53 @@ export function BandPage() {
</span>
</Link>
))}
{sessions?.length === 0 && (
{sessions?.length === 0 && !unattributedSongs?.length && (
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
No sessions yet. Scan Nextcloud to import from <code style={{ color: "var(--teal)" }}>{band.nc_folder_path ?? `bands/${band.slug}/`}</code>.
</p>
)}
{/* Songs not linked to any dated session */}
{!!unattributedSongs?.length && (
<div style={{ marginTop: sessions?.length ? 24 : 0 }}>
<div style={{ color: "var(--text-muted)", fontSize: 11, fontFamily: "monospace", letterSpacing: 1, marginBottom: 8 }}>
UNATTRIBUTED RECORDINGS
</div>
<div style={{ display: "grid", gap: 6 }}>
{unattributedSongs.map((song) => (
<Link
key={song.id}
to={`/bands/${bandId}/songs/${song.id}`}
style={{
background: "var(--bg-inset)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "14px 18px",
textDecoration: "none",
color: "var(--text)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500, marginBottom: 4 }}>{song.title}</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{song.tags.map((t) => (
<span key={t} style={{ background: "var(--teal-bg)", color: "var(--teal)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>{t}</span>
))}
</div>
</div>
<span style={{ color: "var(--text-muted)", fontSize: 12, whiteSpace: "nowrap" }}>
<span style={{ background: "var(--bg-subtle)", borderRadius: 4, padding: "2px 6px", marginRight: 8, fontFamily: "monospace", fontSize: 10 }}>{song.status}</span>
{song.version_count} version{song.version_count !== 1 ? "s" : ""}
</span>
</Link>
))}
</div>
</div>
)}
</div>
)}