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:
@@ -51,6 +51,7 @@ class SongRepository(BaseRepository[Song]):
|
|||||||
bpm_min: float | None = None,
|
bpm_min: float | None = None,
|
||||||
bpm_max: float | None = None,
|
bpm_max: float | None = None,
|
||||||
session_id: uuid.UUID | None = None,
|
session_id: uuid.UUID | None = None,
|
||||||
|
unattributed: bool = False,
|
||||||
) -> list[Song]:
|
) -> list[Song]:
|
||||||
from sqlalchemy import cast, func
|
from sqlalchemy import cast, func
|
||||||
from sqlalchemy.dialects.postgresql import ARRAY
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
@@ -75,6 +76,8 @@ class SongRepository(BaseRepository[Song]):
|
|||||||
stmt = stmt.where(Song.global_bpm <= bpm_max)
|
stmt = stmt.where(Song.global_bpm <= bpm_max)
|
||||||
if session_id is not None:
|
if session_id is not None:
|
||||||
stmt = stmt.where(Song.session_id == session_id)
|
stmt = stmt.where(Song.session_id == session_id)
|
||||||
|
if unattributed:
|
||||||
|
stmt = stmt.where(Song.session_id.is_(None))
|
||||||
|
|
||||||
result = await self.session.execute(stmt)
|
result = await self.session.execute(stmt)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ async def create_band(
|
|||||||
band = await svc.create_band(data, current_member.id, creator=current_member)
|
band = await svc.create_band(data, current_member.id, creator=current_member)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(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)
|
return BandRead.model_validate(band)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ async def search_songs(
|
|||||||
bpm_min: float | None = Query(None, ge=0),
|
bpm_min: float | None = Query(None, ge=0),
|
||||||
bpm_max: float | None = Query(None, ge=0),
|
bpm_max: float | None = Query(None, ge=0),
|
||||||
session_id: uuid.UUID | None = Query(None),
|
session_id: uuid.UUID | None = Query(None),
|
||||||
|
unattributed: bool = Query(False, description="Only songs with no rehearsal session"),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_member: Member = Depends(get_current_member),
|
current_member: Member = Depends(get_current_member),
|
||||||
):
|
):
|
||||||
@@ -78,6 +79,7 @@ async def search_songs(
|
|||||||
bpm_min=bpm_min,
|
bpm_min=bpm_min,
|
||||||
bpm_max=bpm_max,
|
bpm_max=bpm_max,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
unattributed=unattributed,
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
SongRead.model_validate(s, update={"version_count": len(s.versions)})
|
SongRead.model_validate(s, update={"version_count": len(s.versions)})
|
||||||
@@ -173,14 +175,20 @@ async def scan_nextcloud(
|
|||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
band_folder = band.nc_folder_path or f"bands/{band.slug}/"
|
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:
|
try:
|
||||||
items = await nc.list_folder(band_folder)
|
items = await nc.list_folder(band_folder)
|
||||||
except Exception as exc:
|
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.
|
# Collect (nc_file_path, nc_folder, song_title, rehearsal_label) tuples.
|
||||||
# nc_folder is the directory that groups versions of the same song.
|
# nc_folder is the directory that groups versions of the same song.
|
||||||
@@ -195,27 +203,39 @@ async def scan_nextcloud(
|
|||||||
try:
|
try:
|
||||||
sub_items = await nc.list_folder(rel)
|
sub_items = await nc.list_folder(rel)
|
||||||
except Exception as exc:
|
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
|
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]
|
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:
|
for sub in audio_files:
|
||||||
sub_rel = relative(sub.path)
|
sub_rel = relative(sub.path)
|
||||||
song_title = Path(sub_rel).stem
|
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) + "/"
|
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))
|
to_import.append((sub_rel, song_folder, song_title, rehearsal_label))
|
||||||
else:
|
else:
|
||||||
if Path(rel).suffix.lower() in AUDIO_EXTENSIONS:
|
ext = Path(rel).suffix.lower()
|
||||||
|
if ext in AUDIO_EXTENSIONS:
|
||||||
folder = str(Path(rel).parent) + "/"
|
folder = str(Path(rel).parent) + "/"
|
||||||
title = Path(rel).stem
|
title = Path(rel).stem
|
||||||
|
log.info(" queue for import (root-level): %s → title='%s'", rel, title)
|
||||||
to_import.append((rel, folder, title, None))
|
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)
|
song_repo = SongRepository(session)
|
||||||
from rehearsalhub.schemas.audio_version import AudioVersionCreate # noqa: PLC0415
|
from rehearsalhub.schemas.audio_version import AudioVersionCreate # noqa: PLC0415
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.schemas.band import BandCreate, BandReadWithMembers
|
||||||
from rehearsalhub.storage.nextcloud import NextcloudClient
|
from rehearsalhub.storage.nextcloud import NextcloudClient
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BandService:
|
class BandService:
|
||||||
def __init__(self, session: AsyncSession, storage: NextcloudClient | None = None) -> None:
|
def __init__(self, session: AsyncSession, storage: NextcloudClient | None = None) -> None:
|
||||||
self._repo = BandRepository(session)
|
self._repo = BandRepository(session)
|
||||||
self._storage = storage or NextcloudClient()
|
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):
|
if await self._repo.get_by_slug(data.slug):
|
||||||
raise ValueError(f"Slug already taken: {data.slug}")
|
raise ValueError(f"Slug already taken: {data.slug}")
|
||||||
|
|
||||||
nc_folder = (data.nc_base_path or f"bands/{data.slug}/").strip("/") + "/"
|
nc_folder = (data.nc_base_path or f"bands/{data.slug}/").strip("/") + "/"
|
||||||
storage = NextcloudClient.for_member(creator) if creator else self._storage
|
storage = NextcloudClient.for_member(creator) if creator else self._storage
|
||||||
try:
|
|
||||||
await storage.create_folder(nc_folder)
|
if data.nc_base_path:
|
||||||
except Exception:
|
# User explicitly specified a folder — verify it actually exists in NC.
|
||||||
pass # NC might not be reachable during tests; folder creation is best-effort
|
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(
|
band = await self._repo.create(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
@@ -33,6 +54,7 @@ class BandService:
|
|||||||
nc_folder_path=nc_folder,
|
nc_folder_path=nc_folder,
|
||||||
)
|
)
|
||||||
await self._repo.add_member(band.id, creator_id, role="admin")
|
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
|
return band
|
||||||
|
|
||||||
async def get_band_with_members(self, band_id: uuid.UUID) -> Band | None:
|
async def get_band_with_members(self, band_id: uuid.UUID) -> Band | None:
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ async def poll_once(nc_client: NextcloudWatcherClient, settings: WatcherSettings
|
|||||||
|
|
||||||
activities = await nc_client.get_activities(since_id=_last_activity_id)
|
activities = await nc_client.get_activities(since_id=_last_activity_id)
|
||||||
if not activities:
|
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
|
return
|
||||||
|
|
||||||
log.info("Received %d activities (since id=%d)", len(activities), _last_activity_id)
|
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", "")
|
subject = activity.get("subject", "")
|
||||||
raw_path = extract_nc_file_path(activity)
|
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=%d type=%r subject=%r raw_path=%r",
|
||||||
activity_id, activity_type, subject, raw_path,
|
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:
|
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
|
continue
|
||||||
|
|
||||||
nc_path = normalize_nc_path(raw_path, nc_client.username)
|
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
|
# Only care about audio files — skip everything else immediately
|
||||||
if not is_audio_file(nc_path, settings.audio_extensions):
|
if not is_audio_file(nc_path, settings.audio_extensions):
|
||||||
log.debug(
|
log.info(
|
||||||
"Skipping activity %d: '%s' is not an audio file (ext: %s)",
|
" → skip: not an audio file (ext=%s)",
|
||||||
activity_id, nc_path, Path(nc_path).suffix.lower(),
|
Path(nc_path).suffix.lower() or "<none>",
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not is_band_audio_path(nc_path):
|
if not is_band_audio_path(nc_path):
|
||||||
log.debug(
|
log.info(" → skip: path not inside a bands/ folder")
|
||||||
"Skipping activity %d: '%s' is not inside a band folder",
|
|
||||||
activity_id, nc_path,
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if activity_type not in _UPLOAD_TYPES and subject not in _UPLOAD_SUBJECTS:
|
if activity_type not in _UPLOAD_TYPES and subject not in _UPLOAD_SUBJECTS:
|
||||||
log.debug(
|
log.info(
|
||||||
"Skipping activity %d: type=%r subject=%r — not a file upload event",
|
" → skip: type=%r subject=%r is not a file upload event",
|
||||||
activity_id, activity_type, subject,
|
activity_type, subject,
|
||||||
)
|
)
|
||||||
continue
|
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)
|
etag = await nc_client.get_file_etag(nc_path)
|
||||||
success = await register_version_with_api(nc_path, etag, settings.api_url)
|
success = await register_version_with_api(nc_path, etag, settings.api_url)
|
||||||
if not success:
|
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)
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ export function BandPage() {
|
|||||||
enabled: !!bandId && tab === "dates",
|
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({
|
const { data: members } = useQuery({
|
||||||
queryKey: ["members", bandId],
|
queryKey: ["members", bandId],
|
||||||
queryFn: () => api.get<BandMember[]>(`/bands/${bandId}/members`),
|
queryFn: () => api.get<BandMember[]>(`/bands/${bandId}/members`),
|
||||||
@@ -121,6 +127,7 @@ export function BandPage() {
|
|||||||
mutationFn: () => api.post<NcScanResult>(`/bands/${bandId}/nc-scan`, {}),
|
mutationFn: () => api.post<NcScanResult>(`/bands/${bandId}/nc-scan`, {}),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
|
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["songs-unattributed", bandId] });
|
||||||
if (result.imported > 0) {
|
if (result.imported > 0) {
|
||||||
setScanMsg(`Imported ${result.imported} new song${result.imported !== 1 ? "s" : ""} from ${result.folder} (${result.skipped} already registered).`);
|
setScanMsg(`Imported ${result.imported} new song${result.imported !== 1 ? "s" : ""} from ${result.folder} (${result.skipped} already registered).`);
|
||||||
} else if (result.files_found === 0) {
|
} else if (result.files_found === 0) {
|
||||||
@@ -404,11 +411,53 @@ export function BandPage() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{sessions?.length === 0 && (
|
{sessions?.length === 0 && !unattributedSongs?.length && (
|
||||||
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
<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>.
|
No sessions yet. Scan Nextcloud to import from <code style={{ color: "var(--teal)" }}>{band.nc_folder_path ?? `bands/${band.slug}/`}</code>.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user