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

View File

@@ -0,0 +1,35 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
# Security
secret_key: str
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
# Database
database_url: str # postgresql+asyncpg://...
# Redis
redis_url: str = "redis://localhost:6379/0"
job_queue_key: str = "rh:jobs"
# Nextcloud
nextcloud_url: str = "http://nextcloud"
nextcloud_user: str = "ncadmin"
nextcloud_pass: str = ""
# App
domain: str = "localhost"
debug: bool = False
# Worker
analysis_version: str = "1.0.0"
@lru_cache
def get_settings() -> Settings:
return Settings() # type: ignore[call-arg]

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

View File

@@ -0,0 +1,41 @@
"""FastAPI dependency providers."""
from __future__ import annotations
import uuid
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member
from rehearsalhub.services.auth import decode_token
from rehearsalhub.repositories.member import MemberRepository
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_member(
token: str = Depends(oauth2_scheme),
session: AsyncSession = Depends(get_session),
) -> Member:
credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
member_id_str: str | None = payload.get("sub")
if member_id_str is None:
raise credentials_exc
member_id = uuid.UUID(member_id_str)
except Exception:
raise credentials_exc
repo = MemberRepository(session)
member = await repo.get_by_id(member_id)
if member is None:
raise credentials_exc
return member

View File

@@ -0,0 +1,68 @@
"""RehearsalHub FastAPI application entry point."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from rehearsalhub.config import get_settings
from rehearsalhub.routers import (
annotations_router,
auth_router,
bands_router,
internal_router,
members_router,
songs_router,
versions_router,
ws_router,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
# Clean up DB connections on shutdown
from rehearsalhub.db.engine import get_engine
engine = get_engine()
await engine.dispose()
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title="RehearsalHub API",
version="0.1.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[f"https://{settings.domain}", "http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
prefix = "/api/v1"
app.include_router(auth_router, prefix=prefix)
app.include_router(bands_router, prefix=prefix)
app.include_router(songs_router, prefix=prefix)
app.include_router(versions_router, prefix=prefix)
app.include_router(annotations_router, prefix=prefix)
app.include_router(members_router, prefix=prefix)
app.include_router(internal_router, prefix=prefix)
app.include_router(ws_router) # WebSocket routes don't use /api/v1 prefix
@app.get("/api/health")
async def health():
return {"status": "ok"}
return app
app = create_app()

View File

@@ -0,0 +1,4 @@
from rehearsalhub.queue.protocol import JobQueue
from rehearsalhub.queue.redis_queue import RedisJobQueue
__all__ = ["JobQueue", "RedisJobQueue"]

View File

@@ -0,0 +1,28 @@
"""Job queue abstraction. Swap Redis for any other backend by implementing this Protocol."""
from __future__ import annotations
import uuid
from typing import Any, Protocol
class JobQueue(Protocol):
async def enqueue(self, job_type: str, payload: dict[str, Any]) -> uuid.UUID:
"""Persist job to DB + push UUID onto queue. Returns the job UUID."""
...
async def dequeue(self, timeout: int = 5) -> tuple[uuid.UUID, str, dict[str, Any]] | None:
"""Block up to `timeout` seconds for a job. Returns (id, type, payload) or None."""
...
async def mark_running(self, job_id: uuid.UUID) -> None:
"""Mark a job as running. Called by the worker when it picks up the job."""
...
async def mark_done(self, job_id: uuid.UUID) -> None:
"""Mark a job as successfully completed."""
...
async def mark_failed(self, job_id: uuid.UUID, error: str) -> None:
"""Mark a job as failed with an error message. Increments attempt counter."""
...

View File

@@ -0,0 +1,81 @@
"""Redis-backed job queue.
Strategy: Postgres is the source of truth (durable audit log + retry counts).
Redis holds a list of job UUIDs for fast signaling. Workers pop a UUID, load
the full payload from Postgres, process, then update status in Postgres.
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from typing import Any
import redis.asyncio as aioredis
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.config import get_settings
from rehearsalhub.db.models import Job
class RedisJobQueue:
def __init__(self, session: AsyncSession, redis_client: aioredis.Redis | None = None) -> None:
self._session = session
self._redis: aioredis.Redis | None = redis_client
async def _get_redis(self) -> aioredis.Redis:
if self._redis is None:
self._redis = aioredis.from_url(get_settings().redis_url, decode_responses=True)
return self._redis
async def enqueue(self, job_type: str, payload: dict[str, Any]) -> uuid.UUID:
job = Job(type=job_type, payload=payload, status="queued")
self._session.add(job)
await self._session.flush()
await self._session.refresh(job)
r = await self._get_redis()
queue_key = get_settings().job_queue_key
await r.rpush(queue_key, str(job.id))
return job.id
async def dequeue(self, timeout: int = 5) -> tuple[uuid.UUID, str, dict[str, Any]] | None:
r = await self._get_redis()
queue_key = get_settings().job_queue_key
result = await r.blpop(queue_key, timeout=timeout)
if result is None:
return None
_, raw_id = result
job_id = uuid.UUID(raw_id)
job = await self._session.get(Job, job_id)
if job is None:
return None
return job.id, job.type, job.payload
async def mark_running(self, job_id: uuid.UUID) -> None:
job = await self._session.get(Job, 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()
async def mark_done(self, job_id: uuid.UUID) -> None:
job = await self._session.get(Job, job_id)
if job:
job.status = "done"
job.finished_at = datetime.now(timezone.utc)
await self._session.flush()
async def mark_failed(self, job_id: uuid.UUID, error: str) -> None:
job = await self._session.get(Job, job_id)
if job:
job.status = "failed"
job.error = error[:2000]
job.finished_at = datetime.now(timezone.utc)
await self._session.flush()
async def close(self) -> None:
if self._redis:
await self._redis.aclose()

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

View File

@@ -0,0 +1,19 @@
from rehearsalhub.routers.annotations import router as annotations_router
from rehearsalhub.routers.auth import router as auth_router
from rehearsalhub.routers.bands import router as bands_router
from rehearsalhub.routers.internal import router as internal_router
from rehearsalhub.routers.members import router as members_router
from rehearsalhub.routers.songs import router as songs_router
from rehearsalhub.routers.versions import router as versions_router
from rehearsalhub.routers.ws import router as ws_router
__all__ = [
"auth_router",
"bands_router",
"internal_router",
"members_router",
"songs_router",
"versions_router",
"annotations_router",
"ws_router",
]

View File

@@ -0,0 +1,174 @@
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member
from rehearsalhub.repositories.annotation import AnnotationRepository
from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.annotation import (
AnnotationCreate,
AnnotationRead,
AnnotationUpdate,
ReactionCreate,
ReactionRead,
)
from rehearsalhub.services.annotation import AnnotationService
from rehearsalhub.services.band import BandService
from rehearsalhub.ws import manager
router = APIRouter(tags=["annotations"])
async def _assert_version_access(
version_id: uuid.UUID, current_member: Member, session: AsyncSession
) -> None:
version_repo = AudioVersionRepository(session)
version = await version_repo.get_by_id(version_id)
if version is None:
raise HTTPException(status_code=404, detail="Version not found")
song_repo = SongRepository(session)
song = await song_repo.get_by_id(version.song_id)
if song is None:
raise HTTPException(status_code=404, detail="Song not found")
band_svc = BandService(session)
try:
await band_svc.assert_membership(song.band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=403, detail="Not a member")
@router.get("/versions/{version_id}/annotations", response_model=list[AnnotationRead])
async def list_annotations(
version_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
await _assert_version_access(version_id, current_member, session)
repo = AnnotationRepository(session)
annotations = await repo.list_for_version(version_id)
return [AnnotationRead.model_validate(a) for a in annotations]
@router.post(
"/versions/{version_id}/annotations",
response_model=AnnotationRead,
status_code=status.HTTP_201_CREATED,
)
async def create_annotation(
version_id: uuid.UUID,
data: AnnotationCreate,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
await _assert_version_access(version_id, current_member, session)
svc = AnnotationService(session)
annotation = await svc.create_annotation(version_id, current_member.id, data)
read = AnnotationRead.model_validate(annotation)
await manager.broadcast(version_id, "annotation.created", read.model_dump(mode="json"))
return read
@router.patch("/annotations/{annotation_id}", response_model=AnnotationRead)
async def update_annotation(
annotation_id: uuid.UUID,
data: AnnotationUpdate,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
repo = AnnotationRepository(session)
annotation = await repo.get_by_id(annotation_id)
if annotation is None or annotation.deleted_at is not None:
raise HTTPException(status_code=404, detail="Annotation not found")
svc = AnnotationService(session)
try:
annotation = await svc.update_annotation(annotation, current_member.id, data)
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
read = AnnotationRead.model_validate(annotation)
await manager.broadcast(annotation.version_id, "annotation.updated", read.model_dump(mode="json"))
return read
@router.delete("/annotations/{annotation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_annotation(
annotation_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
repo = AnnotationRepository(session)
annotation = await repo.get_by_id(annotation_id)
if annotation is None or annotation.deleted_at is not None:
raise HTTPException(status_code=404, detail="Annotation not found")
svc = AnnotationService(session)
try:
await svc.delete_annotation(annotation, current_member.id)
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
await manager.broadcast(
annotation.version_id, "annotation.deleted", {"id": str(annotation_id)}
)
@router.post(
"/annotations/{annotation_id}/reactions",
response_model=ReactionRead,
status_code=status.HTTP_201_CREATED,
)
async def add_reaction(
annotation_id: uuid.UUID,
data: ReactionCreate,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
repo = AnnotationRepository(session)
annotation = await repo.get_by_id(annotation_id)
if annotation is None or annotation.deleted_at is not None:
raise HTTPException(status_code=404, detail="Annotation not found")
svc = AnnotationService(session)
reaction = await svc.add_reaction(annotation_id, current_member.id, data.emoji)
read = ReactionRead.model_validate(reaction)
await manager.broadcast(
annotation.version_id, "reaction.added", read.model_dump(mode="json")
)
return read
@router.get("/bands/{band_id}/search/ranges")
async def search_ranges(
band_id: uuid.UUID,
bpm_min: float | None = Query(None),
bpm_max: float | None = Query(None),
key: str | None = Query(None),
tag: str | None = Query(None),
min_duration_ms: int | None = Query(None),
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
) -> list[Any]:
band_svc = BandService(session)
try:
await band_svc.assert_membership(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=403, detail="Not a member")
repo = AnnotationRepository(session)
return await repo.search_ranges(band_id, bpm_min, bpm_max, key, tag, min_duration_ms)
@router.get("/bands/{band_id}/jams", response_model=list[AnnotationRead])
async def list_jams(
band_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
band_svc = BandService(session)
try:
await band_svc.assert_membership(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=403, detail="Not a member")
repo = AnnotationRepository(session)
annotations = await repo.list_all_ranges_for_band(band_id)
return [AnnotationRead.model_validate(a) for a in annotations]

View File

@@ -0,0 +1,62 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member
from rehearsalhub.repositories.member import MemberRepository
from rehearsalhub.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from rehearsalhub.schemas.member import MemberRead, MemberSettingsUpdate
from rehearsalhub.services.auth import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=MemberRead, status_code=status.HTTP_201_CREATED)
async def register(req: RegisterRequest, session: AsyncSession = Depends(get_session)):
svc = AuthService(session)
try:
member = await svc.register(req)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
return MemberRead.from_model(member)
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, session: AsyncSession = Depends(get_session)):
svc = AuthService(session)
token = await svc.login(req.email, req.password)
if token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
)
return token
@router.get("/me", response_model=MemberRead)
async def get_me(current_member: Member = Depends(get_current_member)):
return MemberRead.from_model(current_member)
@router.patch("/me/settings", response_model=MemberRead)
async def update_settings(
data: MemberSettingsUpdate,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
repo = MemberRepository(session)
updates: dict = {}
if data.display_name is not None:
updates["display_name"] = data.display_name
if data.nc_url is not None:
updates["nc_url"] = data.nc_url.rstrip("/") if data.nc_url else None
if data.nc_username is not None:
updates["nc_username"] = data.nc_username or None
if data.nc_password is not None:
updates["nc_password"] = data.nc_password or None
if updates:
member = await repo.update(current_member, **updates)
else:
member = current_member
return MemberRead.from_model(member)

View File

@@ -0,0 +1,55 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers
from rehearsalhub.services.band import BandService
router = APIRouter(prefix="/bands", tags=["bands"])
@router.get("", response_model=list[BandRead])
async def list_bands(
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
from rehearsalhub.repositories.band import BandRepository
repo = BandRepository(session)
bands = await repo.list_for_member(current_member.id)
return [BandRead.model_validate(b) for b in bands]
@router.post("", response_model=BandRead, status_code=status.HTTP_201_CREATED)
async def create_band(
data: BandCreate,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
svc = BandService(session)
try:
band = await svc.create_band(data, current_member.id, creator=current_member)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
return BandRead.model_validate(band)
@router.get("/{band_id}", response_model=BandReadWithMembers)
async def get_band(
band_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
svc = BandService(session)
try:
await svc.assert_membership(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
band = await svc.get_band_with_members(band_id)
if band is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
return BandReadWithMembers.model_validate(band)

View 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)}

View File

@@ -0,0 +1,134 @@
"""Band membership and invite endpoints."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.schemas.invite import BandInviteRead, BandMemberRead
from rehearsalhub.services.band import BandService
router = APIRouter(tags=["members"])
@router.get("/bands/{band_id}/members", response_model=list[BandMemberRead])
async def list_members(
band_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
svc = BandService(session)
try:
await svc.assert_membership(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
band = await svc.get_band_with_members(band_id)
if band is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
return [
BandMemberRead(
id=bm.member.id,
display_name=bm.member.display_name,
email=bm.member.email,
role=bm.role,
joined_at=bm.joined_at,
)
for bm in band.memberships
]
@router.post("/bands/{band_id}/invites", response_model=BandInviteRead, status_code=status.HTTP_201_CREATED)
async def create_invite(
band_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
svc = BandService(session)
try:
await svc.assert_admin(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
repo = BandRepository(session)
invite = await repo.create_invite(band_id, current_member.id)
return BandInviteRead.model_validate(invite)
@router.delete("/bands/{band_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(
band_id: uuid.UUID,
member_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
svc = BandService(session)
try:
await svc.assert_admin(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
if member_id == current_member.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot remove yourself")
repo = BandRepository(session)
await repo.remove_member(band_id, member_id)
@router.post("/invites/{token}/accept", response_model=BandMemberRead)
async def accept_invite(
token: str,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
repo = BandRepository(session)
invite = await repo.get_invite_by_token(token)
if invite is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
if invite.used_at is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite already used")
if invite.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Invite expired")
# Idempotent — already a member
existing_role = await repo.get_member_role(invite.band_id, current_member.id)
if existing_role is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already a member")
bm = await repo.add_member(invite.band_id, current_member.id, role=invite.role)
# Mark invite as used
invite.used_at = datetime.now(timezone.utc)
invite.used_by = current_member.id
await session.flush()
return BandMemberRead(
id=current_member.id,
display_name=current_member.display_name,
email=current_member.email,
role=bm.role,
joined_at=bm.joined_at,
)
@router.get("/invites/{token}", response_model=BandInviteRead)
async def get_invite(token: str, session: AsyncSession = Depends(get_session)):
"""Preview invite info (band name etc.) before accepting — no auth required."""
from sqlalchemy.orm import selectinload
from sqlalchemy import select
from rehearsalhub.db.models import BandInvite
stmt = select(BandInvite).options(selectinload(BandInvite.band)).where(BandInvite.token == token)
result = await session.execute(stmt)
invite = result.scalar_one_or_none()
if invite is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
return BandInviteRead.model_validate(invite)

View File

@@ -0,0 +1,240 @@
import logging
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member
from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.repositories.comment import CommentRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.comment import SongCommentCreate, SongCommentRead
from rehearsalhub.schemas.song import SongCreate, SongRead
from rehearsalhub.services.band import BandService
from rehearsalhub.services.song import SongService
from rehearsalhub.storage.nextcloud import NextcloudClient
log = logging.getLogger(__name__)
router = APIRouter(tags=["songs"])
AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus"}
@router.get("/bands/{band_id}/songs", response_model=list[SongRead])
async def list_songs(
band_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
band_svc = BandService(session)
try:
await band_svc.assert_membership(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
song_svc = SongService(session)
return await song_svc.list_songs(band_id)
@router.post("/bands/{band_id}/songs", response_model=SongRead, status_code=status.HTTP_201_CREATED)
async def create_song(
band_id: uuid.UUID,
data: SongCreate,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
band_svc = BandService(session)
try:
await band_svc.assert_membership(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
band_repo = BandRepository(session)
band = await band_repo.get_by_id(band_id)
if band is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
song_svc = SongService(session)
song = await song_svc.create_song(band_id, data, current_member.id, band.slug, creator=current_member)
read = SongRead.model_validate(song)
read.version_count = 0
return read
@router.post("/bands/{band_id}/nc-scan", response_model=list[SongRead])
async def scan_nextcloud(
band_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
"""
Scan the band's Nextcloud folder for audio files and import any not yet
registered as songs/versions. Idempotent — safe to call multiple times.
"""
band_svc = BandService(session)
try:
await band_svc.assert_membership(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
band_repo = BandRepository(session)
band = await band_repo.get_by_id(band_id)
if band is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
nc = NextcloudClient.for_member(current_member)
version_repo = AudioVersionRepository(session)
song_svc = SongService(session)
# dav_prefix to strip full WebDAV hrefs → user-relative paths
dav_prefix = f"/remote.php/dav/files/{nc._auth[0]}/"
def relative(href: str) -> str:
if href.startswith(dav_prefix):
return href[len(dav_prefix):]
return href.lstrip("/")
imported: list[SongRead] = []
try:
items = await nc.list_folder(band.nc_folder_path or f"bands/{band.slug}/")
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Nextcloud unreachable: {exc}")
# Collect (nc_file_path, song_folder_rel, song_title) tuples
to_import: list[tuple[str, str, str]] = []
for item in items:
rel = relative(item.path)
if rel.endswith("/"):
# It's a subdirectory — scan one level deeper
try:
sub_items = await nc.list_folder(rel)
except Exception:
continue
dir_name = Path(rel.rstrip("/")).name
for sub in sub_items:
sub_rel = relative(sub.path)
if Path(sub_rel).suffix.lower() in AUDIO_EXTENSIONS:
to_import.append((sub_rel, rel, dir_name))
else:
if Path(rel).suffix.lower() in AUDIO_EXTENSIONS:
folder = str(Path(rel).parent) + "/"
title = Path(rel).stem
to_import.append((rel, folder, title))
for nc_file_path, nc_folder, song_title in to_import:
# Skip if version already registered by etag
try:
meta = await nc.get_file_metadata(nc_file_path)
etag = meta.etag
except Exception:
etag = None
if etag and await version_repo.get_by_etag(etag):
continue
# Find or create song
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, song_title)
if song is None:
song = await song_repo.create(
band_id=band_id,
title=song_title,
status="jam",
notes=None,
nc_folder_path=nc_folder,
created_by=current_member.id,
)
from rehearsalhub.schemas.audio_version import AudioVersionCreate # noqa: PLC0415
await song_svc.register_version(
song.id,
AudioVersionCreate(
nc_file_path=nc_file_path,
nc_file_etag=etag,
format=Path(nc_file_path).suffix.lstrip(".").lower(),
file_size_bytes=meta.size if etag else None,
),
current_member.id,
)
read = SongRead.model_validate(song)
read.version_count = 1
imported.append(read)
log.info("Imported %s as song '%s'", nc_file_path, song_title)
return imported
# ── Comments ──────────────────────────────────────────────────────────────────
async def _assert_song_membership(
song_id: uuid.UUID, member_id: uuid.UUID, session: AsyncSession
) -> None:
song_repo = SongRepository(session)
song = await song_repo.get_by_id(song_id)
if song is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Song not found")
band_svc = BandService(session)
try:
await band_svc.assert_membership(song.band_id, member_id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
@router.get("/songs/{song_id}/comments", response_model=list[SongCommentRead])
async def list_comments(
song_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
await _assert_song_membership(song_id, current_member.id, session)
repo = CommentRepository(session)
comments = await repo.list_for_song(song_id)
return [SongCommentRead.from_model(c) for c in comments]
@router.post("/songs/{song_id}/comments", response_model=SongCommentRead, status_code=status.HTTP_201_CREATED)
async def create_comment(
song_id: uuid.UUID,
data: SongCommentCreate,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
await _assert_song_membership(song_id, current_member.id, session)
repo = CommentRepository(session)
comment = await repo.create(song_id=song_id, author_id=current_member.id, body=data.body)
comment = await repo.get_with_author(comment.id)
return SongCommentRead.from_model(comment)
@router.delete("/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_comment(
comment_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
repo = CommentRepository(session)
comment = await repo.get_with_author(comment_id)
if comment is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
# Allow author or band admin
if comment.author_id != current_member.id:
song_repo = SongRepository(session)
song = await song_repo.get_by_id(comment.song_id)
band_svc = BandService(session)
try:
await band_svc.assert_admin(song.band_id, current_member.id) # type: ignore[union-attr]
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed")
await repo.delete(comment)

View File

@@ -0,0 +1,120 @@
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member
from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate, AudioVersionRead
from rehearsalhub.services.band import BandService
from rehearsalhub.services.song import SongService
from rehearsalhub.storage.nextcloud import NextcloudClient
router = APIRouter(tags=["versions"])
async def _get_version_and_assert_band_membership(
version_id: uuid.UUID,
session: AsyncSession,
current_member: Member,
) -> tuple:
version_repo = AudioVersionRepository(session)
version = await version_repo.get_by_id(version_id)
if version is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found")
song_repo = SongRepository(session)
song = await song_repo.get_by_id(version.song_id)
if song is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Song not found")
band_svc = BandService(session)
try:
await band_svc.assert_membership(song.band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
return version, song
@router.get("/songs/{song_id}/versions", response_model=list[AudioVersionRead])
async def list_versions(
song_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
song_repo = SongRepository(session)
song = await song_repo.get_by_id(song_id)
if song is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Song not found")
band_svc = BandService(session)
try:
await band_svc.assert_membership(song.band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
version_repo = AudioVersionRepository(session)
return [AudioVersionRead.model_validate(v) for v in await version_repo.list_for_song(song_id)]
@router.post(
"/songs/{song_id}/versions",
response_model=AudioVersionRead,
status_code=status.HTTP_201_CREATED,
)
async def create_version(
song_id: uuid.UUID,
data: AudioVersionCreate,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
song_repo = SongRepository(session)
song = await song_repo.get_by_id(song_id)
if song is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Song not found")
band_svc = BandService(session)
try:
await band_svc.assert_membership(song.band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
song_svc = SongService(session)
version = await song_svc.register_version(song_id, data, current_member.id)
return AudioVersionRead.model_validate(version)
@router.get("/versions/{version_id}/waveform")
async def get_waveform(
version_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
) -> Any:
version, _ = await _get_version_and_assert_band_membership(version_id, session, current_member)
if not version.waveform_url:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waveform not ready")
storage = NextcloudClient()
data = await storage.download(version.waveform_url)
import json
return json.loads(data)
@router.get("/versions/{version_id}/stream")
async def stream_version(
version_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
version, _ = await _get_version_and_assert_band_membership(version_id, session, current_member)
if not version.cdn_hls_base:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stream not ready")
storage = NextcloudClient()
url = await storage.get_direct_url(f"{version.cdn_hls_base}/playlist.m3u8")
return RedirectResponse(url=url, status_code=302)

View File

@@ -0,0 +1,22 @@
"""WebSocket endpoint for real-time version room events."""
import uuid
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from rehearsalhub.ws import manager
router = APIRouter(tags=["websocket"])
@router.websocket("/ws/versions/{version_id}")
async def version_ws(version_id: uuid.UUID, websocket: WebSocket):
await manager.connect(version_id, websocket)
try:
while True:
# Echo back any client pings; clients can send {"event": "ping"}
data = await websocket.receive_json()
if data.get("event") == "ping":
await websocket.send_json({"event": "pong"})
except WebSocketDisconnect:
manager.disconnect(version_id, websocket)

View File

@@ -0,0 +1,35 @@
from rehearsalhub.schemas.annotation import (
AnnotationCreate,
AnnotationRead,
AnnotationUpdate,
RangeAnalysisRead,
ReactionCreate,
ReactionRead,
)
from rehearsalhub.schemas.audio_version import AudioVersionCreate, AudioVersionRead
from rehearsalhub.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandMemberRead
from rehearsalhub.schemas.member import MemberRead
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate
__all__ = [
"LoginRequest",
"RegisterRequest",
"TokenResponse",
"MemberRead",
"BandCreate",
"BandRead",
"BandReadWithMembers",
"BandMemberRead",
"SongCreate",
"SongRead",
"SongUpdate",
"AudioVersionCreate",
"AudioVersionRead",
"AnnotationCreate",
"AnnotationUpdate",
"AnnotationRead",
"RangeAnalysisRead",
"ReactionCreate",
"ReactionRead",
]

View File

@@ -0,0 +1,83 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, model_validator
class RangeAnalysisRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
start_ms: int
end_ms: int
bpm: float | None = None
bpm_confidence: float | None = None
key: str | None = None
key_confidence: float | None = None
scale: str | None = None
avg_loudness_lufs: float | None = None
peak_loudness_dbfs: float | None = None
spectral_centroid: float | None = None
energy: float | None = None
danceability: float | None = None
chroma_vector: list[float] | None = None
mfcc_mean: list[float] | None = None
analysis_version: str | None = None
computed_at: datetime
class ReactionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
member_id: uuid.UUID
emoji: str
created_at: datetime
class AnnotationCreate(BaseModel):
type: Literal["point", "range"]
timestamp_ms: int
range_end_ms: int | None = None
body: str | None = None
label: str | None = None
tags: list[str] = []
parent_id: uuid.UUID | None = None
@model_validator(mode="after")
def validate_range(self) -> "AnnotationCreate":
if self.type == "range" and self.range_end_ms is None:
raise ValueError("range_end_ms is required for type='range'")
if self.type == "range" and self.range_end_ms is not None:
if self.range_end_ms <= self.timestamp_ms:
raise ValueError("range_end_ms must be greater than timestamp_ms")
return self
class AnnotationUpdate(BaseModel):
body: str | None = None
label: str | None = None
tags: list[str] | None = None
resolved: bool | None = None
class ReactionCreate(BaseModel):
emoji: str
class AnnotationRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
version_id: uuid.UUID
author_id: uuid.UUID
type: str
timestamp_ms: int
range_end_ms: int | None = None
body: str | None = None
label: str | None = None
tags: list[str]
parent_id: uuid.UUID | None = None
resolved: bool
created_at: datetime
updated_at: datetime
range_analysis: RangeAnalysisRead | None = None
reactions: list[ReactionRead] = []

View File

@@ -0,0 +1,30 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class AudioVersionCreate(BaseModel):
nc_file_path: str
nc_file_etag: str | None = None
label: str | None = None
format: str | None = None
file_size_bytes: int | None = None
class AudioVersionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
song_id: uuid.UUID
version_number: int
label: str | None = None
nc_file_path: str
nc_file_etag: str | None = None
cdn_hls_base: str | None = None
waveform_url: str | None = None
duration_ms: int | None = None
format: str | None = None
file_size_bytes: int | None = None
analysis_status: str
uploaded_by: uuid.UUID | None = None
uploaded_at: datetime

View File

@@ -0,0 +1,17 @@
from pydantic import BaseModel, EmailStr
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class RegisterRequest(BaseModel):
email: EmailStr
password: str
display_name: str

View File

@@ -0,0 +1,36 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from rehearsalhub.schemas.member import MemberRead
class BandMemberRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
member: MemberRead
role: str
instrument: str | None = None
joined_at: datetime
class BandCreate(BaseModel):
name: str
slug: str
genre_tags: list[str] = []
nc_base_path: str | None = None # e.g. "Bands/MyBand/" — defaults to "bands/{slug}/"
class BandRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
slug: str
genre_tags: list[str]
nc_folder_path: str | None = None
created_at: datetime
updated_at: datetime
class BandReadWithMembers(BandRead):
memberships: list[BandMemberRead] = []

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class SongCommentCreate(BaseModel):
body: str
class SongCommentRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
song_id: uuid.UUID
body: str
author_id: uuid.UUID
author_name: str
created_at: datetime
@classmethod
def from_model(cls, c: object) -> "SongCommentRead":
return cls(
id=getattr(c, "id"),
song_id=getattr(c, "song_id"),
body=getattr(c, "body"),
author_id=getattr(c, "author_id"),
author_name=getattr(getattr(c, "author"), "display_name"),
created_at=getattr(c, "created_at"),
)

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class BandInviteRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
band_id: uuid.UUID
token: str
role: str
expires_at: datetime
used_at: datetime | None = None
class BandMemberRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
display_name: str
email: str
role: str
joined_at: datetime

View File

@@ -0,0 +1,35 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, EmailStr, model_validator
class MemberBase(BaseModel):
email: EmailStr
display_name: str
class MemberRead(MemberBase):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
avatar_url: str | None = None
nc_username: str | None = None
nc_url: str | None = None
nc_configured: bool = False # True if nc_url + nc_username + nc_password are all set
created_at: datetime
@classmethod
def from_model(cls, m: object) -> "MemberRead":
obj = cls.model_validate(m)
obj.nc_configured = bool(
getattr(m, "nc_url") and getattr(m, "nc_username") and getattr(m, "nc_password")
)
return obj
class MemberSettingsUpdate(BaseModel):
display_name: str | None = None
nc_url: str | None = None
nc_username: str | None = None
nc_password: str | None = None # send null to clear, omit to leave unchanged

View File

@@ -0,0 +1,36 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict
class SongCreate(BaseModel):
title: str
status: Literal["jam", "wip", "arranged", "recorded", "released"] = "jam"
notes: str | None = None
class SongUpdate(BaseModel):
title: str | None = None
status: Literal["jam", "wip", "arranged", "recorded", "released"] | None = None
notes: str | None = None
global_key: str | None = None
global_bpm: float | None = None
class SongRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
band_id: uuid.UUID
title: str
status: str
global_key: str | None = None
global_bpm: float | None = None
notes: str | None = None
nc_folder_path: str | None = None
created_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
latest_version_id: uuid.UUID | None = None
version_count: int = 0

View File

@@ -0,0 +1,6 @@
from rehearsalhub.services.annotation import AnnotationService
from rehearsalhub.services.auth import AuthService
from rehearsalhub.services.band import BandService
from rehearsalhub.services.song import SongService
__all__ = ["AuthService", "BandService", "SongService", "AnnotationService"]

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.models import Annotation, Reaction
from rehearsalhub.queue.redis_queue import RedisJobQueue
from rehearsalhub.repositories.annotation import AnnotationRepository
from rehearsalhub.repositories.reaction import ReactionRepository
from rehearsalhub.schemas.annotation import AnnotationCreate, AnnotationUpdate
class AnnotationService:
def __init__(self, session: AsyncSession, job_queue: RedisJobQueue | None = None) -> None:
self._repo = AnnotationRepository(session)
self._reaction_repo = ReactionRepository(session)
self._queue = job_queue or RedisJobQueue(session)
self._session = session
async def create_annotation(
self,
version_id: uuid.UUID,
author_id: uuid.UUID,
data: AnnotationCreate,
) -> Annotation:
annotation = await self._repo.create(
version_id=version_id,
author_id=author_id,
type=data.type,
timestamp_ms=data.timestamp_ms,
range_end_ms=data.range_end_ms,
body=data.body,
label=data.label,
tags=data.tags,
parent_id=data.parent_id,
)
if data.type == "range":
await self._queue.enqueue(
"analyse_range",
{
"annotation_id": str(annotation.id),
"version_id": str(version_id),
"start_ms": data.timestamp_ms,
"end_ms": data.range_end_ms,
},
)
return annotation
async def update_annotation(
self,
annotation: Annotation,
author_id: uuid.UUID,
data: AnnotationUpdate,
) -> Annotation:
if annotation.author_id != author_id:
raise PermissionError("Only the author can edit an annotation")
kwargs = {k: v for k, v in data.model_dump(exclude_none=True).items()}
return await self._repo.update(annotation, **kwargs)
async def delete_annotation(self, annotation: Annotation, member_id: uuid.UUID) -> None:
if annotation.author_id != member_id:
raise PermissionError("Only the author can delete an annotation")
await self._repo.soft_delete(annotation)
async def add_reaction(
self, annotation_id: uuid.UUID, member_id: uuid.UUID, emoji: str
) -> Reaction:
existing = await self._reaction_repo.get_existing(annotation_id, member_id, emoji)
if existing:
return existing
return await self._reaction_repo.create(
annotation_id=annotation_id, member_id=member_id, emoji=emoji
)

View File

@@ -0,0 +1,72 @@
"""Auth service: password hashing, JWT creation/verification."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import bcrypt
from jose import JWTError, jwt
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.config import get_settings
from rehearsalhub.db.models import Member
from rehearsalhub.repositories.member import MemberRepository
from rehearsalhub.schemas.auth import RegisterRequest, TokenResponse
def hash_password(plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def create_access_token(member_id: str, email: str) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
"sub": member_id,
"email": email,
"exp": expire,
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict:
settings = get_settings()
return jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])
class AuthService:
def __init__(self, session: AsyncSession) -> None:
self._repo = MemberRepository(session)
self._session = session
async def register(self, req: RegisterRequest) -> Member:
if await self._repo.email_exists(req.email):
raise ValueError(f"Email already registered: {req.email}")
member = await self._repo.create(
email=req.email.lower(),
display_name=req.display_name,
password_hash=hash_password(req.password),
)
return member
async def login(self, email: str, password: str) -> TokenResponse | None:
member = await self._repo.get_by_email(email)
if member is None or not verify_password(password, member.password_hash):
return None
token = create_access_token(str(member.id), member.email)
return TokenResponse(access_token=token)
async def get_member_from_token(self, token: str) -> Member | None:
try:
payload = decode_token(token)
member_id = payload.get("sub")
if member_id is None:
return None
return await self._repo.get_by_id(__import__("uuid").UUID(member_id))
except (JWTError, ValueError):
return None

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.models import Band
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.schemas.band import BandCreate, BandReadWithMembers
from rehearsalhub.storage.nextcloud import NextcloudClient
class BandService:
def __init__(self, session: AsyncSession, storage: NextcloudClient | None = None) -> None:
self._repo = BandRepository(session)
self._storage = storage or NextcloudClient()
async def create_band(self, data: BandCreate, creator_id: uuid.UUID, creator: object | None = None) -> Band:
if await self._repo.get_by_slug(data.slug):
raise ValueError(f"Slug already taken: {data.slug}")
nc_folder = (data.nc_base_path or f"bands/{data.slug}/").strip("/") + "/"
storage = NextcloudClient.for_member(creator) if creator else self._storage
try:
await storage.create_folder(nc_folder)
except Exception:
pass # NC might not be reachable during tests; folder creation is best-effort
band = await self._repo.create(
name=data.name,
slug=data.slug,
genre_tags=data.genre_tags,
nc_folder_path=nc_folder,
)
await self._repo.add_member(band.id, creator_id, role="admin")
return band
async def get_band_with_members(self, band_id: uuid.UUID) -> Band | None:
return await self._repo.get_with_members(band_id)
async def assert_membership(self, band_id: uuid.UUID, member_id: uuid.UUID) -> str:
"""Returns the member's role or raises PermissionError."""
role = await self._repo.get_member_role(band_id, member_id)
if role is None:
raise PermissionError("Not a member of this band")
return role
async def assert_admin(self, band_id: uuid.UUID, member_id: uuid.UUID) -> None:
role = await self.assert_membership(band_id, member_id)
if role != "admin":
raise PermissionError("Admin role required")

View File

@@ -0,0 +1,92 @@
from __future__ import annotations
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.models import AudioVersion, Song
from rehearsalhub.queue.redis_queue import RedisJobQueue
from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate
from rehearsalhub.storage.nextcloud import NextcloudClient
class SongService:
def __init__(
self,
session: AsyncSession,
job_queue: RedisJobQueue | None = None,
storage: NextcloudClient | None = None,
) -> None:
self._repo = SongRepository(session)
self._version_repo = AudioVersionRepository(session)
self._session = session
self._queue = job_queue or RedisJobQueue(session)
self._storage = storage or NextcloudClient()
async def create_song(
self, band_id: uuid.UUID, data: SongCreate, creator_id: uuid.UUID, band_slug: str,
creator: object | None = None,
) -> Song:
from rehearsalhub.storage.nextcloud import NextcloudClient
nc_folder = f"bands/{band_slug}/songs/{data.title.lower().replace(' ', '-')}/"
storage = NextcloudClient.for_member(creator) if creator else self._storage
try:
await storage.create_folder(nc_folder)
except Exception:
nc_folder = None # best-effort
song = await self._repo.create(
band_id=band_id,
title=data.title,
status=data.status,
notes=data.notes,
nc_folder_path=nc_folder,
created_by=creator_id,
)
return song
async def list_songs(self, band_id: uuid.UUID) -> list[SongRead]:
songs = await self._repo.list_for_band(band_id)
result = []
for song in songs:
versions = song.versions
read = SongRead.model_validate(song)
read.version_count = len(versions)
if versions:
latest = max(versions, key=lambda v: v.version_number)
read.latest_version_id = latest.id
result.append(read)
return result
async def register_version(
self,
song_id: uuid.UUID,
data: AudioVersionCreate,
uploader_id: uuid.UUID,
) -> AudioVersion:
if data.nc_file_etag:
existing = await self._version_repo.get_by_etag(data.nc_file_etag)
if existing:
return existing
version_number = await self._repo.next_version_number(song_id)
version = await self._version_repo.create(
song_id=song_id,
version_number=version_number,
nc_file_path=data.nc_file_path,
nc_file_etag=data.nc_file_etag,
label=data.label,
format=data.format,
file_size_bytes=data.file_size_bytes,
analysis_status="pending",
uploaded_by=uploader_id,
)
await self._queue.enqueue(
"transcode",
{"version_id": str(version.id), "nc_file_path": data.nc_file_path},
)
return version

View File

@@ -0,0 +1,4 @@
from rehearsalhub.storage.nextcloud import NextcloudClient
from rehearsalhub.storage.protocol import FileMetadata, StorageClient
__all__ = ["StorageClient", "FileMetadata", "NextcloudClient"]

View File

@@ -0,0 +1,143 @@
"""Nextcloud WebDAV + OCS storage client."""
from __future__ import annotations
import xml.etree.ElementTree as ET
from typing import Any
import httpx
from rehearsalhub.config import get_settings
from rehearsalhub.storage.protocol import FileMetadata
_DAV_NS = "{DAV:}"
class NextcloudClient:
def __init__(
self,
base_url: str | None = None,
username: str | None = None,
password: str | None = None,
) -> None:
s = get_settings()
self._base = (base_url or s.nextcloud_url).rstrip("/")
self._auth = (username or s.nextcloud_user, password or s.nextcloud_pass)
self._dav_root = f"{self._base}/remote.php/dav/files/{self._auth[0]}"
@classmethod
def for_member(cls, member: object) -> "NextcloudClient":
"""Return a client using member's personal NC credentials if configured,
falling back to the global env-var credentials."""
nc_url = getattr(member, "nc_url", None)
nc_username = getattr(member, "nc_username", None)
nc_password = getattr(member, "nc_password", None)
if nc_url and nc_username and nc_password:
return cls(base_url=nc_url, username=nc_username, password=nc_password)
return cls()
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(auth=self._auth, timeout=30.0)
def _dav_url(self, path: str) -> str:
return f"{self._dav_root}/{path.lstrip('/')}"
async def create_folder(self, path: str) -> None:
async with self._client() as c:
resp = await c.request("MKCOL", self._dav_url(path))
if resp.status_code not in (201, 405): # 405 = already exists
resp.raise_for_status()
async def get_file_metadata(self, path: str) -> FileMetadata:
body = (
'<?xml version="1.0"?>'
'<d:propfind xmlns:d="DAV:">'
" <d:prop><d:getcontentlength/><d:getetag/><d:getcontenttype/></d:prop>"
"</d:propfind>"
)
async with self._client() as c:
resp = await c.request(
"PROPFIND",
self._dav_url(path),
headers={"Depth": "0", "Content-Type": "application/xml"},
content=body,
)
resp.raise_for_status()
return _parse_propfind_single(resp.text, path)
async def list_folder(self, path: str) -> list[FileMetadata]:
body = (
'<?xml version="1.0"?>'
'<d:propfind xmlns:d="DAV:">'
" <d:prop><d:getcontentlength/><d:getetag/><d:getcontenttype/></d:prop>"
"</d:propfind>"
)
async with self._client() as c:
resp = await c.request(
"PROPFIND",
self._dav_url(path),
headers={"Depth": "1", "Content-Type": "application/xml"},
content=body,
)
resp.raise_for_status()
return _parse_propfind_multi(resp.text)
async def download(self, path: str) -> bytes:
async with self._client() as c:
resp = await c.get(self._dav_url(path))
resp.raise_for_status()
return resp.content
async def get_direct_url(self, path: str) -> str:
return self._dav_url(path)
async def delete(self, path: str) -> None:
async with self._client() as c:
resp = await c.request("DELETE", self._dav_url(path))
resp.raise_for_status()
async def get_activities(self, since_id: int = 0, limit: int = 50) -> list[dict[str, Any]]:
"""Fetch recent file activity from Nextcloud OCS API."""
url = f"{self._base}/ocs/v2.php/apps/activity/api/v2/activity/files"
params: dict[str, Any] = {"since": since_id, "limit": limit, "format": "json"}
async with self._client() as c:
resp = await c.get(url, params=params, headers={"OCS-APIRequest": "true"})
resp.raise_for_status()
data = resp.json()
return data.get("ocs", {}).get("data", [])
def _parse_propfind_single(xml_text: str, path: str) -> FileMetadata:
root = ET.fromstring(xml_text)
response = root.find(f"{_DAV_NS}response")
return _response_to_metadata(response, path) # type: ignore[arg-type]
def _parse_propfind_multi(xml_text: str) -> list[FileMetadata]:
root = ET.fromstring(xml_text)
results = []
for i, response in enumerate(root.findall(f"{_DAV_NS}response")):
if i == 0:
continue # skip the folder itself
href = response.findtext(f"{_DAV_NS}href") or ""
results.append(_response_to_metadata(response, href))
return results
def _response_to_metadata(response: ET.Element, fallback_path: str) -> FileMetadata:
propstat = response.find(f"{_DAV_NS}propstat")
prop = propstat.find(f"{_DAV_NS}prop") if propstat is not None else None # type: ignore[union-attr]
href = response.findtext(f"{_DAV_NS}href") or fallback_path
etag = (prop.findtext(f"{_DAV_NS}getetag") or "").strip('"') if prop is not None else ""
size_text = prop.findtext(f"{_DAV_NS}getcontentlength") if prop is not None else "0"
ctype = (
prop.findtext(f"{_DAV_NS}getcontenttype") or "application/octet-stream"
if prop is not None
else "application/octet-stream"
)
return FileMetadata(
path=href,
etag=etag,
size=int(size_text or 0),
content_type=ctype,
)

View File

@@ -0,0 +1,39 @@
"""Storage abstraction. Default impl is Nextcloud/WebDAV; swap for S3, local FS, etc."""
from __future__ import annotations
from typing import Protocol
class FileMetadata:
def __init__(self, path: str, etag: str, size: int, content_type: str) -> None:
self.path = path
self.etag = etag
self.size = size
self.content_type = content_type
class StorageClient(Protocol):
async def create_folder(self, path: str) -> None:
"""Create a folder (and parents) at the given path."""
...
async def get_file_metadata(self, path: str) -> FileMetadata:
"""Return metadata for the file at path."""
...
async def list_folder(self, path: str) -> list[FileMetadata]:
"""List immediate children of the folder at path."""
...
async def download(self, path: str) -> bytes:
"""Download and return the raw bytes of the file at path."""
...
async def get_direct_url(self, path: str) -> str:
"""Return a URL for direct access to the file (used for HLS streaming)."""
...
async def delete(self, path: str) -> None:
"""Delete a file or folder at path."""
...

View File

@@ -0,0 +1,46 @@
"""WebSocket connection manager for real-time version room events."""
from __future__ import annotations
import json
import uuid
from typing import Any
from fastapi import WebSocket
class ConnectionManager:
def __init__(self) -> None:
# version_id -> list of active WebSocket connections
self._rooms: dict[str, list[WebSocket]] = {}
async def connect(self, version_id: uuid.UUID, websocket: WebSocket) -> None:
await websocket.accept()
key = str(version_id)
self._rooms.setdefault(key, []).append(websocket)
def disconnect(self, version_id: uuid.UUID, websocket: WebSocket) -> None:
key = str(version_id)
room = self._rooms.get(key, [])
if websocket in room:
room.remove(websocket)
if not room:
self._rooms.pop(key, None)
async def broadcast(self, version_id: uuid.UUID, event: str, data: Any) -> None:
key = str(version_id)
payload = json.dumps({"event": event, "data": data})
dead: list[WebSocket] = []
for ws in self._rooms.get(key, []):
try:
await ws.send_text(payload)
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(version_id, ws)
def room_size(self, version_id: uuid.UUID) -> int:
return len(self._rooms.get(str(version_id), []))
manager = ConnectionManager()