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:
0
api/src/rehearsalhub/__init__.py
Normal file
0
api/src/rehearsalhub/__init__.py
Normal file
35
api/src/rehearsalhub/config.py
Normal file
35
api/src/rehearsalhub/config.py
Normal 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]
|
||||
25
api/src/rehearsalhub/db/__init__.py
Normal file
25
api/src/rehearsalhub/db/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from rehearsalhub.db.models import (
|
||||
Annotation,
|
||||
AudioVersion,
|
||||
Band,
|
||||
BandMember,
|
||||
Base,
|
||||
Job,
|
||||
Member,
|
||||
RangeAnalysis,
|
||||
Reaction,
|
||||
Song,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Member",
|
||||
"Band",
|
||||
"BandMember",
|
||||
"Song",
|
||||
"AudioVersion",
|
||||
"Annotation",
|
||||
"RangeAnalysis",
|
||||
"Reaction",
|
||||
"Job",
|
||||
]
|
||||
48
api/src/rehearsalhub/db/engine.py
Normal file
48
api/src/rehearsalhub/db/engine.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from rehearsalhub.config import get_settings
|
||||
|
||||
_engine = None
|
||||
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
settings = get_settings()
|
||||
_engine = create_async_engine(
|
||||
settings.database_url,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_pre_ping=True,
|
||||
echo=settings.debug,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
global _session_factory
|
||||
if _session_factory is None:
|
||||
_session_factory = async_sessionmaker(
|
||||
get_engine(),
|
||||
expire_on_commit=False,
|
||||
class_=AsyncSession,
|
||||
)
|
||||
return _session_factory
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency that yields an async DB session."""
|
||||
async with get_session_factory()() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
368
api/src/rehearsalhub/db/models.py
Normal file
368
api/src/rehearsalhub/db/models.py
Normal file
@@ -0,0 +1,368 @@
|
||||
"""All SQLAlchemy 2.0 ORM models for RehearsalHub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
# ── Members ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Member(Base):
|
||||
__tablename__ = "members"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(Text)
|
||||
nc_username: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
nc_url: Mapped[Optional[str]] = mapped_column(Text)
|
||||
nc_password: Mapped[Optional[str]] = mapped_column(Text)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
band_memberships: Mapped[list[BandMember]] = relationship(
|
||||
"BandMember", back_populates="member", cascade="all, delete-orphan"
|
||||
)
|
||||
authored_songs: Mapped[list[Song]] = relationship("Song", back_populates="creator")
|
||||
uploaded_versions: Mapped[list[AudioVersion]] = relationship(
|
||||
"AudioVersion", back_populates="uploader"
|
||||
)
|
||||
annotations: Mapped[list[Annotation]] = relationship(
|
||||
"Annotation", back_populates="author", foreign_keys="Annotation.author_id"
|
||||
)
|
||||
reactions: Mapped[list[Reaction]] = relationship(
|
||||
"Reaction", back_populates="member", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
# ── Bands ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Band(Base):
|
||||
__tablename__ = "bands"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
|
||||
nc_user: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
genre_tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
memberships: Mapped[list[BandMember]] = relationship(
|
||||
"BandMember", back_populates="band", cascade="all, delete-orphan"
|
||||
)
|
||||
songs: Mapped[list[Song]] = relationship(
|
||||
"Song", back_populates="band", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class BandMember(Base):
|
||||
__tablename__ = "band_members"
|
||||
__table_args__ = (UniqueConstraint("band_id", "member_id", name="uq_band_member"),)
|
||||
|
||||
band_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
member_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
|
||||
joined_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
instrument: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
|
||||
band: Mapped[Band] = relationship("Band", back_populates="memberships")
|
||||
member: Mapped[Member] = relationship("Member", back_populates="band_memberships")
|
||||
|
||||
|
||||
class BandInvite(Base):
|
||||
__tablename__ = "band_invites"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
band_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
used_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
|
||||
)
|
||||
|
||||
band: Mapped[Band] = relationship("Band")
|
||||
creator: Mapped[Member] = relationship("Member", foreign_keys=[created_by])
|
||||
|
||||
|
||||
# ── Songs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Song(Base):
|
||||
__tablename__ = "songs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
band_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="jam")
|
||||
global_key: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
global_bpm: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
|
||||
notes: Mapped[Optional[str]] = mapped_column(Text)
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
band: Mapped[Band] = relationship("Band", back_populates="songs")
|
||||
creator: Mapped[Optional[Member]] = relationship("Member", back_populates="authored_songs")
|
||||
versions: Mapped[list[AudioVersion]] = relationship(
|
||||
"AudioVersion", back_populates="song", cascade="all, delete-orphan"
|
||||
)
|
||||
comments: Mapped[list[SongComment]] = relationship(
|
||||
"SongComment", back_populates="song", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class SongComment(Base):
|
||||
__tablename__ = "song_comments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
song_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("songs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
author_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
song: Mapped[Song] = relationship("Song", back_populates="comments")
|
||||
author: Mapped[Member] = relationship("Member")
|
||||
|
||||
|
||||
# ── Audio Versions ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AudioVersion(Base):
|
||||
__tablename__ = "audio_versions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
song_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("songs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
label: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
nc_file_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
|
||||
waveform_url: Mapped[Optional[str]] = mapped_column(Text)
|
||||
duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
format: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||
analysis_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
uploaded_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
|
||||
)
|
||||
uploaded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
song: Mapped[Song] = relationship("Song", back_populates="versions")
|
||||
uploader: Mapped[Optional[Member]] = relationship(
|
||||
"Member", back_populates="uploaded_versions"
|
||||
)
|
||||
annotations: Mapped[list[Annotation]] = relationship(
|
||||
"Annotation", back_populates="version", cascade="all, delete-orphan"
|
||||
)
|
||||
range_analyses: Mapped[list[RangeAnalysis]] = relationship(
|
||||
"RangeAnalysis", back_populates="version", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
# ── Annotations ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Annotation(Base):
|
||||
__tablename__ = "annotations"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
version_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("audio_versions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
author_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(10), nullable=False) # 'point' | 'range'
|
||||
timestamp_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
range_end_ms: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
body: Mapped[Optional[str]] = mapped_column(Text)
|
||||
voice_note_url: Mapped[Optional[str]] = mapped_column(Text)
|
||||
label: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
|
||||
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("annotations.id", ondelete="SET NULL")
|
||||
)
|
||||
resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
version: Mapped[AudioVersion] = relationship("AudioVersion", back_populates="annotations")
|
||||
author: Mapped[Member] = relationship(
|
||||
"Member", back_populates="annotations", foreign_keys=[author_id]
|
||||
)
|
||||
replies: Mapped[list[Annotation]] = relationship(
|
||||
"Annotation", foreign_keys=[parent_id], back_populates="parent"
|
||||
)
|
||||
parent: Mapped[Optional[Annotation]] = relationship(
|
||||
"Annotation", foreign_keys=[parent_id], back_populates="replies", remote_side=[id]
|
||||
)
|
||||
reactions: Mapped[list[Reaction]] = relationship(
|
||||
"Reaction", back_populates="annotation", cascade="all, delete-orphan"
|
||||
)
|
||||
range_analysis: Mapped[Optional[RangeAnalysis]] = relationship(
|
||||
"RangeAnalysis", back_populates="annotation", uselist=False
|
||||
)
|
||||
|
||||
|
||||
# ── Range Analyses ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RangeAnalysis(Base):
|
||||
__tablename__ = "range_analyses"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
annotation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("annotations.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
)
|
||||
version_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("audio_versions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
start_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
end_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
bpm: Mapped[Optional[float]] = mapped_column(Numeric(7, 2))
|
||||
bpm_confidence: Mapped[Optional[float]] = mapped_column(Numeric(4, 3))
|
||||
key: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
key_confidence: Mapped[Optional[float]] = mapped_column(Numeric(4, 3))
|
||||
scale: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
avg_loudness_lufs: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
|
||||
peak_loudness_dbfs: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
|
||||
spectral_centroid: Mapped[Optional[float]] = mapped_column(Numeric(10, 2))
|
||||
energy: Mapped[Optional[float]] = mapped_column(Numeric(5, 4))
|
||||
danceability: Mapped[Optional[float]] = mapped_column(Numeric(5, 4))
|
||||
chroma_vector: Mapped[Optional[list[float]]] = mapped_column(ARRAY(Numeric))
|
||||
mfcc_mean: Mapped[Optional[list[float]]] = mapped_column(ARRAY(Numeric))
|
||||
analysis_version: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
computed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
annotation: Mapped[Annotation] = relationship(
|
||||
"Annotation", back_populates="range_analysis"
|
||||
)
|
||||
version: Mapped[AudioVersion] = relationship(
|
||||
"AudioVersion", back_populates="range_analyses"
|
||||
)
|
||||
|
||||
|
||||
# ── Reactions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Reaction(Base):
|
||||
__tablename__ = "reactions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("annotation_id", "member_id", "emoji", name="uq_reaction"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
annotation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("annotations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
member_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
emoji: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
annotation: Mapped[Annotation] = relationship("Annotation", back_populates="reactions")
|
||||
member: Mapped[Member] = relationship("Member", back_populates="reactions")
|
||||
|
||||
|
||||
# ── Jobs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True)
|
||||
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
error: Mapped[Optional[str]] = mapped_column(Text)
|
||||
queued_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
41
api/src/rehearsalhub/dependencies.py
Normal file
41
api/src/rehearsalhub/dependencies.py
Normal 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
|
||||
68
api/src/rehearsalhub/main.py
Normal file
68
api/src/rehearsalhub/main.py
Normal 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()
|
||||
4
api/src/rehearsalhub/queue/__init__.py
Normal file
4
api/src/rehearsalhub/queue/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from rehearsalhub.queue.protocol import JobQueue
|
||||
from rehearsalhub.queue.redis_queue import RedisJobQueue
|
||||
|
||||
__all__ = ["JobQueue", "RedisJobQueue"]
|
||||
28
api/src/rehearsalhub/queue/protocol.py
Normal file
28
api/src/rehearsalhub/queue/protocol.py
Normal 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."""
|
||||
...
|
||||
81
api/src/rehearsalhub/queue/redis_queue.py
Normal file
81
api/src/rehearsalhub/queue/redis_queue.py
Normal 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()
|
||||
19
api/src/rehearsalhub/repositories/__init__.py
Normal file
19
api/src/rehearsalhub/repositories/__init__.py
Normal 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",
|
||||
]
|
||||
113
api/src/rehearsalhub/repositories/annotation.py
Normal file
113
api/src/rehearsalhub/repositories/annotation.py
Normal 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())
|
||||
54
api/src/rehearsalhub/repositories/audio_version.py
Normal file
54
api/src/rehearsalhub/repositories/audio_version.py
Normal 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()
|
||||
92
api/src/rehearsalhub/repositories/band.py
Normal file
92
api/src/rehearsalhub/repositories/band.py
Normal 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())
|
||||
53
api/src/rehearsalhub/repositories/base.py
Normal file
53
api/src/rehearsalhub/repositories/base.py
Normal 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()
|
||||
32
api/src/rehearsalhub/repositories/comment.py
Normal file
32
api/src/rehearsalhub/repositories/comment.py
Normal 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()
|
||||
47
api/src/rehearsalhub/repositories/job.py
Normal file
47
api/src/rehearsalhub/repositories/job.py
Normal 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
|
||||
18
api/src/rehearsalhub/repositories/member.py
Normal file
18
api/src/rehearsalhub/repositories/member.py
Normal 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
|
||||
23
api/src/rehearsalhub/repositories/reaction.py
Normal file
23
api/src/rehearsalhub/repositories/reaction.py
Normal 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()
|
||||
51
api/src/rehearsalhub/repositories/song.py
Normal file
51
api/src/rehearsalhub/repositories/song.py
Normal 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()
|
||||
19
api/src/rehearsalhub/routers/__init__.py
Normal file
19
api/src/rehearsalhub/routers/__init__.py
Normal 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",
|
||||
]
|
||||
174
api/src/rehearsalhub/routers/annotations.py
Normal file
174
api/src/rehearsalhub/routers/annotations.py
Normal 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]
|
||||
62
api/src/rehearsalhub/routers/auth.py
Normal file
62
api/src/rehearsalhub/routers/auth.py
Normal 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)
|
||||
55
api/src/rehearsalhub/routers/bands.py
Normal file
55
api/src/rehearsalhub/routers/bands.py
Normal 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)
|
||||
101
api/src/rehearsalhub/routers/internal.py
Normal file
101
api/src/rehearsalhub/routers/internal.py
Normal 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)}
|
||||
134
api/src/rehearsalhub/routers/members.py
Normal file
134
api/src/rehearsalhub/routers/members.py
Normal 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)
|
||||
240
api/src/rehearsalhub/routers/songs.py
Normal file
240
api/src/rehearsalhub/routers/songs.py
Normal 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)
|
||||
120
api/src/rehearsalhub/routers/versions.py
Normal file
120
api/src/rehearsalhub/routers/versions.py
Normal 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)
|
||||
22
api/src/rehearsalhub/routers/ws.py
Normal file
22
api/src/rehearsalhub/routers/ws.py
Normal 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)
|
||||
35
api/src/rehearsalhub/schemas/__init__.py
Normal file
35
api/src/rehearsalhub/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
83
api/src/rehearsalhub/schemas/annotation.py
Normal file
83
api/src/rehearsalhub/schemas/annotation.py
Normal 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] = []
|
||||
30
api/src/rehearsalhub/schemas/audio_version.py
Normal file
30
api/src/rehearsalhub/schemas/audio_version.py
Normal 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
|
||||
17
api/src/rehearsalhub/schemas/auth.py
Normal file
17
api/src/rehearsalhub/schemas/auth.py
Normal 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
|
||||
36
api/src/rehearsalhub/schemas/band.py
Normal file
36
api/src/rehearsalhub/schemas/band.py
Normal 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] = []
|
||||
32
api/src/rehearsalhub/schemas/comment.py
Normal file
32
api/src/rehearsalhub/schemas/comment.py
Normal 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"),
|
||||
)
|
||||
27
api/src/rehearsalhub/schemas/invite.py
Normal file
27
api/src/rehearsalhub/schemas/invite.py
Normal 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
|
||||
35
api/src/rehearsalhub/schemas/member.py
Normal file
35
api/src/rehearsalhub/schemas/member.py
Normal 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
|
||||
36
api/src/rehearsalhub/schemas/song.py
Normal file
36
api/src/rehearsalhub/schemas/song.py
Normal 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
|
||||
6
api/src/rehearsalhub/services/__init__.py
Normal file
6
api/src/rehearsalhub/services/__init__.py
Normal 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"]
|
||||
76
api/src/rehearsalhub/services/annotation.py
Normal file
76
api/src/rehearsalhub/services/annotation.py
Normal 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
|
||||
)
|
||||
72
api/src/rehearsalhub/services/auth.py
Normal file
72
api/src/rehearsalhub/services/auth.py
Normal 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
|
||||
51
api/src/rehearsalhub/services/band.py
Normal file
51
api/src/rehearsalhub/services/band.py
Normal 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")
|
||||
92
api/src/rehearsalhub/services/song.py
Normal file
92
api/src/rehearsalhub/services/song.py
Normal 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
|
||||
4
api/src/rehearsalhub/storage/__init__.py
Normal file
4
api/src/rehearsalhub/storage/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from rehearsalhub.storage.nextcloud import NextcloudClient
|
||||
from rehearsalhub.storage.protocol import FileMetadata, StorageClient
|
||||
|
||||
__all__ = ["StorageClient", "FileMetadata", "NextcloudClient"]
|
||||
143
api/src/rehearsalhub/storage/nextcloud.py
Normal file
143
api/src/rehearsalhub/storage/nextcloud.py
Normal 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,
|
||||
)
|
||||
39
api/src/rehearsalhub/storage/protocol.py
Normal file
39
api/src/rehearsalhub/storage/protocol.py
Normal 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."""
|
||||
...
|
||||
46
api/src/rehearsalhub/ws.py
Normal file
46
api/src/rehearsalhub/ws.py
Normal 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()
|
||||
Reference in New Issue
Block a user