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