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:
Steffen Schuhmann
2026-03-28 21:53:03 +01:00
commit f7be1b994d
139 changed files with 12743 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.orm import selectinload
import secrets
from datetime import datetime, timedelta, timezone
from rehearsalhub.db.models import Band, BandInvite, BandMember
from rehearsalhub.repositories.base import BaseRepository
class BandRepository(BaseRepository[Band]):
model = Band
async def get_by_slug(self, slug: str) -> Band | None:
stmt = select(Band).where(Band.slug == slug)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_with_members(self, band_id: uuid.UUID) -> Band | None:
stmt = (
select(Band)
.options(selectinload(Band.memberships).selectinload(BandMember.member))
.where(Band.id == band_id)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_member_role(self, band_id: uuid.UUID, member_id: uuid.UUID) -> str | None:
stmt = select(BandMember.role).where(
BandMember.band_id == band_id, BandMember.member_id == member_id
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def add_member(
self,
band_id: uuid.UUID,
member_id: uuid.UUID,
role: str = "member",
instrument: str | None = None,
) -> BandMember:
bm = BandMember(band_id=band_id, member_id=member_id, role=role, instrument=instrument)
self.session.add(bm)
await self.session.flush()
return bm
async def is_member(self, band_id: uuid.UUID, member_id: uuid.UUID) -> bool:
return await self.get_member_role(band_id, member_id) is not None
async def remove_member(self, band_id: uuid.UUID, member_id: uuid.UUID) -> None:
stmt = select(BandMember).where(
BandMember.band_id == band_id, BandMember.member_id == member_id
)
result = await self.session.execute(stmt)
bm = result.scalar_one_or_none()
if bm:
await self.session.delete(bm)
await self.session.flush()
async def create_invite(
self, band_id: uuid.UUID, created_by: uuid.UUID, role: str = "member", ttl_hours: int = 72
) -> BandInvite:
invite = BandInvite(
band_id=band_id,
token=secrets.token_urlsafe(32),
role=role,
created_by=created_by,
expires_at=datetime.now(timezone.utc) + timedelta(hours=ttl_hours),
)
self.session.add(invite)
await self.session.flush()
await self.session.refresh(invite)
return invite
async def get_invite_by_token(self, token: str) -> BandInvite | None:
stmt = select(BandInvite).where(BandInvite.token == token)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_for_member(self, member_id: uuid.UUID) -> list[Band]:
stmt = (
select(Band)
.join(BandMember, BandMember.band_id == Band.id)
.where(BandMember.member_id == member_id)
.order_by(Band.name)
)
result = await self.session.execute(stmt)
return list(result.scalars().all())