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:
25
api/src/rehearsalhub/db/__init__.py
Normal file
25
api/src/rehearsalhub/db/__init__.py
Normal 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",
|
||||
]
|
||||
48
api/src/rehearsalhub/db/engine.py
Normal file
48
api/src/rehearsalhub/db/engine.py
Normal 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
|
||||
368
api/src/rehearsalhub/db/models.py
Normal file
368
api/src/rehearsalhub/db/models.py
Normal 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))
|
||||
Reference in New Issue
Block a user