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:
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
|
||||
Reference in New Issue
Block a user