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,203 @@
"""Integration tests for annotation endpoints."""
import uuid
import pytest
from tests.factories import (
create_annotation,
create_audio_version,
create_band,
create_member,
create_song,
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_point_annotation(client, db_session, auth_headers_for, current_member):
band = await create_band(db_session, creator_id=current_member.id)
song = await create_song(db_session, band_id=band.id, creator_id=current_member.id)
version = await create_audio_version(db_session, song_id=song.id, uploader_id=current_member.id)
await db_session.commit()
headers = await auth_headers_for(current_member)
resp = await client.post(
f"/api/v1/versions/{version.id}/annotations",
json={"type": "point", "timestamp_ms": 3000, "body": "Nice groove here", "tags": ["groove"]},
headers=headers,
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["type"] == "point"
assert data["timestamp_ms"] == 3000
assert data["tags"] == ["groove"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_range_annotation(client, db_session, auth_headers_for, current_member):
band = await create_band(db_session, creator_id=current_member.id)
song = await create_song(db_session, band_id=band.id, creator_id=current_member.id)
version = await create_audio_version(db_session, song_id=song.id, uploader_id=current_member.id)
await db_session.commit()
headers = await auth_headers_for(current_member)
resp = await client.post(
f"/api/v1/versions/{version.id}/annotations",
json={
"type": "range",
"timestamp_ms": 5000,
"range_end_ms": 15000,
"label": "Vamp section",
"tags": ["vamp", "groove"],
},
headers=headers,
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["type"] == "range"
assert data["range_end_ms"] == 15000
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_annotation_invalid_range_returns_422(client, db_session, auth_headers_for, current_member):
band = await create_band(db_session, creator_id=current_member.id)
song = await create_song(db_session, band_id=band.id, creator_id=current_member.id)
version = await create_audio_version(db_session, song_id=song.id)
await db_session.commit()
headers = await auth_headers_for(current_member)
resp = await client.post(
f"/api/v1/versions/{version.id}/annotations",
json={"type": "range", "timestamp_ms": 5000, "range_end_ms": 3000},
headers=headers,
)
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_annotations(client, db_session, auth_headers_for, current_member):
band = await create_band(db_session, creator_id=current_member.id)
song = await create_song(db_session, band_id=band.id, creator_id=current_member.id)
version = await create_audio_version(db_session, song_id=song.id)
await create_annotation(db_session, version_id=version.id, author_id=current_member.id, timestamp_ms=1000)
await create_annotation(db_session, version_id=version.id, author_id=current_member.id, timestamp_ms=3000)
await db_session.commit()
headers = await auth_headers_for(current_member)
resp = await client.get(f"/api/v1/versions/{version.id}/annotations", headers=headers)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["timestamp_ms"] == 1000
assert data[1]["timestamp_ms"] == 3000
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_annotation(client, db_session, auth_headers_for, current_member):
band = await create_band(db_session, creator_id=current_member.id)
song = await create_song(db_session, band_id=band.id, creator_id=current_member.id)
version = await create_audio_version(db_session, song_id=song.id)
annotation = await create_annotation(
db_session, version_id=version.id, author_id=current_member.id
)
await db_session.commit()
headers = await auth_headers_for(current_member)
resp = await client.patch(
f"/api/v1/annotations/{annotation.id}",
json={"resolved": True, "label": "Updated label"},
headers=headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["resolved"] is True
assert data["label"] == "Updated label"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_annotation(client, db_session, auth_headers_for, current_member):
band = await create_band(db_session, creator_id=current_member.id)
song = await create_song(db_session, band_id=band.id, creator_id=current_member.id)
version = await create_audio_version(db_session, song_id=song.id)
annotation = await create_annotation(
db_session, version_id=version.id, author_id=current_member.id
)
await db_session.commit()
headers = await auth_headers_for(current_member)
resp = await client.delete(f"/api/v1/annotations/{annotation.id}", headers=headers)
assert resp.status_code == 204
# Should be gone from list
list_resp = await client.get(f"/api/v1/versions/{version.id}/annotations", headers=headers)
assert all(a["id"] != str(annotation.id) for a in list_resp.json())
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_reaction(client, db_session, auth_headers_for, current_member):
band = await create_band(db_session, creator_id=current_member.id)
song = await create_song(db_session, band_id=band.id, creator_id=current_member.id)
version = await create_audio_version(db_session, song_id=song.id)
annotation = await create_annotation(
db_session, version_id=version.id, author_id=current_member.id
)
await db_session.commit()
headers = await auth_headers_for(current_member)
resp = await client.post(
f"/api/v1/annotations/{annotation.id}/reactions",
json={"emoji": "🔥"},
headers=headers,
)
assert resp.status_code == 201
assert resp.json()["emoji"] == "🔥"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_search_ranges(client, db_session, auth_headers_for, current_member):
from rehearsalhub.db.models import RangeAnalysis
from rehearsalhub.repositories.annotation import AnnotationRepository
band = await create_band(db_session, creator_id=current_member.id)
song = await create_song(db_session, band_id=band.id, creator_id=current_member.id)
version = await create_audio_version(db_session, song_id=song.id)
annotation = await create_annotation(
db_session,
version_id=version.id,
author_id=current_member.id,
type="range",
timestamp_ms=5000,
range_end_ms=15000,
tags=["groove"],
)
# Manually insert a range_analysis
ra = RangeAnalysis(
annotation_id=annotation.id,
version_id=version.id,
start_ms=5000,
end_ms=15000,
bpm=120.0,
key="G major",
scale="major",
)
db_session.add(ra)
await db_session.commit()
headers = await auth_headers_for(current_member)
resp = await client.get(
f"/api/v1/bands/{band.id}/search/ranges",
params={"bpm_min": 110, "bpm_max": 130, "key": "G major"},
headers=headers,
)
assert resp.status_code == 200
results = resp.json()
assert len(results) >= 1
assert any(r["bpm"] is not None for r in results)