"""Internal endpoints — called by trusted services (watcher) on the Docker network.""" import logging from pathlib import Path from fastapi import APIRouter, Depends, Header, HTTPException, status from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from rehearsalhub.config import get_settings from rehearsalhub.db.engine import get_session from rehearsalhub.db.models import BandMember, Member from rehearsalhub.repositories.audio_version import AudioVersionRepository from rehearsalhub.repositories.band import BandRepository from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository from rehearsalhub.repositories.song import SongRepository from rehearsalhub.schemas.audio_version import AudioVersionCreate from rehearsalhub.services.session import extract_session_folder, parse_rehearsal_date from rehearsalhub.services.song import SongService from rehearsalhub.storage.nextcloud import NextcloudClient log = logging.getLogger(__name__) router = APIRouter(prefix="/internal", tags=["internal"]) async def _verify_internal_secret(x_internal_token: str | None = Header(None)) -> None: """Verify the shared secret sent by internal services.""" settings = get_settings() if x_internal_token != settings.internal_secret: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") 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), _: None = Depends(_verify_internal_secret), ): """ 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"} band_repo = BandRepository(session) # Try slug-based lookup first (standard bands/{slug}/ layout) parts = path.split("/") band = None if len(parts) >= 3 and parts[0] == "bands": band = await band_repo.get_by_slug(parts[1]) # Fall back to prefix match for bands with custom nc_folder_path if band is None: band = await band_repo.get_by_nc_folder_prefix(path) if band is None: log.info("nc-upload: no band found for path '%s' — skipping", path) 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 # If the file sits directly inside a dated session folder, give it a unique # virtual folder so it becomes its own song (not merged with other takes). session_folder_path = extract_session_folder(path) if session_folder_path and session_folder_path.rstrip("/") == nc_folder.rstrip("/"): nc_folder = nc_folder + title + "/" 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"} # Resolve or create rehearsal session from YYMMDD folder segment session_repo = RehearsalSessionRepository(session) rehearsal_date = parse_rehearsal_date(path) rehearsal_session_id = None if rehearsal_date: rehearsal_session = await session_repo.get_or_create(band.id, rehearsal_date, nc_folder) rehearsal_session_id = rehearsal_session.id log.debug("nc-upload: linked to session %s (%s)", rehearsal_session_id, rehearsal_date) 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, session_id=rehearsal_session_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) elif rehearsal_session_id and song.session_id is None: song = await song_repo.update(song, session_id=rehearsal_session_id) # Use first member of the band as uploader (best-effort for watcher uploads) result = await session.execute( select(BandMember.member_id).where(BandMember.band_id == band.id).limit(1) ) uploader_id = result.scalar_one_or_none() # Get the uploader's storage credentials storage = None if uploader_id: uploader_result = await session.execute( select(Member).where(Member.id == uploader_id).limit(1) # type: ignore[arg-type] ) uploader = uploader_result.scalar_one_or_none() storage = NextcloudClient.for_member(uploader) if uploader else None song_svc = SongService(session, storage=storage) 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)}