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,19 @@
from rehearsalhub.routers.annotations import router as annotations_router
from rehearsalhub.routers.auth import router as auth_router
from rehearsalhub.routers.bands import router as bands_router
from rehearsalhub.routers.internal import router as internal_router
from rehearsalhub.routers.members import router as members_router
from rehearsalhub.routers.songs import router as songs_router
from rehearsalhub.routers.versions import router as versions_router
from rehearsalhub.routers.ws import router as ws_router
__all__ = [
"auth_router",
"bands_router",
"internal_router",
"members_router",
"songs_router",
"versions_router",
"annotations_router",
"ws_router",
]

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,101 @@
"""Internal endpoints — called by trusted services (watcher) on the Docker network."""
import logging
from pathlib import Path
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session
from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.services.song import SongService
log = logging.getLogger(__name__)
router = APIRouter(prefix="/internal", tags=["internal"])
AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus"}
class NcUploadEvent(BaseModel):
nc_file_path: str
nc_file_etag: str | None = None
@router.post("/nc-upload", status_code=200)
async def nc_upload(
event: NcUploadEvent,
session: AsyncSession = Depends(get_session),
):
"""
Called by nc-watcher when a new audio file is detected in Nextcloud.
Parses the path to find/create the band+song and registers a version.
Expected path format: bands/{slug}/[songs/]{folder}/filename.ext
"""
path = event.nc_file_path.lstrip("/")
if Path(path).suffix.lower() not in AUDIO_EXTENSIONS:
return {"status": "skipped", "reason": "not an audio file"}
parts = path.split("/")
if len(parts) < 3 or parts[0] != "bands":
return {"status": "skipped", "reason": "path not under bands/"}
band_slug = parts[1]
band_repo = BandRepository(session)
band = await band_repo.get_by_slug(band_slug)
if band is None:
log.warning("nc-upload: band slug '%s' not found in DB", band_slug)
return {"status": "skipped", "reason": "band not found"}
# Determine song title and folder from remaining path segments
# e.g. bands/my-band/songs/session1/rec.mp3 → folder=bands/my-band/songs/session1/, title=session1
# e.g. bands/my-band/rec.mp3 → folder=bands/my-band/, title=rec
parent = str(Path(path).parent)
nc_folder = parent.rstrip("/") + "/"
title = Path(path).stem if len(parts) == 3 else parts[-2]
version_repo = AudioVersionRepository(session)
if event.nc_file_etag and await version_repo.get_by_etag(event.nc_file_etag):
return {"status": "skipped", "reason": "version already registered"}
song_repo = SongRepository(session)
song = await song_repo.get_by_nc_folder_path(nc_folder)
if song is None:
song = await song_repo.get_by_title_and_band(band.id, title)
if song is None:
song = await song_repo.create(
band_id=band.id,
title=title,
status="jam",
notes=None,
nc_folder_path=nc_folder,
created_by=None,
)
log.info("nc-upload: created song '%s' for band '%s'", title, band_slug)
# Use first member of the band as uploader (best-effort for watcher uploads)
from sqlalchemy import select
from rehearsalhub.db.models import BandMember
result = await session.execute(
select(BandMember.member_id).where(BandMember.band_id == band.id).limit(1)
)
uploader_id = result.scalar_one_or_none()
song_svc = SongService(session)
version = await song_svc.register_version(
song.id,
AudioVersionCreate(
nc_file_path=path,
nc_file_etag=event.nc_file_etag,
format=Path(path).suffix.lstrip(".").lower(),
),
uploader_id,
)
log.info("nc-upload: registered version %s for song '%s'", version.id, song.title)
return {"status": "ok", "version_id": str(version.id), "song_id": str(song.id)}

View File

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

View File

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

View File

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

View File

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