Files
rehearshalhub/api/src/rehearsalhub/routers/internal.py
Steffen Schuhmann b882c9ea6d feat(api): auto-link rehearsal sessions on watcher upload and nc-scan
parse_rehearsal_date() extracts YYMMDD / YYYYMMDD from the file path
and get_or_create() a RehearsalSession. Both the watcher nc-upload
endpoint and the nc-scan endpoint now set song.session_id when a
dated folder is detected. Existing songs without a session_id are
back-filled on the next import of the same folder.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 13:41:01 +02:00

121 lines
4.7 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.rehearsal_session import RehearsalSessionRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.services.session import parse_rehearsal_date
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"}
# 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)
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)}