Files
rehearshalhub/api/src/rehearsalhub/routers/internal.py
Steffen Schuhmann 47bc802775 fix: robust NC activity filter, title extraction, scan result detail
Watcher:
- Accept both NC 22+ (type="file_created") and older NC (subject="created_self")
  so the upload filter works across all Nextcloud versions
- Add .opus to audio_extensions
- Fix tests: set nc.username on mocks, use realistic activity dicts with type field
- Add tests for old NC style, non-band path filter, normalize_nc_path, cursor advance

API:
- Fix internal.py title extraction: always use filename stem (was using
  parts[-2] for >3-part paths, which gave folder name instead of song title)
- nc-scan now returns NcScanResult with folder, files_found, imported, skipped counts
  instead of bare song list — gives the UI actionable feedback

Web:
- Show rich scan result message: folder scanned, count imported, count already registered

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:26:58 +02:00

107 lines
3.9 KiB
Python

"""Internal endpoints — called by trusted services (watcher) on the Docker network."""
import logging
from pathlib import Path
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session
from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.services.song import SongService
log = logging.getLogger(__name__)
router = APIRouter(prefix="/internal", tags=["internal"])
AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus"}
class NcUploadEvent(BaseModel):
nc_file_path: str
nc_file_etag: str | None = None
@router.post("/nc-upload", status_code=200)
async def nc_upload(
event: NcUploadEvent,
session: AsyncSession = Depends(get_session),
):
"""
Called by nc-watcher when a new audio file is detected in Nextcloud.
Parses the path to find/create the band+song and registers a version.
Expected path format: bands/{slug}/[songs/]{folder}/filename.ext
"""
path = event.nc_file_path.lstrip("/")
if Path(path).suffix.lower() not in AUDIO_EXTENSIONS:
return {"status": "skipped", "reason": "not an audio file"}
parts = path.split("/")
if len(parts) < 3 or parts[0] != "bands":
return {"status": "skipped", "reason": "path not under bands/"}
band_slug = parts[1]
band_repo = BandRepository(session)
band = await band_repo.get_by_slug(band_slug)
if band is None:
log.warning("nc-upload: band slug '%s' not found in DB", band_slug)
return {"status": "skipped", "reason": "band not found"}
# Determine song title and folder from path.
# The title is always the filename stem (e.g. "take1" from "take1.wav").
# The nc_folder groups all versions of the same recording (the parent directory).
#
# Examples:
# bands/my-band/take1.wav → folder=bands/my-band/, title=take1
# bands/my-band/231015/take1.wav → folder=bands/my-band/231015/, title=take1
# bands/my-band/songs/groove/take1.wav → folder=bands/my-band/songs/groove/, title=take1
parent = str(Path(path).parent)
nc_folder = parent.rstrip("/") + "/"
title = Path(path).stem
version_repo = AudioVersionRepository(session)
if event.nc_file_etag and await version_repo.get_by_etag(event.nc_file_etag):
return {"status": "skipped", "reason": "version already registered"}
song_repo = SongRepository(session)
song = await song_repo.get_by_nc_folder_path(nc_folder)
if song is None:
song = await song_repo.get_by_title_and_band(band.id, title)
if song is None:
song = await song_repo.create(
band_id=band.id,
title=title,
status="jam",
notes=None,
nc_folder_path=nc_folder,
created_by=None,
)
log.info("nc-upload: created song '%s' for band '%s'", title, band_slug)
# Use first member of the band as uploader (best-effort for watcher uploads)
from sqlalchemy import select
from rehearsalhub.db.models import BandMember
result = await session.execute(
select(BandMember.member_id).where(BandMember.band_id == band.id).limit(1)
)
uploader_id = result.scalar_one_or_none()
song_svc = SongService(session)
version = await song_svc.register_version(
song.id,
AudioVersionCreate(
nc_file_path=path,
nc_file_etag=event.nc_file_etag,
format=Path(path).suffix.lstrip(".").lower(),
),
uploader_id,
)
log.info("nc-upload: registered version %s for song '%s'", version.id, song.title)
return {"status": "ok", "version_id": str(version.id), "song_id": str(song.id)}