Initial commit: RehearsalHub POC
Full-stack self-hosted band rehearsal platform: Backend (FastAPI + SQLAlchemy 2.0 async): - Auth with JWT (register, login, /me, settings) - Band management with Nextcloud folder integration - Song management with audio version tracking - Nextcloud scan to auto-import audio files - Band membership with link-based invite system - Song comments - Audio analysis worker (BPM, key, loudness, waveform) - Nextcloud activity watcher for auto-import - WebSocket support for real-time annotation updates - Alembic migrations (0001–0003) - Repository pattern, Ruff + mypy configured Frontend (React 18 + Vite + TypeScript strict): - Login/register page with post-login redirect - Home page with band list and creation form - Band page with member panel, invite link, song list, NC scan - Song page with waveform player, annotations, comment thread - Settings page for per-user Nextcloud credentials - Invite acceptance page (/invite/:token) - ESLint v9 flat config + TypeScript strict mode Infrastructure: - Docker Compose: PostgreSQL, Redis, API, worker, watcher, nginx - nginx reverse proxy for static files + /api/ proxy - make check runs all linters before docker compose build Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
101
api/src/rehearsalhub/routers/internal.py
Normal file
101
api/src/rehearsalhub/routers/internal.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""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 remaining path segments
|
||||
# e.g. bands/my-band/songs/session1/rec.mp3 → folder=bands/my-band/songs/session1/, title=session1
|
||||
# e.g. bands/my-band/rec.mp3 → folder=bands/my-band/, title=rec
|
||||
parent = str(Path(path).parent)
|
||||
nc_folder = parent.rstrip("/") + "/"
|
||||
title = Path(path).stem if len(parts) == 3 else parts[-2]
|
||||
|
||||
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)}
|
||||
Reference in New Issue
Block a user