Initial commit: RehearsalHub POC

Full-stack self-hosted band rehearsal platform:

Backend (FastAPI + SQLAlchemy 2.0 async):
- Auth with JWT (register, login, /me, settings)
- Band management with Nextcloud folder integration
- Song management with audio version tracking
- Nextcloud scan to auto-import audio files
- Band membership with link-based invite system
- Song comments
- Audio analysis worker (BPM, key, loudness, waveform)
- Nextcloud activity watcher for auto-import
- WebSocket support for real-time annotation updates
- Alembic migrations (0001–0003)
- Repository pattern, Ruff + mypy configured

Frontend (React 18 + Vite + TypeScript strict):
- Login/register page with post-login redirect
- Home page with band list and creation form
- Band page with member panel, invite link, song list, NC scan
- Song page with waveform player, annotations, comment thread
- Settings page for per-user Nextcloud credentials
- Invite acceptance page (/invite/:token)
- ESLint v9 flat config + TypeScript strict mode

Infrastructure:
- Docker Compose: PostgreSQL, Redis, API, worker, watcher, nginx
- nginx reverse proxy for static files + /api/ proxy
- make check runs all linters before docker compose build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Steffen Schuhmann
2026-03-28 21:53:03 +01:00
commit f7be1b994d
139 changed files with 12743 additions and 0 deletions

View File

@@ -0,0 +1,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)