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,19 @@
from rehearsalhub.repositories.annotation import AnnotationRepository
from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.repositories.base import BaseRepository
from rehearsalhub.repositories.job import JobRepository
from rehearsalhub.repositories.member import MemberRepository
from rehearsalhub.repositories.reaction import ReactionRepository
from rehearsalhub.repositories.song import SongRepository
__all__ = [
"BaseRepository",
"BandRepository",
"MemberRepository",
"SongRepository",
"AudioVersionRepository",
"AnnotationRepository",
"ReactionRepository",
"JobRepository",
]

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import and_, select
from sqlalchemy.orm import selectinload
from rehearsalhub.db.models import Annotation, RangeAnalysis
from rehearsalhub.repositories.base import BaseRepository
class AnnotationRepository(BaseRepository[Annotation]):
model = Annotation
async def list_for_version(self, version_id: uuid.UUID) -> list[Annotation]:
stmt = (
select(Annotation)
.where(
Annotation.version_id == version_id,
Annotation.deleted_at.is_(None),
)
.options(
selectinload(Annotation.range_analysis),
selectinload(Annotation.reactions),
selectinload(Annotation.author),
)
.order_by(Annotation.timestamp_ms)
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def soft_delete(self, annotation: Annotation) -> None:
from datetime import datetime, timezone
annotation.deleted_at = datetime.now(timezone.utc)
await self.session.flush()
async def search_ranges(
self,
band_id: uuid.UUID,
bpm_min: float | None = None,
bpm_max: float | None = None,
key: str | None = None,
tag: str | None = None,
min_duration_ms: int | None = None,
) -> list[dict[str, Any]]:
from rehearsalhub.db.models import AudioVersion, RangeAnalysis, Song
conditions = [
Song.band_id == band_id,
Annotation.type == "range",
Annotation.deleted_at.is_(None),
]
if bpm_min is not None:
conditions.append(RangeAnalysis.bpm >= bpm_min)
if bpm_max is not None:
conditions.append(RangeAnalysis.bpm <= bpm_max)
if key is not None:
conditions.append(RangeAnalysis.key.ilike(f"%{key}%"))
if tag is not None:
conditions.append(Annotation.tags.any(tag))
if min_duration_ms is not None:
conditions.append(
(Annotation.range_end_ms - Annotation.timestamp_ms) >= min_duration_ms
)
stmt = (
select(
Annotation.id.label("annotation_id"),
Song.title.label("song_title"),
Song.id.label("song_id"),
AudioVersion.id.label("version_id"),
AudioVersion.label.label("version_label"),
Annotation.timestamp_ms.label("start_ms"),
Annotation.range_end_ms.label("end_ms"),
Annotation.label.label("label"),
Annotation.tags.label("tags"),
RangeAnalysis.bpm,
RangeAnalysis.key,
RangeAnalysis.scale,
RangeAnalysis.avg_loudness_lufs,
RangeAnalysis.energy,
)
.join(AudioVersion, Annotation.version_id == AudioVersion.id)
.join(Song, AudioVersion.song_id == Song.id)
.join(RangeAnalysis, RangeAnalysis.annotation_id == Annotation.id)
.where(and_(*conditions))
.order_by(Annotation.timestamp_ms)
)
result = await self.session.execute(stmt)
return [row._asdict() for row in result]
async def list_all_ranges_for_band(self, band_id: uuid.UUID) -> list[Annotation]:
from rehearsalhub.db.models import AudioVersion, Song
stmt = (
select(Annotation)
.join(AudioVersion, Annotation.version_id == AudioVersion.id)
.join(Song, AudioVersion.song_id == Song.id)
.where(
Song.band_id == band_id,
Annotation.type == "range",
Annotation.deleted_at.is_(None),
)
.options(
selectinload(Annotation.range_analysis),
selectinload(Annotation.author),
)
.order_by(Annotation.created_at.desc())
)
result = await self.session.execute(stmt)
return list(result.scalars().all())

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from rehearsalhub.db.models import AudioVersion
from rehearsalhub.repositories.base import BaseRepository
class AudioVersionRepository(BaseRepository[AudioVersion]):
model = AudioVersion
async def get_by_etag(self, nc_file_etag: str) -> AudioVersion | None:
stmt = select(AudioVersion).where(AudioVersion.nc_file_etag == nc_file_etag)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_for_song(self, song_id: uuid.UUID) -> list[AudioVersion]:
stmt = (
select(AudioVersion)
.where(AudioVersion.song_id == song_id)
.order_by(AudioVersion.version_number.desc())
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def get_latest_for_song(self, song_id: uuid.UUID) -> AudioVersion | None:
stmt = (
select(AudioVersion)
.where(AudioVersion.song_id == song_id)
.order_by(AudioVersion.version_number.desc())
.limit(1)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_with_annotations(self, version_id: uuid.UUID) -> AudioVersion | None:
from rehearsalhub.db.models import Annotation, RangeAnalysis
stmt = (
select(AudioVersion)
.options(
selectinload(AudioVersion.annotations).selectinload(
Annotation.range_analysis
),
selectinload(AudioVersion.annotations).selectinload(Annotation.reactions),
selectinload(AudioVersion.annotations).selectinload(Annotation.author),
)
.where(AudioVersion.id == version_id)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()

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())

View File

@@ -0,0 +1,53 @@
"""Generic async repository. All concrete repos extend BaseRepository[T]."""
from __future__ import annotations
import uuid
from typing import Any, Generic, Sequence, TypeVar
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.models import Base
ModelT = TypeVar("ModelT", bound=Base)
class BaseRepository(Generic[ModelT]):
model: type[ModelT]
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_by_id(self, id: uuid.UUID) -> ModelT | None:
return await self.session.get(self.model, id)
async def list(self, **filters: Any) -> Sequence[ModelT]:
stmt = select(self.model).filter_by(**filters)
result = await self.session.execute(stmt)
return result.scalars().all()
async def create(self, **kwargs: Any) -> ModelT:
obj = self.model(**kwargs)
self.session.add(obj)
await self.session.flush()
await self.session.refresh(obj)
return obj
async def update(self, obj: ModelT, **kwargs: Any) -> ModelT:
for key, value in kwargs.items():
setattr(obj, key, value)
await self.session.flush()
await self.session.refresh(obj)
return obj
async def delete(self, obj: ModelT) -> None:
await self.session.delete(obj)
await self.session.flush()
async def count(self, **filters: Any) -> int:
from sqlalchemy import func, select
stmt = select(func.count()).select_from(self.model).filter_by(**filters)
result = await self.session.execute(stmt)
return result.scalar_one()

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from rehearsalhub.db.models import SongComment
from rehearsalhub.repositories.base import BaseRepository
class CommentRepository(BaseRepository[SongComment]):
model = SongComment
async def list_for_song(self, song_id: uuid.UUID) -> list[SongComment]:
stmt = (
select(SongComment)
.options(selectinload(SongComment.author))
.where(SongComment.song_id == song_id)
.order_by(SongComment.created_at)
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def get_with_author(self, comment_id: uuid.UUID) -> SongComment | None:
stmt = (
select(SongComment)
.options(selectinload(SongComment.author))
.where(SongComment.id == comment_id)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from rehearsalhub.db.models import Job
from rehearsalhub.repositories.base import BaseRepository
class JobRepository(BaseRepository[Job]):
model = Job
async def list_pending(self, job_type: str | None = None) -> list[Job]:
stmt = select(Job).where(Job.status.in_(["queued", "running"]))
if job_type:
stmt = stmt.where(Job.type == job_type)
stmt = stmt.order_by(Job.queued_at)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def mark_running(self, job_id: uuid.UUID) -> Job | None:
job = await self.get_by_id(job_id)
if job:
job.status = "running"
job.started_at = datetime.now(timezone.utc)
job.attempt = (job.attempt or 0) + 1
await self.session.flush()
return job
async def mark_done(self, job_id: uuid.UUID) -> Job | None:
job = await self.get_by_id(job_id)
if job:
job.status = "done"
job.finished_at = datetime.now(timezone.utc)
await self.session.flush()
return job
async def mark_failed(self, job_id: uuid.UUID, error: str) -> Job | None:
job = await self.get_by_id(job_id)
if job:
job.status = "failed"
job.error = error[:2000]
job.finished_at = datetime.now(timezone.utc)
await self.session.flush()
return job

View File

@@ -0,0 +1,18 @@
from __future__ import annotations
from sqlalchemy import select
from rehearsalhub.db.models import Member
from rehearsalhub.repositories.base import BaseRepository
class MemberRepository(BaseRepository[Member]):
model = Member
async def get_by_email(self, email: str) -> Member | None:
stmt = select(Member).where(Member.email == email.lower())
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def email_exists(self, email: str) -> bool:
return await self.get_by_email(email) is not None

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
import uuid
from sqlalchemy import select
from rehearsalhub.db.models import Reaction
from rehearsalhub.repositories.base import BaseRepository
class ReactionRepository(BaseRepository[Reaction]):
model = Reaction
async def get_existing(
self, annotation_id: uuid.UUID, member_id: uuid.UUID, emoji: str
) -> Reaction | None:
stmt = select(Reaction).where(
Reaction.annotation_id == annotation_id,
Reaction.member_id == member_id,
Reaction.emoji == emoji,
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from rehearsalhub.db.models import AudioVersion, Song
from rehearsalhub.repositories.base import BaseRepository
class SongRepository(BaseRepository[Song]):
model = Song
async def list_for_band(self, band_id: uuid.UUID) -> list[Song]:
stmt = (
select(Song)
.where(Song.band_id == band_id)
.options(selectinload(Song.versions))
.order_by(Song.updated_at.desc())
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def get_with_versions(self, song_id: uuid.UUID) -> Song | None:
stmt = (
select(Song)
.options(selectinload(Song.versions))
.where(Song.id == song_id)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_by_nc_folder_path(self, nc_folder_path: str) -> "Song | None":
stmt = select(Song).where(Song.nc_folder_path == nc_folder_path)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_by_title_and_band(self, band_id: uuid.UUID, title: str) -> "Song | None":
stmt = select(Song).where(Song.band_id == band_id, Song.title == title)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def next_version_number(self, song_id: uuid.UUID) -> int:
from sqlalchemy import func
stmt = select(func.coalesce(func.max(AudioVersion.version_number), 0) + 1).where(
AudioVersion.song_id == song_id
)
result = await self.session.execute(stmt)
return result.scalar_one()