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,25 @@
from rehearsalhub.db.models import (
Annotation,
AudioVersion,
Band,
BandMember,
Base,
Job,
Member,
RangeAnalysis,
Reaction,
Song,
)
__all__ = [
"Base",
"Member",
"Band",
"BandMember",
"Song",
"AudioVersion",
"Annotation",
"RangeAnalysis",
"Reaction",
"Job",
]

View File

@@ -0,0 +1,48 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from rehearsalhub.config import get_settings
_engine = None
_session_factory: async_sessionmaker[AsyncSession] | None = None
def get_engine():
global _engine
if _engine is None:
settings = get_settings()
_engine = create_async_engine(
settings.database_url,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
echo=settings.debug,
)
return _engine
def get_session_factory() -> async_sessionmaker[AsyncSession]:
global _session_factory
if _session_factory is None:
_session_factory = async_sessionmaker(
get_engine(),
expire_on_commit=False,
class_=AsyncSession,
)
return _session_factory
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency that yields an async DB session."""
async with get_session_factory()() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise

View File

@@ -0,0 +1,368 @@
"""All SQLAlchemy 2.0 ORM models for RehearsalHub."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
ForeignKey,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
# ── Members ───────────────────────────────────────────────────────────────────
class Member(Base):
__tablename__ = "members"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
avatar_url: Mapped[Optional[str]] = mapped_column(Text)
nc_username: Mapped[Optional[str]] = mapped_column(String(255))
nc_url: Mapped[Optional[str]] = mapped_column(Text)
nc_password: Mapped[Optional[str]] = mapped_column(Text)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
band_memberships: Mapped[list[BandMember]] = relationship(
"BandMember", back_populates="member", cascade="all, delete-orphan"
)
authored_songs: Mapped[list[Song]] = relationship("Song", back_populates="creator")
uploaded_versions: Mapped[list[AudioVersion]] = relationship(
"AudioVersion", back_populates="uploader"
)
annotations: Mapped[list[Annotation]] = relationship(
"Annotation", back_populates="author", foreign_keys="Annotation.author_id"
)
reactions: Mapped[list[Reaction]] = relationship(
"Reaction", back_populates="member", cascade="all, delete-orphan"
)
# ── Bands ─────────────────────────────────────────────────────────────────────
class Band(Base):
__tablename__ = "bands"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
nc_user: Mapped[Optional[str]] = mapped_column(String(255))
genre_tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
memberships: Mapped[list[BandMember]] = relationship(
"BandMember", back_populates="band", cascade="all, delete-orphan"
)
songs: Mapped[list[Song]] = relationship(
"Song", back_populates="band", cascade="all, delete-orphan"
)
class BandMember(Base):
__tablename__ = "band_members"
__table_args__ = (UniqueConstraint("band_id", "member_id", name="uq_band_member"),)
band_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), primary_key=True
)
member_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), primary_key=True
)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
joined_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
instrument: Mapped[Optional[str]] = mapped_column(String(100))
band: Mapped[Band] = relationship("Band", back_populates="memberships")
member: Mapped[Member] = relationship("Member", back_populates="band_memberships")
class BandInvite(Base):
__tablename__ = "band_invites"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
band_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
)
token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
created_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
used_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
)
band: Mapped[Band] = relationship("Band")
creator: Mapped[Member] = relationship("Member", foreign_keys=[created_by])
# ── Songs ─────────────────────────────────────────────────────────────────────
class Song(Base):
__tablename__ = "songs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
band_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="jam")
global_key: Mapped[Optional[str]] = mapped_column(String(30))
global_bpm: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
notes: Mapped[Optional[str]] = mapped_column(Text)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
band: Mapped[Band] = relationship("Band", back_populates="songs")
creator: Mapped[Optional[Member]] = relationship("Member", back_populates="authored_songs")
versions: Mapped[list[AudioVersion]] = relationship(
"AudioVersion", back_populates="song", cascade="all, delete-orphan"
)
comments: Mapped[list[SongComment]] = relationship(
"SongComment", back_populates="song", cascade="all, delete-orphan"
)
class SongComment(Base):
__tablename__ = "song_comments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
song_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("songs.id", ondelete="CASCADE"), nullable=False, index=True
)
author_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
)
body: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
song: Mapped[Song] = relationship("Song", back_populates="comments")
author: Mapped[Member] = relationship("Member")
# ── Audio Versions ────────────────────────────────────────────────────────────
class AudioVersion(Base):
__tablename__ = "audio_versions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
song_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("songs.id", ondelete="CASCADE"), nullable=False, index=True
)
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
label: Mapped[Optional[str]] = mapped_column(String(255))
nc_file_path: Mapped[str] = mapped_column(Text, nullable=False)
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
waveform_url: Mapped[Optional[str]] = mapped_column(Text)
duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
format: Mapped[Optional[str]] = mapped_column(String(10))
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
analysis_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
uploaded_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
)
uploaded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
song: Mapped[Song] = relationship("Song", back_populates="versions")
uploader: Mapped[Optional[Member]] = relationship(
"Member", back_populates="uploaded_versions"
)
annotations: Mapped[list[Annotation]] = relationship(
"Annotation", back_populates="version", cascade="all, delete-orphan"
)
range_analyses: Mapped[list[RangeAnalysis]] = relationship(
"RangeAnalysis", back_populates="version", cascade="all, delete-orphan"
)
# ── Annotations ───────────────────────────────────────────────────────────────
class Annotation(Base):
__tablename__ = "annotations"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
version_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("audio_versions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
author_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
)
type: Mapped[str] = mapped_column(String(10), nullable=False) # 'point' | 'range'
timestamp_ms: Mapped[int] = mapped_column(Integer, nullable=False)
range_end_ms: Mapped[Optional[int]] = mapped_column(Integer)
body: Mapped[Optional[str]] = mapped_column(Text)
voice_note_url: Mapped[Optional[str]] = mapped_column(Text)
label: Mapped[Optional[str]] = mapped_column(String(255))
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("annotations.id", ondelete="SET NULL")
)
resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
version: Mapped[AudioVersion] = relationship("AudioVersion", back_populates="annotations")
author: Mapped[Member] = relationship(
"Member", back_populates="annotations", foreign_keys=[author_id]
)
replies: Mapped[list[Annotation]] = relationship(
"Annotation", foreign_keys=[parent_id], back_populates="parent"
)
parent: Mapped[Optional[Annotation]] = relationship(
"Annotation", foreign_keys=[parent_id], back_populates="replies", remote_side=[id]
)
reactions: Mapped[list[Reaction]] = relationship(
"Reaction", back_populates="annotation", cascade="all, delete-orphan"
)
range_analysis: Mapped[Optional[RangeAnalysis]] = relationship(
"RangeAnalysis", back_populates="annotation", uselist=False
)
# ── Range Analyses ────────────────────────────────────────────────────────────
class RangeAnalysis(Base):
__tablename__ = "range_analyses"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
annotation_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("annotations.id", ondelete="CASCADE"),
unique=True,
nullable=False,
)
version_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("audio_versions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
start_ms: Mapped[int] = mapped_column(Integer, nullable=False)
end_ms: Mapped[int] = mapped_column(Integer, nullable=False)
bpm: Mapped[Optional[float]] = mapped_column(Numeric(7, 2))
bpm_confidence: Mapped[Optional[float]] = mapped_column(Numeric(4, 3))
key: Mapped[Optional[str]] = mapped_column(String(30))
key_confidence: Mapped[Optional[float]] = mapped_column(Numeric(4, 3))
scale: Mapped[Optional[str]] = mapped_column(String(10))
avg_loudness_lufs: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
peak_loudness_dbfs: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
spectral_centroid: Mapped[Optional[float]] = mapped_column(Numeric(10, 2))
energy: Mapped[Optional[float]] = mapped_column(Numeric(5, 4))
danceability: Mapped[Optional[float]] = mapped_column(Numeric(5, 4))
chroma_vector: Mapped[Optional[list[float]]] = mapped_column(ARRAY(Numeric))
mfcc_mean: Mapped[Optional[list[float]]] = mapped_column(ARRAY(Numeric))
analysis_version: Mapped[Optional[str]] = mapped_column(String(20))
computed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
annotation: Mapped[Annotation] = relationship(
"Annotation", back_populates="range_analysis"
)
version: Mapped[AudioVersion] = relationship(
"AudioVersion", back_populates="range_analyses"
)
# ── Reactions ─────────────────────────────────────────────────────────────────
class Reaction(Base):
__tablename__ = "reactions"
__table_args__ = (
UniqueConstraint("annotation_id", "member_id", "emoji", name="uq_reaction"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
annotation_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("annotations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
member_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
)
emoji: Mapped[str] = mapped_column(String(10), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
annotation: Mapped[Annotation] = relationship("Annotation", back_populates="reactions")
member: Mapped[Member] = relationship("Member", back_populates="reactions")
# ── Jobs ──────────────────────────────────────────────────────────────────────
class Job(Base):
__tablename__ = "jobs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True)
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
error: Mapped[Optional[str]] = mapped_column(Text)
queued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))