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
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# ── Security ──────────────────────────────────────────────────────────────────
|
||||
# Generate with: openssl rand -hex 32
|
||||
SECRET_KEY=replace_me_with_32_byte_hex
|
||||
|
||||
# ── Domain ────────────────────────────────────────────────────────────────────
|
||||
DOMAIN=yourdomain.com
|
||||
ACME_EMAIL=admin@yourdomain.com
|
||||
|
||||
# ── PostgreSQL ────────────────────────────────────────────────────────────────
|
||||
POSTGRES_DB=rehearsalhub
|
||||
POSTGRES_USER=rh_user
|
||||
POSTGRES_PASSWORD=change_me
|
||||
|
||||
# ── Nextcloud (external instance) ────────────────────────────────────────────
|
||||
# Full URL to your Nextcloud, e.g. https://cloud.example.com
|
||||
NEXTCLOUD_URL=https://cloud.example.com
|
||||
# A dedicated Nextcloud user for RehearsalHub (admin or a service account)
|
||||
NEXTCLOUD_USER=rh_service
|
||||
NEXTCLOUD_PASS=change_me
|
||||
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
# ── Environment & secrets ─────────────────────────────────────────────────────
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
|
||||
# ── Python ────────────────────────────────────────────────────────────────────
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
.uv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# ── Node / Frontend ───────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
web/dist/
|
||||
web/.vite/
|
||||
*.tsbuildinfo
|
||||
|
||||
# ── Docker ────────────────────────────────────────────────────────────────────
|
||||
docker-compose.override.yml
|
||||
|
||||
# ── Database & storage ────────────────────────────────────────────────────────
|
||||
*.sqlite
|
||||
*.db
|
||||
pgdata/
|
||||
redisdata/
|
||||
|
||||
# ── OS ────────────────────────────────────────────────────────────────────────
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# ── IDE ───────────────────────────────────────────────────────────────────────
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# ── Nextcloud local volumes ───────────────────────────────────────────────────
|
||||
nextcloud/data/
|
||||
nextcloud/config/
|
||||
nextcloud/apps/
|
||||
|
||||
# ── Traefik ───────────────────────────────────────────────────────────────────
|
||||
traefik/acme.json
|
||||
75
Makefile
Normal file
75
Makefile
Normal file
@@ -0,0 +1,75 @@
|
||||
.PHONY: up down build logs migrate seed test test-api test-worker test-watcher lint check format
|
||||
|
||||
up:
|
||||
docker compose up -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
build: check
|
||||
docker compose build
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
|
||||
migrate:
|
||||
docker compose exec api alembic upgrade head
|
||||
|
||||
migrate-auto:
|
||||
docker compose exec api alembic revision --autogenerate -m "$(m)"
|
||||
|
||||
# ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
setup: up
|
||||
@echo "Waiting for Nextcloud to initialize (this can take ~60s)..."
|
||||
@sleep 60
|
||||
bash scripts/nc-setup.sh
|
||||
bash scripts/seed.sh
|
||||
|
||||
# ── Testing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test: test-api test-worker test-watcher
|
||||
|
||||
test-api:
|
||||
cd api && uv run pytest tests/ -v --cov=src/rehearsalhub --cov-report=term-missing
|
||||
|
||||
test-worker:
|
||||
cd worker && uv run pytest tests/ -v --cov=src/worker --cov-report=term-missing
|
||||
|
||||
test-watcher:
|
||||
cd watcher && uv run pytest tests/ -v --cov=src/watcher --cov-report=term-missing
|
||||
|
||||
test-integration:
|
||||
cd api && uv run pytest tests/integration/ -v -m integration
|
||||
|
||||
# ── Linting & type checking ───────────────────────────────────────────────────
|
||||
|
||||
# check: run all linters + type checkers locally (fast, no Docker)
|
||||
check: lint typecheck-web
|
||||
|
||||
lint:
|
||||
cd api && uv run ruff check src/ tests/ && uv run mypy src/
|
||||
cd worker && uv run ruff check src/ tests/
|
||||
cd watcher && uv run ruff check src/ tests/
|
||||
cd web && npm run lint
|
||||
|
||||
typecheck-web:
|
||||
cd web && npm run typecheck
|
||||
|
||||
format:
|
||||
cd api && uv run ruff format src/ tests/
|
||||
cd worker && uv run ruff format src/ tests/
|
||||
cd watcher && uv run ruff format src/ tests/
|
||||
|
||||
# ── Dev helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
shell-api:
|
||||
docker compose exec api bash
|
||||
|
||||
shell-db:
|
||||
docker compose exec db psql -U $${POSTGRES_USER} -d $${POSTGRES_DB}
|
||||
|
||||
shell-redis:
|
||||
docker compose exec redis redis-cli
|
||||
22
api/Dockerfile
Normal file
22
api/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM python:3.12-slim AS base
|
||||
WORKDIR /app
|
||||
RUN pip install uv
|
||||
|
||||
FROM base AS development
|
||||
COPY pyproject.toml .
|
||||
RUN uv sync
|
||||
COPY . .
|
||||
CMD ["uv", "run", "uvicorn", "rehearsalhub.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
FROM base AS lint
|
||||
COPY pyproject.toml .
|
||||
RUN uv sync --frozen
|
||||
COPY src/ src/
|
||||
RUN uv run ruff check src/ && uv run mypy src/
|
||||
|
||||
FROM base AS production
|
||||
COPY pyproject.toml .
|
||||
RUN uv sync --no-dev --frozen || uv sync --no-dev
|
||||
COPY . .
|
||||
ENTRYPOINT ["sh", "entrypoint.sh"]
|
||||
CMD ["uv", "run", "uvicorn", "rehearsalhub.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
||||
38
api/alembic.ini
Normal file
38
api/alembic.ini
Normal file
@@ -0,0 +1,38 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = postgresql+asyncpg://rh_user:change_me@localhost:5432/rehearsalhub
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
0
api/alembic/__init__.py
Normal file
0
api/alembic/__init__.py
Normal file
52
api/alembic/env.py
Normal file
52
api/alembic/env.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from rehearsalhub.db.models import Base
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
return os.environ.get("DATABASE_URL", config.get_main_option("sqlalchemy.url")) # type: ignore
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=get_url(),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online() -> None:
|
||||
url = get_url()
|
||||
connectable = create_async_engine(url, future=True)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
195
api/alembic/versions/0001_initial.py
Normal file
195
api/alembic/versions/0001_initial.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-03-28
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"members",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("email", sa.String(320), nullable=False),
|
||||
sa.Column("display_name", sa.String(255), nullable=False),
|
||||
sa.Column("avatar_url", sa.Text(), nullable=True),
|
||||
sa.Column("nc_username", sa.String(255), nullable=True),
|
||||
sa.Column("password_hash", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("email"),
|
||||
)
|
||||
op.create_index("ix_members_email", "members", ["email"])
|
||||
|
||||
op.create_table(
|
||||
"bands",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("slug", sa.String(255), nullable=False),
|
||||
sa.Column("nc_folder_path", sa.Text(), nullable=True),
|
||||
sa.Column("nc_user", sa.String(255), nullable=True),
|
||||
sa.Column("genre_tags", postgresql.ARRAY(sa.Text()), nullable=False, server_default="{}"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("slug"),
|
||||
)
|
||||
op.create_index("ix_bands_slug", "bands", ["slug"])
|
||||
|
||||
op.create_table(
|
||||
"band_members",
|
||||
sa.Column("band_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("member_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("role", sa.String(20), nullable=False),
|
||||
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("instrument", sa.String(100), nullable=True),
|
||||
sa.ForeignKeyConstraint(["band_id"], ["bands.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["member_id"], ["members.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("band_id", "member_id"),
|
||||
sa.UniqueConstraint("band_id", "member_id", name="uq_band_member"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"songs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("band_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("title", sa.String(500), nullable=False),
|
||||
sa.Column("nc_folder_path", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="jam"),
|
||||
sa.Column("global_key", sa.String(30), nullable=True),
|
||||
sa.Column("global_bpm", sa.Numeric(6, 2), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["band_id"], ["bands.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["members.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_songs_band_id", "songs", ["band_id"])
|
||||
|
||||
op.create_table(
|
||||
"audio_versions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("song_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("version_number", sa.Integer(), nullable=False),
|
||||
sa.Column("label", sa.String(255), nullable=True),
|
||||
sa.Column("nc_file_path", sa.Text(), nullable=False),
|
||||
sa.Column("nc_file_etag", sa.String(255), nullable=True),
|
||||
sa.Column("cdn_hls_base", sa.Text(), nullable=True),
|
||||
sa.Column("waveform_url", sa.Text(), nullable=True),
|
||||
sa.Column("duration_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("format", sa.String(10), nullable=True),
|
||||
sa.Column("file_size_bytes", sa.BigInteger(), nullable=True),
|
||||
sa.Column("analysis_status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("uploaded_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("uploaded_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["song_id"], ["songs.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["uploaded_by"], ["members.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_audio_versions_song_id", "audio_versions", ["song_id"])
|
||||
|
||||
op.create_table(
|
||||
"annotations",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("version_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("author_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("type", sa.String(10), nullable=False),
|
||||
sa.Column("timestamp_ms", sa.Integer(), nullable=False),
|
||||
sa.Column("range_end_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("body", sa.Text(), nullable=True),
|
||||
sa.Column("voice_note_url", sa.Text(), nullable=True),
|
||||
sa.Column("label", sa.String(255), nullable=True),
|
||||
sa.Column("tags", postgresql.ARRAY(sa.Text()), nullable=False, server_default="{}"),
|
||||
sa.Column("parent_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("resolved", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["author_id"], ["members.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["parent_id"], ["annotations.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["version_id"], ["audio_versions.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_annotations_version_id", "annotations", ["version_id"])
|
||||
|
||||
op.create_table(
|
||||
"range_analyses",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("annotation_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("version_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("start_ms", sa.Integer(), nullable=False),
|
||||
sa.Column("end_ms", sa.Integer(), nullable=False),
|
||||
sa.Column("bpm", sa.Numeric(7, 2), nullable=True),
|
||||
sa.Column("bpm_confidence", sa.Numeric(4, 3), nullable=True),
|
||||
sa.Column("key", sa.String(30), nullable=True),
|
||||
sa.Column("key_confidence", sa.Numeric(4, 3), nullable=True),
|
||||
sa.Column("scale", sa.String(10), nullable=True),
|
||||
sa.Column("avg_loudness_lufs", sa.Numeric(6, 2), nullable=True),
|
||||
sa.Column("peak_loudness_dbfs", sa.Numeric(6, 2), nullable=True),
|
||||
sa.Column("spectral_centroid", sa.Numeric(10, 2), nullable=True),
|
||||
sa.Column("energy", sa.Numeric(5, 4), nullable=True),
|
||||
sa.Column("danceability", sa.Numeric(5, 4), nullable=True),
|
||||
sa.Column("chroma_vector", postgresql.ARRAY(sa.Numeric()), nullable=True),
|
||||
sa.Column("mfcc_mean", postgresql.ARRAY(sa.Numeric()), nullable=True),
|
||||
sa.Column("analysis_version", sa.String(20), nullable=True),
|
||||
sa.Column("computed_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["annotation_id"], ["annotations.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["version_id"], ["audio_versions.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("annotation_id"),
|
||||
)
|
||||
op.create_index("ix_range_analyses_version_id", "range_analyses", ["version_id"])
|
||||
|
||||
op.create_table(
|
||||
"reactions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("annotation_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("member_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("emoji", sa.String(10), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["annotation_id"], ["annotations.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["member_id"], ["members.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("annotation_id", "member_id", "emoji", name="uq_reaction"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"jobs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("type", sa.String(50), nullable=False),
|
||||
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="queued"),
|
||||
sa.Column("attempt", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("queued_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_jobs_type", "jobs", ["type"])
|
||||
op.create_index("ix_jobs_status", "jobs", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("jobs")
|
||||
op.drop_table("reactions")
|
||||
op.drop_table("range_analyses")
|
||||
op.drop_table("annotations")
|
||||
op.drop_table("audio_versions")
|
||||
op.drop_table("songs")
|
||||
op.drop_table("band_members")
|
||||
op.drop_table("bands")
|
||||
op.drop_table("members")
|
||||
24
api/alembic/versions/0002_member_nc_config.py
Normal file
24
api/alembic/versions/0002_member_nc_config.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Add nc_url and nc_password to members
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-03-28
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("members", sa.Column("nc_url", sa.Text(), nullable=True))
|
||||
op.add_column("members", sa.Column("nc_password", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("members", "nc_password")
|
||||
op.drop_column("members", "nc_url")
|
||||
43
api/alembic/versions/0003_invites_and_comments.py
Normal file
43
api/alembic/versions/0003_invites_and_comments.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Add band_invites and song_comments tables.
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-03-28
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"band_invites",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("band_id", UUID(as_uuid=True), sa.ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("token", sa.String(64), unique=True, nullable=False, index=True),
|
||||
sa.Column("role", sa.String(20), nullable=False, server_default="member"),
|
||||
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("members.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("used_by", UUID(as_uuid=True), sa.ForeignKey("members.id", ondelete="SET NULL"), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"song_comments",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("song_id", UUID(as_uuid=True), sa.ForeignKey("songs.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("author_id", UUID(as_uuid=True), sa.ForeignKey("members.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("body", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("song_comments")
|
||||
op.drop_table("band_invites")
|
||||
0
api/alembic/versions/__init__.py
Normal file
0
api/alembic/versions/__init__.py
Normal file
8
api/entrypoint.sh
Normal file
8
api/entrypoint.sh
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Running database migrations..."
|
||||
uv run alembic upgrade head
|
||||
|
||||
echo "Starting server..."
|
||||
exec "$@"
|
||||
63
api/pyproject.toml
Normal file
63
api/pyproject.toml
Normal file
@@ -0,0 +1,63 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "rehearsalhub-api"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"sqlalchemy[asyncio]>=2.0",
|
||||
"asyncpg>=0.29",
|
||||
"alembic>=1.13",
|
||||
"pydantic[email]>=2.7",
|
||||
"pydantic-settings>=2.3",
|
||||
"python-jose[cryptography]>=3.3",
|
||||
"bcrypt>=4.1",
|
||||
"httpx>=0.27",
|
||||
"redis[hiredis]>=5.0",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.23",
|
||||
"pytest-cov>=5",
|
||||
"testcontainers[postgres]>=4.7",
|
||||
"ruff>=0.4",
|
||||
"mypy>=1.10",
|
||||
"types-python-jose",
|
||||
"factory-boy>=3.3",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/rehearsalhub"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"integration: marks tests that require external services (deselect with '-m not integration')",
|
||||
"unit: marks fast unit tests with no external deps",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
src = ["src"]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src/rehearsalhub"]
|
||||
omit = ["src/rehearsalhub/db/models.py"]
|
||||
|
||||
0
api/src/rehearsalhub/__init__.py
Normal file
0
api/src/rehearsalhub/__init__.py
Normal file
35
api/src/rehearsalhub/config.py
Normal file
35
api/src/rehearsalhub/config.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
# Security
|
||||
secret_key: str
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||||
|
||||
# Database
|
||||
database_url: str # postgresql+asyncpg://...
|
||||
|
||||
# Redis
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
job_queue_key: str = "rh:jobs"
|
||||
|
||||
# Nextcloud
|
||||
nextcloud_url: str = "http://nextcloud"
|
||||
nextcloud_user: str = "ncadmin"
|
||||
nextcloud_pass: str = ""
|
||||
|
||||
# App
|
||||
domain: str = "localhost"
|
||||
debug: bool = False
|
||||
|
||||
# Worker
|
||||
analysis_version: str = "1.0.0"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings() # type: ignore[call-arg]
|
||||
25
api/src/rehearsalhub/db/__init__.py
Normal file
25
api/src/rehearsalhub/db/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from rehearsalhub.db.models import (
|
||||
Annotation,
|
||||
AudioVersion,
|
||||
Band,
|
||||
BandMember,
|
||||
Base,
|
||||
Job,
|
||||
Member,
|
||||
RangeAnalysis,
|
||||
Reaction,
|
||||
Song,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Member",
|
||||
"Band",
|
||||
"BandMember",
|
||||
"Song",
|
||||
"AudioVersion",
|
||||
"Annotation",
|
||||
"RangeAnalysis",
|
||||
"Reaction",
|
||||
"Job",
|
||||
]
|
||||
48
api/src/rehearsalhub/db/engine.py
Normal file
48
api/src/rehearsalhub/db/engine.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from rehearsalhub.config import get_settings
|
||||
|
||||
_engine = None
|
||||
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
settings = get_settings()
|
||||
_engine = create_async_engine(
|
||||
settings.database_url,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_pre_ping=True,
|
||||
echo=settings.debug,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
global _session_factory
|
||||
if _session_factory is None:
|
||||
_session_factory = async_sessionmaker(
|
||||
get_engine(),
|
||||
expire_on_commit=False,
|
||||
class_=AsyncSession,
|
||||
)
|
||||
return _session_factory
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency that yields an async DB session."""
|
||||
async with get_session_factory()() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
368
api/src/rehearsalhub/db/models.py
Normal file
368
api/src/rehearsalhub/db/models.py
Normal file
@@ -0,0 +1,368 @@
|
||||
"""All SQLAlchemy 2.0 ORM models for RehearsalHub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
# ── Members ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Member(Base):
|
||||
__tablename__ = "members"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(Text)
|
||||
nc_username: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
nc_url: Mapped[Optional[str]] = mapped_column(Text)
|
||||
nc_password: Mapped[Optional[str]] = mapped_column(Text)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
band_memberships: Mapped[list[BandMember]] = relationship(
|
||||
"BandMember", back_populates="member", cascade="all, delete-orphan"
|
||||
)
|
||||
authored_songs: Mapped[list[Song]] = relationship("Song", back_populates="creator")
|
||||
uploaded_versions: Mapped[list[AudioVersion]] = relationship(
|
||||
"AudioVersion", back_populates="uploader"
|
||||
)
|
||||
annotations: Mapped[list[Annotation]] = relationship(
|
||||
"Annotation", back_populates="author", foreign_keys="Annotation.author_id"
|
||||
)
|
||||
reactions: Mapped[list[Reaction]] = relationship(
|
||||
"Reaction", back_populates="member", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
# ── Bands ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Band(Base):
|
||||
__tablename__ = "bands"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
|
||||
nc_user: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
genre_tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
memberships: Mapped[list[BandMember]] = relationship(
|
||||
"BandMember", back_populates="band", cascade="all, delete-orphan"
|
||||
)
|
||||
songs: Mapped[list[Song]] = relationship(
|
||||
"Song", back_populates="band", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class BandMember(Base):
|
||||
__tablename__ = "band_members"
|
||||
__table_args__ = (UniqueConstraint("band_id", "member_id", name="uq_band_member"),)
|
||||
|
||||
band_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
member_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
|
||||
joined_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
instrument: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
|
||||
band: Mapped[Band] = relationship("Band", back_populates="memberships")
|
||||
member: Mapped[Member] = relationship("Member", back_populates="band_memberships")
|
||||
|
||||
|
||||
class BandInvite(Base):
|
||||
__tablename__ = "band_invites"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
band_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
used_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
|
||||
)
|
||||
|
||||
band: Mapped[Band] = relationship("Band")
|
||||
creator: Mapped[Member] = relationship("Member", foreign_keys=[created_by])
|
||||
|
||||
|
||||
# ── Songs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Song(Base):
|
||||
__tablename__ = "songs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
band_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="jam")
|
||||
global_key: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
global_bpm: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
|
||||
notes: Mapped[Optional[str]] = mapped_column(Text)
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
band: Mapped[Band] = relationship("Band", back_populates="songs")
|
||||
creator: Mapped[Optional[Member]] = relationship("Member", back_populates="authored_songs")
|
||||
versions: Mapped[list[AudioVersion]] = relationship(
|
||||
"AudioVersion", back_populates="song", cascade="all, delete-orphan"
|
||||
)
|
||||
comments: Mapped[list[SongComment]] = relationship(
|
||||
"SongComment", back_populates="song", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class SongComment(Base):
|
||||
__tablename__ = "song_comments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
song_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("songs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
author_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
song: Mapped[Song] = relationship("Song", back_populates="comments")
|
||||
author: Mapped[Member] = relationship("Member")
|
||||
|
||||
|
||||
# ── Audio Versions ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AudioVersion(Base):
|
||||
__tablename__ = "audio_versions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
song_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("songs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
label: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
nc_file_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
|
||||
waveform_url: Mapped[Optional[str]] = mapped_column(Text)
|
||||
duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
format: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||
analysis_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
uploaded_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
|
||||
)
|
||||
uploaded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
song: Mapped[Song] = relationship("Song", back_populates="versions")
|
||||
uploader: Mapped[Optional[Member]] = relationship(
|
||||
"Member", back_populates="uploaded_versions"
|
||||
)
|
||||
annotations: Mapped[list[Annotation]] = relationship(
|
||||
"Annotation", back_populates="version", cascade="all, delete-orphan"
|
||||
)
|
||||
range_analyses: Mapped[list[RangeAnalysis]] = relationship(
|
||||
"RangeAnalysis", back_populates="version", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
# ── Annotations ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Annotation(Base):
|
||||
__tablename__ = "annotations"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
version_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("audio_versions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
author_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(10), nullable=False) # 'point' | 'range'
|
||||
timestamp_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
range_end_ms: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
body: Mapped[Optional[str]] = mapped_column(Text)
|
||||
voice_note_url: Mapped[Optional[str]] = mapped_column(Text)
|
||||
label: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
|
||||
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("annotations.id", ondelete="SET NULL")
|
||||
)
|
||||
resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
version: Mapped[AudioVersion] = relationship("AudioVersion", back_populates="annotations")
|
||||
author: Mapped[Member] = relationship(
|
||||
"Member", back_populates="annotations", foreign_keys=[author_id]
|
||||
)
|
||||
replies: Mapped[list[Annotation]] = relationship(
|
||||
"Annotation", foreign_keys=[parent_id], back_populates="parent"
|
||||
)
|
||||
parent: Mapped[Optional[Annotation]] = relationship(
|
||||
"Annotation", foreign_keys=[parent_id], back_populates="replies", remote_side=[id]
|
||||
)
|
||||
reactions: Mapped[list[Reaction]] = relationship(
|
||||
"Reaction", back_populates="annotation", cascade="all, delete-orphan"
|
||||
)
|
||||
range_analysis: Mapped[Optional[RangeAnalysis]] = relationship(
|
||||
"RangeAnalysis", back_populates="annotation", uselist=False
|
||||
)
|
||||
|
||||
|
||||
# ── Range Analyses ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RangeAnalysis(Base):
|
||||
__tablename__ = "range_analyses"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
annotation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("annotations.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
)
|
||||
version_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("audio_versions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
start_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
end_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
bpm: Mapped[Optional[float]] = mapped_column(Numeric(7, 2))
|
||||
bpm_confidence: Mapped[Optional[float]] = mapped_column(Numeric(4, 3))
|
||||
key: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
key_confidence: Mapped[Optional[float]] = mapped_column(Numeric(4, 3))
|
||||
scale: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
avg_loudness_lufs: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
|
||||
peak_loudness_dbfs: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
|
||||
spectral_centroid: Mapped[Optional[float]] = mapped_column(Numeric(10, 2))
|
||||
energy: Mapped[Optional[float]] = mapped_column(Numeric(5, 4))
|
||||
danceability: Mapped[Optional[float]] = mapped_column(Numeric(5, 4))
|
||||
chroma_vector: Mapped[Optional[list[float]]] = mapped_column(ARRAY(Numeric))
|
||||
mfcc_mean: Mapped[Optional[list[float]]] = mapped_column(ARRAY(Numeric))
|
||||
analysis_version: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
computed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
annotation: Mapped[Annotation] = relationship(
|
||||
"Annotation", back_populates="range_analysis"
|
||||
)
|
||||
version: Mapped[AudioVersion] = relationship(
|
||||
"AudioVersion", back_populates="range_analyses"
|
||||
)
|
||||
|
||||
|
||||
# ── Reactions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Reaction(Base):
|
||||
__tablename__ = "reactions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("annotation_id", "member_id", "emoji", name="uq_reaction"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
annotation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("annotations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
member_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
emoji: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
annotation: Mapped[Annotation] = relationship("Annotation", back_populates="reactions")
|
||||
member: Mapped[Member] = relationship("Member", back_populates="reactions")
|
||||
|
||||
|
||||
# ── Jobs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True)
|
||||
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
error: Mapped[Optional[str]] = mapped_column(Text)
|
||||
queued_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
41
api/src/rehearsalhub/dependencies.py
Normal file
41
api/src/rehearsalhub/dependencies.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""FastAPI dependency providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.db.engine import get_session
|
||||
from rehearsalhub.db.models import Member
|
||||
from rehearsalhub.services.auth import decode_token
|
||||
from rehearsalhub.repositories.member import MemberRepository
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_member(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Member:
|
||||
credentials_exc = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
member_id_str: str | None = payload.get("sub")
|
||||
if member_id_str is None:
|
||||
raise credentials_exc
|
||||
member_id = uuid.UUID(member_id_str)
|
||||
except Exception:
|
||||
raise credentials_exc
|
||||
|
||||
repo = MemberRepository(session)
|
||||
member = await repo.get_by_id(member_id)
|
||||
if member is None:
|
||||
raise credentials_exc
|
||||
return member
|
||||
68
api/src/rehearsalhub/main.py
Normal file
68
api/src/rehearsalhub/main.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""RehearsalHub FastAPI application entry point."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from rehearsalhub.config import get_settings
|
||||
from rehearsalhub.routers import (
|
||||
annotations_router,
|
||||
auth_router,
|
||||
bands_router,
|
||||
internal_router,
|
||||
members_router,
|
||||
songs_router,
|
||||
versions_router,
|
||||
ws_router,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
# Clean up DB connections on shutdown
|
||||
from rehearsalhub.db.engine import get_engine
|
||||
|
||||
engine = get_engine()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="RehearsalHub API",
|
||||
version="0.1.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[f"https://{settings.domain}", "http://localhost:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
prefix = "/api/v1"
|
||||
app.include_router(auth_router, prefix=prefix)
|
||||
app.include_router(bands_router, prefix=prefix)
|
||||
app.include_router(songs_router, prefix=prefix)
|
||||
app.include_router(versions_router, prefix=prefix)
|
||||
app.include_router(annotations_router, prefix=prefix)
|
||||
app.include_router(members_router, prefix=prefix)
|
||||
app.include_router(internal_router, prefix=prefix)
|
||||
app.include_router(ws_router) # WebSocket routes don't use /api/v1 prefix
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
4
api/src/rehearsalhub/queue/__init__.py
Normal file
4
api/src/rehearsalhub/queue/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from rehearsalhub.queue.protocol import JobQueue
|
||||
from rehearsalhub.queue.redis_queue import RedisJobQueue
|
||||
|
||||
__all__ = ["JobQueue", "RedisJobQueue"]
|
||||
28
api/src/rehearsalhub/queue/protocol.py
Normal file
28
api/src/rehearsalhub/queue/protocol.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Job queue abstraction. Swap Redis for any other backend by implementing this Protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class JobQueue(Protocol):
|
||||
async def enqueue(self, job_type: str, payload: dict[str, Any]) -> uuid.UUID:
|
||||
"""Persist job to DB + push UUID onto queue. Returns the job UUID."""
|
||||
...
|
||||
|
||||
async def dequeue(self, timeout: int = 5) -> tuple[uuid.UUID, str, dict[str, Any]] | None:
|
||||
"""Block up to `timeout` seconds for a job. Returns (id, type, payload) or None."""
|
||||
...
|
||||
|
||||
async def mark_running(self, job_id: uuid.UUID) -> None:
|
||||
"""Mark a job as running. Called by the worker when it picks up the job."""
|
||||
...
|
||||
|
||||
async def mark_done(self, job_id: uuid.UUID) -> None:
|
||||
"""Mark a job as successfully completed."""
|
||||
...
|
||||
|
||||
async def mark_failed(self, job_id: uuid.UUID, error: str) -> None:
|
||||
"""Mark a job as failed with an error message. Increments attempt counter."""
|
||||
...
|
||||
81
api/src/rehearsalhub/queue/redis_queue.py
Normal file
81
api/src/rehearsalhub/queue/redis_queue.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Redis-backed job queue.
|
||||
|
||||
Strategy: Postgres is the source of truth (durable audit log + retry counts).
|
||||
Redis holds a list of job UUIDs for fast signaling. Workers pop a UUID, load
|
||||
the full payload from Postgres, process, then update status in Postgres.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.config import get_settings
|
||||
from rehearsalhub.db.models import Job
|
||||
|
||||
|
||||
class RedisJobQueue:
|
||||
def __init__(self, session: AsyncSession, redis_client: aioredis.Redis | None = None) -> None:
|
||||
self._session = session
|
||||
self._redis: aioredis.Redis | None = redis_client
|
||||
|
||||
async def _get_redis(self) -> aioredis.Redis:
|
||||
if self._redis is None:
|
||||
self._redis = aioredis.from_url(get_settings().redis_url, decode_responses=True)
|
||||
return self._redis
|
||||
|
||||
async def enqueue(self, job_type: str, payload: dict[str, Any]) -> uuid.UUID:
|
||||
job = Job(type=job_type, payload=payload, status="queued")
|
||||
self._session.add(job)
|
||||
await self._session.flush()
|
||||
await self._session.refresh(job)
|
||||
|
||||
r = await self._get_redis()
|
||||
queue_key = get_settings().job_queue_key
|
||||
await r.rpush(queue_key, str(job.id))
|
||||
return job.id
|
||||
|
||||
async def dequeue(self, timeout: int = 5) -> tuple[uuid.UUID, str, dict[str, Any]] | None:
|
||||
r = await self._get_redis()
|
||||
queue_key = get_settings().job_queue_key
|
||||
result = await r.blpop(queue_key, timeout=timeout)
|
||||
if result is None:
|
||||
return None
|
||||
_, raw_id = result
|
||||
job_id = uuid.UUID(raw_id)
|
||||
job = await self._session.get(Job, job_id)
|
||||
if job is None:
|
||||
return None
|
||||
return job.id, job.type, job.payload
|
||||
|
||||
async def mark_running(self, job_id: uuid.UUID) -> None:
|
||||
job = await self._session.get(Job, job_id)
|
||||
if job:
|
||||
job.status = "running"
|
||||
job.started_at = datetime.now(timezone.utc)
|
||||
job.attempt = (job.attempt or 0) + 1
|
||||
await self._session.flush()
|
||||
|
||||
async def mark_done(self, job_id: uuid.UUID) -> None:
|
||||
job = await self._session.get(Job, job_id)
|
||||
if job:
|
||||
job.status = "done"
|
||||
job.finished_at = datetime.now(timezone.utc)
|
||||
await self._session.flush()
|
||||
|
||||
async def mark_failed(self, job_id: uuid.UUID, error: str) -> None:
|
||||
job = await self._session.get(Job, job_id)
|
||||
if job:
|
||||
job.status = "failed"
|
||||
job.error = error[:2000]
|
||||
job.finished_at = datetime.now(timezone.utc)
|
||||
await self._session.flush()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._redis:
|
||||
await self._redis.aclose()
|
||||
19
api/src/rehearsalhub/repositories/__init__.py
Normal file
19
api/src/rehearsalhub/repositories/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from rehearsalhub.repositories.annotation import AnnotationRepository
|
||||
from rehearsalhub.repositories.audio_version import AudioVersionRepository
|
||||
from rehearsalhub.repositories.band import BandRepository
|
||||
from rehearsalhub.repositories.base import BaseRepository
|
||||
from rehearsalhub.repositories.job import JobRepository
|
||||
from rehearsalhub.repositories.member import MemberRepository
|
||||
from rehearsalhub.repositories.reaction import ReactionRepository
|
||||
from rehearsalhub.repositories.song import SongRepository
|
||||
|
||||
__all__ = [
|
||||
"BaseRepository",
|
||||
"BandRepository",
|
||||
"MemberRepository",
|
||||
"SongRepository",
|
||||
"AudioVersionRepository",
|
||||
"AnnotationRepository",
|
||||
"ReactionRepository",
|
||||
"JobRepository",
|
||||
]
|
||||
113
api/src/rehearsalhub/repositories/annotation.py
Normal file
113
api/src/rehearsalhub/repositories/annotation.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from rehearsalhub.db.models import Annotation, RangeAnalysis
|
||||
from rehearsalhub.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class AnnotationRepository(BaseRepository[Annotation]):
|
||||
model = Annotation
|
||||
|
||||
async def list_for_version(self, version_id: uuid.UUID) -> list[Annotation]:
|
||||
stmt = (
|
||||
select(Annotation)
|
||||
.where(
|
||||
Annotation.version_id == version_id,
|
||||
Annotation.deleted_at.is_(None),
|
||||
)
|
||||
.options(
|
||||
selectinload(Annotation.range_analysis),
|
||||
selectinload(Annotation.reactions),
|
||||
selectinload(Annotation.author),
|
||||
)
|
||||
.order_by(Annotation.timestamp_ms)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def soft_delete(self, annotation: Annotation) -> None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
annotation.deleted_at = datetime.now(timezone.utc)
|
||||
await self.session.flush()
|
||||
|
||||
async def search_ranges(
|
||||
self,
|
||||
band_id: uuid.UUID,
|
||||
bpm_min: float | None = None,
|
||||
bpm_max: float | None = None,
|
||||
key: str | None = None,
|
||||
tag: str | None = None,
|
||||
min_duration_ms: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
from rehearsalhub.db.models import AudioVersion, RangeAnalysis, Song
|
||||
|
||||
conditions = [
|
||||
Song.band_id == band_id,
|
||||
Annotation.type == "range",
|
||||
Annotation.deleted_at.is_(None),
|
||||
]
|
||||
if bpm_min is not None:
|
||||
conditions.append(RangeAnalysis.bpm >= bpm_min)
|
||||
if bpm_max is not None:
|
||||
conditions.append(RangeAnalysis.bpm <= bpm_max)
|
||||
if key is not None:
|
||||
conditions.append(RangeAnalysis.key.ilike(f"%{key}%"))
|
||||
if tag is not None:
|
||||
conditions.append(Annotation.tags.any(tag))
|
||||
if min_duration_ms is not None:
|
||||
conditions.append(
|
||||
(Annotation.range_end_ms - Annotation.timestamp_ms) >= min_duration_ms
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Annotation.id.label("annotation_id"),
|
||||
Song.title.label("song_title"),
|
||||
Song.id.label("song_id"),
|
||||
AudioVersion.id.label("version_id"),
|
||||
AudioVersion.label.label("version_label"),
|
||||
Annotation.timestamp_ms.label("start_ms"),
|
||||
Annotation.range_end_ms.label("end_ms"),
|
||||
Annotation.label.label("label"),
|
||||
Annotation.tags.label("tags"),
|
||||
RangeAnalysis.bpm,
|
||||
RangeAnalysis.key,
|
||||
RangeAnalysis.scale,
|
||||
RangeAnalysis.avg_loudness_lufs,
|
||||
RangeAnalysis.energy,
|
||||
)
|
||||
.join(AudioVersion, Annotation.version_id == AudioVersion.id)
|
||||
.join(Song, AudioVersion.song_id == Song.id)
|
||||
.join(RangeAnalysis, RangeAnalysis.annotation_id == Annotation.id)
|
||||
.where(and_(*conditions))
|
||||
.order_by(Annotation.timestamp_ms)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return [row._asdict() for row in result]
|
||||
|
||||
async def list_all_ranges_for_band(self, band_id: uuid.UUID) -> list[Annotation]:
|
||||
from rehearsalhub.db.models import AudioVersion, Song
|
||||
|
||||
stmt = (
|
||||
select(Annotation)
|
||||
.join(AudioVersion, Annotation.version_id == AudioVersion.id)
|
||||
.join(Song, AudioVersion.song_id == Song.id)
|
||||
.where(
|
||||
Song.band_id == band_id,
|
||||
Annotation.type == "range",
|
||||
Annotation.deleted_at.is_(None),
|
||||
)
|
||||
.options(
|
||||
selectinload(Annotation.range_analysis),
|
||||
selectinload(Annotation.author),
|
||||
)
|
||||
.order_by(Annotation.created_at.desc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
54
api/src/rehearsalhub/repositories/audio_version.py
Normal file
54
api/src/rehearsalhub/repositories/audio_version.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from rehearsalhub.db.models import AudioVersion
|
||||
from rehearsalhub.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class AudioVersionRepository(BaseRepository[AudioVersion]):
|
||||
model = AudioVersion
|
||||
|
||||
async def get_by_etag(self, nc_file_etag: str) -> AudioVersion | None:
|
||||
stmt = select(AudioVersion).where(AudioVersion.nc_file_etag == nc_file_etag)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_for_song(self, song_id: uuid.UUID) -> list[AudioVersion]:
|
||||
stmt = (
|
||||
select(AudioVersion)
|
||||
.where(AudioVersion.song_id == song_id)
|
||||
.order_by(AudioVersion.version_number.desc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_latest_for_song(self, song_id: uuid.UUID) -> AudioVersion | None:
|
||||
stmt = (
|
||||
select(AudioVersion)
|
||||
.where(AudioVersion.song_id == song_id)
|
||||
.order_by(AudioVersion.version_number.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_with_annotations(self, version_id: uuid.UUID) -> AudioVersion | None:
|
||||
from rehearsalhub.db.models import Annotation, RangeAnalysis
|
||||
|
||||
stmt = (
|
||||
select(AudioVersion)
|
||||
.options(
|
||||
selectinload(AudioVersion.annotations).selectinload(
|
||||
Annotation.range_analysis
|
||||
),
|
||||
selectinload(AudioVersion.annotations).selectinload(Annotation.reactions),
|
||||
selectinload(AudioVersion.annotations).selectinload(Annotation.author),
|
||||
)
|
||||
.where(AudioVersion.id == version_id)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
92
api/src/rehearsalhub/repositories/band.py
Normal file
92
api/src/rehearsalhub/repositories/band.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from rehearsalhub.db.models import Band, BandInvite, BandMember
|
||||
from rehearsalhub.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class BandRepository(BaseRepository[Band]):
|
||||
model = Band
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Band | None:
|
||||
stmt = select(Band).where(Band.slug == slug)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_with_members(self, band_id: uuid.UUID) -> Band | None:
|
||||
stmt = (
|
||||
select(Band)
|
||||
.options(selectinload(Band.memberships).selectinload(BandMember.member))
|
||||
.where(Band.id == band_id)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_member_role(self, band_id: uuid.UUID, member_id: uuid.UUID) -> str | None:
|
||||
stmt = select(BandMember.role).where(
|
||||
BandMember.band_id == band_id, BandMember.member_id == member_id
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def add_member(
|
||||
self,
|
||||
band_id: uuid.UUID,
|
||||
member_id: uuid.UUID,
|
||||
role: str = "member",
|
||||
instrument: str | None = None,
|
||||
) -> BandMember:
|
||||
bm = BandMember(band_id=band_id, member_id=member_id, role=role, instrument=instrument)
|
||||
self.session.add(bm)
|
||||
await self.session.flush()
|
||||
return bm
|
||||
|
||||
async def is_member(self, band_id: uuid.UUID, member_id: uuid.UUID) -> bool:
|
||||
return await self.get_member_role(band_id, member_id) is not None
|
||||
|
||||
async def remove_member(self, band_id: uuid.UUID, member_id: uuid.UUID) -> None:
|
||||
stmt = select(BandMember).where(
|
||||
BandMember.band_id == band_id, BandMember.member_id == member_id
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
bm = result.scalar_one_or_none()
|
||||
if bm:
|
||||
await self.session.delete(bm)
|
||||
await self.session.flush()
|
||||
|
||||
async def create_invite(
|
||||
self, band_id: uuid.UUID, created_by: uuid.UUID, role: str = "member", ttl_hours: int = 72
|
||||
) -> BandInvite:
|
||||
invite = BandInvite(
|
||||
band_id=band_id,
|
||||
token=secrets.token_urlsafe(32),
|
||||
role=role,
|
||||
created_by=created_by,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=ttl_hours),
|
||||
)
|
||||
self.session.add(invite)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(invite)
|
||||
return invite
|
||||
|
||||
async def get_invite_by_token(self, token: str) -> BandInvite | None:
|
||||
stmt = select(BandInvite).where(BandInvite.token == token)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_for_member(self, member_id: uuid.UUID) -> list[Band]:
|
||||
stmt = (
|
||||
select(Band)
|
||||
.join(BandMember, BandMember.band_id == Band.id)
|
||||
.where(BandMember.member_id == member_id)
|
||||
.order_by(Band.name)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
53
api/src/rehearsalhub/repositories/base.py
Normal file
53
api/src/rehearsalhub/repositories/base.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Generic async repository. All concrete repos extend BaseRepository[T]."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Generic, Sequence, TypeVar
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.db.models import Base
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=Base)
|
||||
|
||||
|
||||
class BaseRepository(Generic[ModelT]):
|
||||
model: type[ModelT]
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get_by_id(self, id: uuid.UUID) -> ModelT | None:
|
||||
return await self.session.get(self.model, id)
|
||||
|
||||
async def list(self, **filters: Any) -> Sequence[ModelT]:
|
||||
stmt = select(self.model).filter_by(**filters)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def create(self, **kwargs: Any) -> ModelT:
|
||||
obj = self.model(**kwargs)
|
||||
self.session.add(obj)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(obj)
|
||||
return obj
|
||||
|
||||
async def update(self, obj: ModelT, **kwargs: Any) -> ModelT:
|
||||
for key, value in kwargs.items():
|
||||
setattr(obj, key, value)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(obj)
|
||||
return obj
|
||||
|
||||
async def delete(self, obj: ModelT) -> None:
|
||||
await self.session.delete(obj)
|
||||
await self.session.flush()
|
||||
|
||||
async def count(self, **filters: Any) -> int:
|
||||
from sqlalchemy import func, select
|
||||
|
||||
stmt = select(func.count()).select_from(self.model).filter_by(**filters)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
32
api/src/rehearsalhub/repositories/comment.py
Normal file
32
api/src/rehearsalhub/repositories/comment.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from rehearsalhub.db.models import SongComment
|
||||
from rehearsalhub.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class CommentRepository(BaseRepository[SongComment]):
|
||||
model = SongComment
|
||||
|
||||
async def list_for_song(self, song_id: uuid.UUID) -> list[SongComment]:
|
||||
stmt = (
|
||||
select(SongComment)
|
||||
.options(selectinload(SongComment.author))
|
||||
.where(SongComment.song_id == song_id)
|
||||
.order_by(SongComment.created_at)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_with_author(self, comment_id: uuid.UUID) -> SongComment | None:
|
||||
stmt = (
|
||||
select(SongComment)
|
||||
.options(selectinload(SongComment.author))
|
||||
.where(SongComment.id == comment_id)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
47
api/src/rehearsalhub/repositories/job.py
Normal file
47
api/src/rehearsalhub/repositories/job.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from rehearsalhub.db.models import Job
|
||||
from rehearsalhub.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class JobRepository(BaseRepository[Job]):
|
||||
model = Job
|
||||
|
||||
async def list_pending(self, job_type: str | None = None) -> list[Job]:
|
||||
stmt = select(Job).where(Job.status.in_(["queued", "running"]))
|
||||
if job_type:
|
||||
stmt = stmt.where(Job.type == job_type)
|
||||
stmt = stmt.order_by(Job.queued_at)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def mark_running(self, job_id: uuid.UUID) -> Job | None:
|
||||
job = await self.get_by_id(job_id)
|
||||
if job:
|
||||
job.status = "running"
|
||||
job.started_at = datetime.now(timezone.utc)
|
||||
job.attempt = (job.attempt or 0) + 1
|
||||
await self.session.flush()
|
||||
return job
|
||||
|
||||
async def mark_done(self, job_id: uuid.UUID) -> Job | None:
|
||||
job = await self.get_by_id(job_id)
|
||||
if job:
|
||||
job.status = "done"
|
||||
job.finished_at = datetime.now(timezone.utc)
|
||||
await self.session.flush()
|
||||
return job
|
||||
|
||||
async def mark_failed(self, job_id: uuid.UUID, error: str) -> Job | None:
|
||||
job = await self.get_by_id(job_id)
|
||||
if job:
|
||||
job.status = "failed"
|
||||
job.error = error[:2000]
|
||||
job.finished_at = datetime.now(timezone.utc)
|
||||
await self.session.flush()
|
||||
return job
|
||||
18
api/src/rehearsalhub/repositories/member.py
Normal file
18
api/src/rehearsalhub/repositories/member.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from rehearsalhub.db.models import Member
|
||||
from rehearsalhub.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class MemberRepository(BaseRepository[Member]):
|
||||
model = Member
|
||||
|
||||
async def get_by_email(self, email: str) -> Member | None:
|
||||
stmt = select(Member).where(Member.email == email.lower())
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def email_exists(self, email: str) -> bool:
|
||||
return await self.get_by_email(email) is not None
|
||||
23
api/src/rehearsalhub/repositories/reaction.py
Normal file
23
api/src/rehearsalhub/repositories/reaction.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from rehearsalhub.db.models import Reaction
|
||||
from rehearsalhub.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class ReactionRepository(BaseRepository[Reaction]):
|
||||
model = Reaction
|
||||
|
||||
async def get_existing(
|
||||
self, annotation_id: uuid.UUID, member_id: uuid.UUID, emoji: str
|
||||
) -> Reaction | None:
|
||||
stmt = select(Reaction).where(
|
||||
Reaction.annotation_id == annotation_id,
|
||||
Reaction.member_id == member_id,
|
||||
Reaction.emoji == emoji,
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
51
api/src/rehearsalhub/repositories/song.py
Normal file
51
api/src/rehearsalhub/repositories/song.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from rehearsalhub.db.models import AudioVersion, Song
|
||||
from rehearsalhub.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class SongRepository(BaseRepository[Song]):
|
||||
model = Song
|
||||
|
||||
async def list_for_band(self, band_id: uuid.UUID) -> list[Song]:
|
||||
stmt = (
|
||||
select(Song)
|
||||
.where(Song.band_id == band_id)
|
||||
.options(selectinload(Song.versions))
|
||||
.order_by(Song.updated_at.desc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_with_versions(self, song_id: uuid.UUID) -> Song | None:
|
||||
stmt = (
|
||||
select(Song)
|
||||
.options(selectinload(Song.versions))
|
||||
.where(Song.id == song_id)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_nc_folder_path(self, nc_folder_path: str) -> "Song | None":
|
||||
stmt = select(Song).where(Song.nc_folder_path == nc_folder_path)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_title_and_band(self, band_id: uuid.UUID, title: str) -> "Song | None":
|
||||
stmt = select(Song).where(Song.band_id == band_id, Song.title == title)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def next_version_number(self, song_id: uuid.UUID) -> int:
|
||||
from sqlalchemy import func
|
||||
|
||||
stmt = select(func.coalesce(func.max(AudioVersion.version_number), 0) + 1).where(
|
||||
AudioVersion.song_id == song_id
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
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)
|
||||
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
|
||||
6
api/src/rehearsalhub/services/__init__.py
Normal file
6
api/src/rehearsalhub/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from rehearsalhub.services.annotation import AnnotationService
|
||||
from rehearsalhub.services.auth import AuthService
|
||||
from rehearsalhub.services.band import BandService
|
||||
from rehearsalhub.services.song import SongService
|
||||
|
||||
__all__ = ["AuthService", "BandService", "SongService", "AnnotationService"]
|
||||
76
api/src/rehearsalhub/services/annotation.py
Normal file
76
api/src/rehearsalhub/services/annotation.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.db.models import Annotation, Reaction
|
||||
from rehearsalhub.queue.redis_queue import RedisJobQueue
|
||||
from rehearsalhub.repositories.annotation import AnnotationRepository
|
||||
from rehearsalhub.repositories.reaction import ReactionRepository
|
||||
from rehearsalhub.schemas.annotation import AnnotationCreate, AnnotationUpdate
|
||||
|
||||
|
||||
class AnnotationService:
|
||||
def __init__(self, session: AsyncSession, job_queue: RedisJobQueue | None = None) -> None:
|
||||
self._repo = AnnotationRepository(session)
|
||||
self._reaction_repo = ReactionRepository(session)
|
||||
self._queue = job_queue or RedisJobQueue(session)
|
||||
self._session = session
|
||||
|
||||
async def create_annotation(
|
||||
self,
|
||||
version_id: uuid.UUID,
|
||||
author_id: uuid.UUID,
|
||||
data: AnnotationCreate,
|
||||
) -> Annotation:
|
||||
annotation = await self._repo.create(
|
||||
version_id=version_id,
|
||||
author_id=author_id,
|
||||
type=data.type,
|
||||
timestamp_ms=data.timestamp_ms,
|
||||
range_end_ms=data.range_end_ms,
|
||||
body=data.body,
|
||||
label=data.label,
|
||||
tags=data.tags,
|
||||
parent_id=data.parent_id,
|
||||
)
|
||||
|
||||
if data.type == "range":
|
||||
await self._queue.enqueue(
|
||||
"analyse_range",
|
||||
{
|
||||
"annotation_id": str(annotation.id),
|
||||
"version_id": str(version_id),
|
||||
"start_ms": data.timestamp_ms,
|
||||
"end_ms": data.range_end_ms,
|
||||
},
|
||||
)
|
||||
|
||||
return annotation
|
||||
|
||||
async def update_annotation(
|
||||
self,
|
||||
annotation: Annotation,
|
||||
author_id: uuid.UUID,
|
||||
data: AnnotationUpdate,
|
||||
) -> Annotation:
|
||||
if annotation.author_id != author_id:
|
||||
raise PermissionError("Only the author can edit an annotation")
|
||||
kwargs = {k: v for k, v in data.model_dump(exclude_none=True).items()}
|
||||
return await self._repo.update(annotation, **kwargs)
|
||||
|
||||
async def delete_annotation(self, annotation: Annotation, member_id: uuid.UUID) -> None:
|
||||
if annotation.author_id != member_id:
|
||||
raise PermissionError("Only the author can delete an annotation")
|
||||
await self._repo.soft_delete(annotation)
|
||||
|
||||
async def add_reaction(
|
||||
self, annotation_id: uuid.UUID, member_id: uuid.UUID, emoji: str
|
||||
) -> Reaction:
|
||||
existing = await self._reaction_repo.get_existing(annotation_id, member_id, emoji)
|
||||
if existing:
|
||||
return existing
|
||||
return await self._reaction_repo.create(
|
||||
annotation_id=annotation_id, member_id=member_id, emoji=emoji
|
||||
)
|
||||
72
api/src/rehearsalhub/services/auth.py
Normal file
72
api/src/rehearsalhub/services/auth.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Auth service: password hashing, JWT creation/verification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.config import get_settings
|
||||
from rehearsalhub.db.models import Member
|
||||
from rehearsalhub.repositories.member import MemberRepository
|
||||
from rehearsalhub.schemas.auth import RegisterRequest, TokenResponse
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def create_access_token(member_id: str, email: str) -> str:
|
||||
settings = get_settings()
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {
|
||||
"sub": member_id,
|
||||
"email": email,
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
settings = get_settings()
|
||||
return jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._repo = MemberRepository(session)
|
||||
self._session = session
|
||||
|
||||
async def register(self, req: RegisterRequest) -> Member:
|
||||
if await self._repo.email_exists(req.email):
|
||||
raise ValueError(f"Email already registered: {req.email}")
|
||||
member = await self._repo.create(
|
||||
email=req.email.lower(),
|
||||
display_name=req.display_name,
|
||||
password_hash=hash_password(req.password),
|
||||
)
|
||||
return member
|
||||
|
||||
async def login(self, email: str, password: str) -> TokenResponse | None:
|
||||
member = await self._repo.get_by_email(email)
|
||||
if member is None or not verify_password(password, member.password_hash):
|
||||
return None
|
||||
token = create_access_token(str(member.id), member.email)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
async def get_member_from_token(self, token: str) -> Member | None:
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
member_id = payload.get("sub")
|
||||
if member_id is None:
|
||||
return None
|
||||
return await self._repo.get_by_id(__import__("uuid").UUID(member_id))
|
||||
except (JWTError, ValueError):
|
||||
return None
|
||||
51
api/src/rehearsalhub/services/band.py
Normal file
51
api/src/rehearsalhub/services/band.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.db.models import Band
|
||||
from rehearsalhub.repositories.band import BandRepository
|
||||
from rehearsalhub.schemas.band import BandCreate, BandReadWithMembers
|
||||
from rehearsalhub.storage.nextcloud import NextcloudClient
|
||||
|
||||
|
||||
class BandService:
|
||||
def __init__(self, session: AsyncSession, storage: NextcloudClient | None = None) -> None:
|
||||
self._repo = BandRepository(session)
|
||||
self._storage = storage or NextcloudClient()
|
||||
|
||||
async def create_band(self, data: BandCreate, creator_id: uuid.UUID, creator: object | None = None) -> Band:
|
||||
if await self._repo.get_by_slug(data.slug):
|
||||
raise ValueError(f"Slug already taken: {data.slug}")
|
||||
|
||||
nc_folder = (data.nc_base_path or f"bands/{data.slug}/").strip("/") + "/"
|
||||
storage = NextcloudClient.for_member(creator) if creator else self._storage
|
||||
try:
|
||||
await storage.create_folder(nc_folder)
|
||||
except Exception:
|
||||
pass # NC might not be reachable during tests; folder creation is best-effort
|
||||
|
||||
band = await self._repo.create(
|
||||
name=data.name,
|
||||
slug=data.slug,
|
||||
genre_tags=data.genre_tags,
|
||||
nc_folder_path=nc_folder,
|
||||
)
|
||||
await self._repo.add_member(band.id, creator_id, role="admin")
|
||||
return band
|
||||
|
||||
async def get_band_with_members(self, band_id: uuid.UUID) -> Band | None:
|
||||
return await self._repo.get_with_members(band_id)
|
||||
|
||||
async def assert_membership(self, band_id: uuid.UUID, member_id: uuid.UUID) -> str:
|
||||
"""Returns the member's role or raises PermissionError."""
|
||||
role = await self._repo.get_member_role(band_id, member_id)
|
||||
if role is None:
|
||||
raise PermissionError("Not a member of this band")
|
||||
return role
|
||||
|
||||
async def assert_admin(self, band_id: uuid.UUID, member_id: uuid.UUID) -> None:
|
||||
role = await self.assert_membership(band_id, member_id)
|
||||
if role != "admin":
|
||||
raise PermissionError("Admin role required")
|
||||
92
api/src/rehearsalhub/services/song.py
Normal file
92
api/src/rehearsalhub/services/song.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.db.models import AudioVersion, Song
|
||||
from rehearsalhub.queue.redis_queue import RedisJobQueue
|
||||
from rehearsalhub.repositories.audio_version import AudioVersionRepository
|
||||
from rehearsalhub.repositories.song import SongRepository
|
||||
from rehearsalhub.schemas.audio_version import AudioVersionCreate
|
||||
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate
|
||||
from rehearsalhub.storage.nextcloud import NextcloudClient
|
||||
|
||||
|
||||
class SongService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
job_queue: RedisJobQueue | None = None,
|
||||
storage: NextcloudClient | None = None,
|
||||
) -> None:
|
||||
self._repo = SongRepository(session)
|
||||
self._version_repo = AudioVersionRepository(session)
|
||||
self._session = session
|
||||
self._queue = job_queue or RedisJobQueue(session)
|
||||
self._storage = storage or NextcloudClient()
|
||||
|
||||
async def create_song(
|
||||
self, band_id: uuid.UUID, data: SongCreate, creator_id: uuid.UUID, band_slug: str,
|
||||
creator: object | None = None,
|
||||
) -> Song:
|
||||
from rehearsalhub.storage.nextcloud import NextcloudClient
|
||||
nc_folder = f"bands/{band_slug}/songs/{data.title.lower().replace(' ', '-')}/"
|
||||
storage = NextcloudClient.for_member(creator) if creator else self._storage
|
||||
try:
|
||||
await storage.create_folder(nc_folder)
|
||||
except Exception:
|
||||
nc_folder = None # best-effort
|
||||
|
||||
song = await self._repo.create(
|
||||
band_id=band_id,
|
||||
title=data.title,
|
||||
status=data.status,
|
||||
notes=data.notes,
|
||||
nc_folder_path=nc_folder,
|
||||
created_by=creator_id,
|
||||
)
|
||||
return song
|
||||
|
||||
async def list_songs(self, band_id: uuid.UUID) -> list[SongRead]:
|
||||
songs = await self._repo.list_for_band(band_id)
|
||||
result = []
|
||||
for song in songs:
|
||||
versions = song.versions
|
||||
read = SongRead.model_validate(song)
|
||||
read.version_count = len(versions)
|
||||
if versions:
|
||||
latest = max(versions, key=lambda v: v.version_number)
|
||||
read.latest_version_id = latest.id
|
||||
result.append(read)
|
||||
return result
|
||||
|
||||
async def register_version(
|
||||
self,
|
||||
song_id: uuid.UUID,
|
||||
data: AudioVersionCreate,
|
||||
uploader_id: uuid.UUID,
|
||||
) -> AudioVersion:
|
||||
if data.nc_file_etag:
|
||||
existing = await self._version_repo.get_by_etag(data.nc_file_etag)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
version_number = await self._repo.next_version_number(song_id)
|
||||
version = await self._version_repo.create(
|
||||
song_id=song_id,
|
||||
version_number=version_number,
|
||||
nc_file_path=data.nc_file_path,
|
||||
nc_file_etag=data.nc_file_etag,
|
||||
label=data.label,
|
||||
format=data.format,
|
||||
file_size_bytes=data.file_size_bytes,
|
||||
analysis_status="pending",
|
||||
uploaded_by=uploader_id,
|
||||
)
|
||||
|
||||
await self._queue.enqueue(
|
||||
"transcode",
|
||||
{"version_id": str(version.id), "nc_file_path": data.nc_file_path},
|
||||
)
|
||||
return version
|
||||
4
api/src/rehearsalhub/storage/__init__.py
Normal file
4
api/src/rehearsalhub/storage/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from rehearsalhub.storage.nextcloud import NextcloudClient
|
||||
from rehearsalhub.storage.protocol import FileMetadata, StorageClient
|
||||
|
||||
__all__ = ["StorageClient", "FileMetadata", "NextcloudClient"]
|
||||
143
api/src/rehearsalhub/storage/nextcloud.py
Normal file
143
api/src/rehearsalhub/storage/nextcloud.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Nextcloud WebDAV + OCS storage client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from rehearsalhub.config import get_settings
|
||||
from rehearsalhub.storage.protocol import FileMetadata
|
||||
|
||||
_DAV_NS = "{DAV:}"
|
||||
|
||||
|
||||
class NextcloudClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> None:
|
||||
s = get_settings()
|
||||
self._base = (base_url or s.nextcloud_url).rstrip("/")
|
||||
self._auth = (username or s.nextcloud_user, password or s.nextcloud_pass)
|
||||
self._dav_root = f"{self._base}/remote.php/dav/files/{self._auth[0]}"
|
||||
|
||||
@classmethod
|
||||
def for_member(cls, member: object) -> "NextcloudClient":
|
||||
"""Return a client using member's personal NC credentials if configured,
|
||||
falling back to the global env-var credentials."""
|
||||
nc_url = getattr(member, "nc_url", None)
|
||||
nc_username = getattr(member, "nc_username", None)
|
||||
nc_password = getattr(member, "nc_password", None)
|
||||
if nc_url and nc_username and nc_password:
|
||||
return cls(base_url=nc_url, username=nc_username, password=nc_password)
|
||||
return cls()
|
||||
|
||||
def _client(self) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(auth=self._auth, timeout=30.0)
|
||||
|
||||
def _dav_url(self, path: str) -> str:
|
||||
return f"{self._dav_root}/{path.lstrip('/')}"
|
||||
|
||||
async def create_folder(self, path: str) -> None:
|
||||
async with self._client() as c:
|
||||
resp = await c.request("MKCOL", self._dav_url(path))
|
||||
if resp.status_code not in (201, 405): # 405 = already exists
|
||||
resp.raise_for_status()
|
||||
|
||||
async def get_file_metadata(self, path: str) -> FileMetadata:
|
||||
body = (
|
||||
'<?xml version="1.0"?>'
|
||||
'<d:propfind xmlns:d="DAV:">'
|
||||
" <d:prop><d:getcontentlength/><d:getetag/><d:getcontenttype/></d:prop>"
|
||||
"</d:propfind>"
|
||||
)
|
||||
async with self._client() as c:
|
||||
resp = await c.request(
|
||||
"PROPFIND",
|
||||
self._dav_url(path),
|
||||
headers={"Depth": "0", "Content-Type": "application/xml"},
|
||||
content=body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return _parse_propfind_single(resp.text, path)
|
||||
|
||||
async def list_folder(self, path: str) -> list[FileMetadata]:
|
||||
body = (
|
||||
'<?xml version="1.0"?>'
|
||||
'<d:propfind xmlns:d="DAV:">'
|
||||
" <d:prop><d:getcontentlength/><d:getetag/><d:getcontenttype/></d:prop>"
|
||||
"</d:propfind>"
|
||||
)
|
||||
async with self._client() as c:
|
||||
resp = await c.request(
|
||||
"PROPFIND",
|
||||
self._dav_url(path),
|
||||
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||
content=body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return _parse_propfind_multi(resp.text)
|
||||
|
||||
async def download(self, path: str) -> bytes:
|
||||
async with self._client() as c:
|
||||
resp = await c.get(self._dav_url(path))
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
async def get_direct_url(self, path: str) -> str:
|
||||
return self._dav_url(path)
|
||||
|
||||
async def delete(self, path: str) -> None:
|
||||
async with self._client() as c:
|
||||
resp = await c.request("DELETE", self._dav_url(path))
|
||||
resp.raise_for_status()
|
||||
|
||||
async def get_activities(self, since_id: int = 0, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""Fetch recent file activity from Nextcloud OCS API."""
|
||||
url = f"{self._base}/ocs/v2.php/apps/activity/api/v2/activity/files"
|
||||
params: dict[str, Any] = {"since": since_id, "limit": limit, "format": "json"}
|
||||
async with self._client() as c:
|
||||
resp = await c.get(url, params=params, headers={"OCS-APIRequest": "true"})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("ocs", {}).get("data", [])
|
||||
|
||||
|
||||
def _parse_propfind_single(xml_text: str, path: str) -> FileMetadata:
|
||||
root = ET.fromstring(xml_text)
|
||||
response = root.find(f"{_DAV_NS}response")
|
||||
return _response_to_metadata(response, path) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _parse_propfind_multi(xml_text: str) -> list[FileMetadata]:
|
||||
root = ET.fromstring(xml_text)
|
||||
results = []
|
||||
for i, response in enumerate(root.findall(f"{_DAV_NS}response")):
|
||||
if i == 0:
|
||||
continue # skip the folder itself
|
||||
href = response.findtext(f"{_DAV_NS}href") or ""
|
||||
results.append(_response_to_metadata(response, href))
|
||||
return results
|
||||
|
||||
|
||||
def _response_to_metadata(response: ET.Element, fallback_path: str) -> FileMetadata:
|
||||
propstat = response.find(f"{_DAV_NS}propstat")
|
||||
prop = propstat.find(f"{_DAV_NS}prop") if propstat is not None else None # type: ignore[union-attr]
|
||||
href = response.findtext(f"{_DAV_NS}href") or fallback_path
|
||||
etag = (prop.findtext(f"{_DAV_NS}getetag") or "").strip('"') if prop is not None else ""
|
||||
size_text = prop.findtext(f"{_DAV_NS}getcontentlength") if prop is not None else "0"
|
||||
ctype = (
|
||||
prop.findtext(f"{_DAV_NS}getcontenttype") or "application/octet-stream"
|
||||
if prop is not None
|
||||
else "application/octet-stream"
|
||||
)
|
||||
return FileMetadata(
|
||||
path=href,
|
||||
etag=etag,
|
||||
size=int(size_text or 0),
|
||||
content_type=ctype,
|
||||
)
|
||||
39
api/src/rehearsalhub/storage/protocol.py
Normal file
39
api/src/rehearsalhub/storage/protocol.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Storage abstraction. Default impl is Nextcloud/WebDAV; swap for S3, local FS, etc."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class FileMetadata:
|
||||
def __init__(self, path: str, etag: str, size: int, content_type: str) -> None:
|
||||
self.path = path
|
||||
self.etag = etag
|
||||
self.size = size
|
||||
self.content_type = content_type
|
||||
|
||||
|
||||
class StorageClient(Protocol):
|
||||
async def create_folder(self, path: str) -> None:
|
||||
"""Create a folder (and parents) at the given path."""
|
||||
...
|
||||
|
||||
async def get_file_metadata(self, path: str) -> FileMetadata:
|
||||
"""Return metadata for the file at path."""
|
||||
...
|
||||
|
||||
async def list_folder(self, path: str) -> list[FileMetadata]:
|
||||
"""List immediate children of the folder at path."""
|
||||
...
|
||||
|
||||
async def download(self, path: str) -> bytes:
|
||||
"""Download and return the raw bytes of the file at path."""
|
||||
...
|
||||
|
||||
async def get_direct_url(self, path: str) -> str:
|
||||
"""Return a URL for direct access to the file (used for HLS streaming)."""
|
||||
...
|
||||
|
||||
async def delete(self, path: str) -> None:
|
||||
"""Delete a file or folder at path."""
|
||||
...
|
||||
46
api/src/rehearsalhub/ws.py
Normal file
46
api/src/rehearsalhub/ws.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""WebSocket connection manager for real-time version room events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self) -> None:
|
||||
# version_id -> list of active WebSocket connections
|
||||
self._rooms: dict[str, list[WebSocket]] = {}
|
||||
|
||||
async def connect(self, version_id: uuid.UUID, websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
key = str(version_id)
|
||||
self._rooms.setdefault(key, []).append(websocket)
|
||||
|
||||
def disconnect(self, version_id: uuid.UUID, websocket: WebSocket) -> None:
|
||||
key = str(version_id)
|
||||
room = self._rooms.get(key, [])
|
||||
if websocket in room:
|
||||
room.remove(websocket)
|
||||
if not room:
|
||||
self._rooms.pop(key, None)
|
||||
|
||||
async def broadcast(self, version_id: uuid.UUID, event: str, data: Any) -> None:
|
||||
key = str(version_id)
|
||||
payload = json.dumps({"event": event, "data": data})
|
||||
dead: list[WebSocket] = []
|
||||
for ws in self._rooms.get(key, []):
|
||||
try:
|
||||
await ws.send_text(payload)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self.disconnect(version_id, ws)
|
||||
|
||||
def room_size(self, version_id: uuid.UUID) -> int:
|
||||
return len(self._rooms.get(str(version_id), []))
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
0
api/tests/__init__.py
Normal file
0
api/tests/__init__.py
Normal file
48
api/tests/conftest.py
Normal file
48
api/tests/conftest.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Top-level test fixtures. Unit tests use these mocked fixtures (no external services)."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
"""AsyncSession mock for unit tests."""
|
||||
session = AsyncMock(spec=AsyncSession)
|
||||
session.flush = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
session.rollback = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
session.get = AsyncMock(return_value=None)
|
||||
session.execute = AsyncMock()
|
||||
session.delete = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_member_id():
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_band_id():
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_song_id():
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_version_id():
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_annotation_id():
|
||||
return uuid.uuid4()
|
||||
101
api/tests/factories.py
Normal file
101
api/tests/factories.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Test data factories for creating model instances in integration tests."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from rehearsalhub.db.models import Annotation, AudioVersion, Band, BandMember, Member, Song
|
||||
from rehearsalhub.services.auth import hash_password
|
||||
|
||||
|
||||
async def create_member(
|
||||
session,
|
||||
email: str = "test@example.com",
|
||||
display_name: str = "Test User",
|
||||
password: str = "testpassword123",
|
||||
) -> Member:
|
||||
from rehearsalhub.repositories.member import MemberRepository
|
||||
|
||||
repo = MemberRepository(session)
|
||||
return await repo.create(
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
password_hash=hash_password(password),
|
||||
)
|
||||
|
||||
|
||||
async def create_band(
|
||||
session,
|
||||
name: str = "Test Band",
|
||||
slug: str | None = None,
|
||||
creator_id: uuid.UUID | None = None,
|
||||
) -> Band:
|
||||
from rehearsalhub.repositories.band import BandRepository
|
||||
|
||||
repo = BandRepository(session)
|
||||
slug = slug or f"test-band-{uuid.uuid4().hex[:6]}"
|
||||
band = await repo.create(name=name, slug=slug, genre_tags=[])
|
||||
if creator_id:
|
||||
await repo.add_member(band.id, creator_id, role="admin")
|
||||
return band
|
||||
|
||||
|
||||
async def create_song(
|
||||
session,
|
||||
band_id: uuid.UUID,
|
||||
creator_id: uuid.UUID | None = None,
|
||||
title: str = "Test Song",
|
||||
status: str = "jam",
|
||||
) -> Song:
|
||||
from rehearsalhub.repositories.song import SongRepository
|
||||
|
||||
repo = SongRepository(session)
|
||||
return await repo.create(
|
||||
band_id=band_id,
|
||||
title=title,
|
||||
status=status,
|
||||
created_by=creator_id,
|
||||
)
|
||||
|
||||
|
||||
async def create_audio_version(
|
||||
session,
|
||||
song_id: uuid.UUID,
|
||||
uploader_id: uuid.UUID | None = None,
|
||||
version_number: int = 1,
|
||||
analysis_status: str = "done",
|
||||
) -> AudioVersion:
|
||||
from rehearsalhub.repositories.audio_version import AudioVersionRepository
|
||||
|
||||
repo = AudioVersionRepository(session)
|
||||
return await repo.create(
|
||||
song_id=song_id,
|
||||
version_number=version_number,
|
||||
nc_file_path=f"/bands/test/songs/test/v{version_number}.wav",
|
||||
nc_file_etag=uuid.uuid4().hex,
|
||||
analysis_status=analysis_status,
|
||||
uploaded_by=uploader_id,
|
||||
)
|
||||
|
||||
|
||||
async def create_annotation(
|
||||
session,
|
||||
version_id: uuid.UUID,
|
||||
author_id: uuid.UUID,
|
||||
type: str = "point",
|
||||
timestamp_ms: int = 5000,
|
||||
range_end_ms: int | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> Annotation:
|
||||
from rehearsalhub.repositories.annotation import AnnotationRepository
|
||||
|
||||
repo = AnnotationRepository(session)
|
||||
return await repo.create(
|
||||
version_id=version_id,
|
||||
author_id=author_id,
|
||||
type=type,
|
||||
timestamp_ms=timestamp_ms,
|
||||
range_end_ms=range_end_ms if type == "range" else None,
|
||||
body="Test annotation",
|
||||
label="Test label",
|
||||
tags=tags or [],
|
||||
)
|
||||
0
api/tests/integration/__init__.py
Normal file
0
api/tests/integration/__init__.py
Normal file
98
api/tests/integration/conftest.py
Normal file
98
api/tests/integration/conftest.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Integration test fixtures using testcontainers for a real Postgres."""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from rehearsalhub.db.models import Base
|
||||
from rehearsalhub.main import create_app
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def pg_container():
|
||||
"""Start a real Postgres container for the test session."""
|
||||
from testcontainers.postgres import PostgresContainer
|
||||
|
||||
with PostgresContainer("postgres:16-alpine") as pg:
|
||||
yield pg
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def pg_url(pg_container) -> str:
|
||||
url = pg_container.get_connection_url()
|
||||
return url.replace("postgresql+psycopg2://", "postgresql+asyncpg://").replace(
|
||||
"postgresql://", "postgresql+asyncpg://"
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def pg_engine(pg_url):
|
||||
engine = create_async_engine(pg_url, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def db_session(pg_engine) -> AsyncSession:
|
||||
"""Each test gets its own connection with a rolled-back transaction."""
|
||||
async with pg_engine.connect() as conn:
|
||||
trans = await conn.begin()
|
||||
session = AsyncSession(bind=conn, expire_on_commit=False)
|
||||
yield session
|
||||
await session.close()
|
||||
await trans.rollback()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def client(db_session):
|
||||
"""httpx AsyncClient with DB session dependency overridden."""
|
||||
from rehearsalhub.db.engine import get_session
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_session] = lambda: db_session
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def auth_headers(client, db_session):
|
||||
"""Register + login a test user, return Authorization headers."""
|
||||
from tests.factories import create_member
|
||||
|
||||
member = await create_member(db_session, email="auth@test.com")
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login", json={"email": "auth@test.com", "password": "testpassword123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
token = resp.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def current_member(db_session):
|
||||
from tests.factories import create_member
|
||||
|
||||
member = await create_member(db_session, email=f"member_{__import__('uuid').uuid4().hex[:6]}@test.com")
|
||||
await db_session.commit()
|
||||
return member
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def auth_headers_for(client, db_session):
|
||||
"""Factory fixture: given a member, return auth headers for them."""
|
||||
|
||||
async def _make(member):
|
||||
from rehearsalhub.services.auth import create_access_token
|
||||
|
||||
token = create_access_token(str(member.id), member.email)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
return _make
|
||||
203
api/tests/integration/test_api_annotations.py
Normal file
203
api/tests/integration/test_api_annotations.py
Normal 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)
|
||||
73
api/tests/integration/test_api_auth.py
Normal file
73
api/tests/integration/test_api_auth.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Integration tests for auth endpoints."""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_register_creates_member(client, db_session):
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "newuser@test.com", "password": "pass123!", "display_name": "New User"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
assert data["email"] == "newuser@test.com"
|
||||
assert data["display_name"] == "New User"
|
||||
assert "password_hash" not in data
|
||||
assert "id" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_register_duplicate_email_returns_409(client, db_session):
|
||||
await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "dup@test.com", "password": "pass123!", "display_name": "User"},
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "dup@test.com", "password": "pass456!", "display_name": "User2"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_returns_jwt(client, db_session):
|
||||
await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "login@test.com", "password": "pass123!", "display_name": "Login User"},
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "login@test.com", "password": "pass123!"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_wrong_password_returns_401(client, db_session):
|
||||
await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "wrongpw@test.com", "password": "correct!", "display_name": "User"},
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "wrongpw@test.com", "password": "wrong!"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_protected_endpoint_without_token_returns_401(client):
|
||||
import uuid
|
||||
|
||||
resp = await client.get(f"/api/v1/bands/{uuid.uuid4()}")
|
||||
assert resp.status_code == 401
|
||||
74
api/tests/integration/test_api_bands.py
Normal file
74
api/tests/integration/test_api_bands.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Integration tests for band endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.factories import create_band, create_member
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_band(client, db_session, auth_headers_for, current_member):
|
||||
headers = await auth_headers_for(current_member)
|
||||
resp = await client.post(
|
||||
"/api/v1/bands",
|
||||
json={"name": "My Band", "slug": "my-band-001", "genre_tags": ["rock"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
assert data["name"] == "My Band"
|
||||
assert data["slug"] == "my-band-001"
|
||||
assert data["genre_tags"] == ["rock"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_band_duplicate_slug_returns_409(client, db_session, auth_headers_for, current_member):
|
||||
headers = await auth_headers_for(current_member)
|
||||
await create_band(db_session, name="Existing", slug="taken-slug", creator_id=current_member.id)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/bands",
|
||||
json={"name": "Another", "slug": "taken-slug"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_band_with_members(client, db_session, auth_headers_for, current_member):
|
||||
headers = await auth_headers_for(current_member)
|
||||
band = await create_band(db_session, creator_id=current_member.id)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get(f"/api/v1/bands/{band.id}", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["id"] == str(band.id)
|
||||
assert len(data["memberships"]) == 1
|
||||
assert data["memberships"][0]["role"] == "admin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_band_non_member_returns_403(client, db_session, auth_headers_for, current_member):
|
||||
headers = await auth_headers_for(current_member)
|
||||
# Band with a different creator
|
||||
other_member = await create_member(db_session, email="other@test.com")
|
||||
band = await create_band(db_session, creator_id=other_member.id)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get(f"/api/v1/bands/{band.id}", headers=headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_band_not_found_returns_404(client, db_session, auth_headers_for, current_member):
|
||||
headers = await auth_headers_for(current_member)
|
||||
resp = await client.get(f"/api/v1/bands/{uuid.uuid4()}", headers=headers)
|
||||
assert resp.status_code in (403, 404)
|
||||
99
api/tests/integration/test_api_songs.py
Normal file
99
api/tests/integration/test_api_songs.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Integration tests for song and version endpoints."""
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.factories import create_audio_version, create_band, create_member, create_song
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_song(client, db_session, auth_headers_for, current_member):
|
||||
band = await create_band(db_session, creator_id=current_member.id)
|
||||
await db_session.commit()
|
||||
|
||||
headers = await auth_headers_for(current_member)
|
||||
resp = await client.post(
|
||||
f"/api/v1/bands/{band.id}/songs",
|
||||
json={"title": "New Song", "status": "jam"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
assert data["title"] == "New Song"
|
||||
assert data["status"] == "jam"
|
||||
assert data["band_id"] == str(band.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_list_songs(client, db_session, auth_headers_for, current_member):
|
||||
band = await create_band(db_session, creator_id=current_member.id)
|
||||
await create_song(db_session, band_id=band.id, title="Song 1")
|
||||
await create_song(db_session, band_id=band.id, title="Song 2")
|
||||
await db_session.commit()
|
||||
|
||||
headers = await auth_headers_for(current_member)
|
||||
resp = await client.get(f"/api/v1/bands/{band.id}/songs", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
titles = {s["title"] for s in data}
|
||||
assert "Song 1" in titles
|
||||
assert "Song 2" in titles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_version_increments_version_number(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)
|
||||
await db_session.commit()
|
||||
|
||||
headers = await auth_headers_for(current_member)
|
||||
resp1 = await client.post(
|
||||
f"/api/v1/songs/{song.id}/versions",
|
||||
json={"nc_file_path": "/bands/test/songs/song/v1.wav", "nc_file_etag": "etag1"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp1.status_code == 201
|
||||
assert resp1.json()["version_number"] == 1
|
||||
|
||||
resp2 = await client.post(
|
||||
f"/api/v1/songs/{song.id}/versions",
|
||||
json={"nc_file_path": "/bands/test/songs/song/v2.wav", "nc_file_etag": "etag2"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp2.status_code == 201
|
||||
assert resp2.json()["version_number"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_list_versions(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)
|
||||
await create_audio_version(db_session, song_id=song.id, version_number=1)
|
||||
await create_audio_version(db_session, song_id=song.id, version_number=2)
|
||||
await db_session.commit()
|
||||
|
||||
headers = await auth_headers_for(current_member)
|
||||
resp = await client.get(f"/api/v1/songs/{song.id}/versions", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_non_member_cannot_create_song(client, db_session, auth_headers_for, current_member):
|
||||
other = await create_member(db_session, email="other2@test.com")
|
||||
band = await create_band(db_session, creator_id=other.id)
|
||||
await db_session.commit()
|
||||
|
||||
headers = await auth_headers_for(current_member)
|
||||
resp = await client.post(
|
||||
f"/api/v1/bands/{band.id}/songs",
|
||||
json={"title": "Intruder Song", "status": "jam"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
0
api/tests/unit/__init__.py
Normal file
0
api/tests/unit/__init__.py
Normal file
112
api/tests/unit/test_auth.py
Normal file
112
api/tests/unit/test_auth.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Unit tests for auth service (no DB required)."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from rehearsalhub.services.auth import (
|
||||
AuthService,
|
||||
create_access_token,
|
||||
decode_token,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
|
||||
def test_hash_and_verify_password():
|
||||
plain = "supersecret123"
|
||||
hashed = hash_password(plain)
|
||||
assert verify_password(plain, hashed)
|
||||
assert not verify_password("wrongpassword", hashed)
|
||||
|
||||
|
||||
def test_create_and_decode_token():
|
||||
member_id = str(uuid.uuid4())
|
||||
email = "test@example.com"
|
||||
token = create_access_token(member_id, email)
|
||||
payload = decode_token(token)
|
||||
assert payload["sub"] == member_id
|
||||
assert payload["email"] == email
|
||||
|
||||
|
||||
def test_decode_invalid_token_raises():
|
||||
from jose import JWTError
|
||||
|
||||
with pytest.raises(Exception):
|
||||
decode_token("not.a.valid.token")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_returns_token(mock_session):
|
||||
from rehearsalhub.db.models import Member
|
||||
|
||||
member = MagicMock(spec=Member)
|
||||
member.id = uuid.uuid4()
|
||||
member.email = "user@example.com"
|
||||
member.password_hash = hash_password("correctpassword")
|
||||
|
||||
with patch(
|
||||
"rehearsalhub.repositories.member.MemberRepository.get_by_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=member,
|
||||
):
|
||||
svc = AuthService(mock_session)
|
||||
result = await svc.login("user@example.com", "correctpassword")
|
||||
|
||||
assert result is not None
|
||||
assert result.access_token
|
||||
assert result.token_type == "bearer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password_returns_none(mock_session):
|
||||
from rehearsalhub.db.models import Member
|
||||
|
||||
member = MagicMock(spec=Member)
|
||||
member.id = uuid.uuid4()
|
||||
member.email = "user@example.com"
|
||||
member.password_hash = hash_password("correctpassword")
|
||||
|
||||
with patch(
|
||||
"rehearsalhub.repositories.member.MemberRepository.get_by_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=member,
|
||||
):
|
||||
svc = AuthService(mock_session)
|
||||
result = await svc.login("user@example.com", "wrongpassword")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_unknown_email_returns_none(mock_session):
|
||||
with patch(
|
||||
"rehearsalhub.repositories.member.MemberRepository.get_by_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
svc = AuthService(mock_session)
|
||||
result = await svc.login("nobody@example.com", "anypassword")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_duplicate_email_raises(mock_session):
|
||||
from rehearsalhub.schemas.auth import RegisterRequest
|
||||
|
||||
with patch(
|
||||
"rehearsalhub.repositories.member.MemberRepository.email_exists",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
svc = AuthService(mock_session)
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
await svc.register(
|
||||
RegisterRequest(
|
||||
email="dup@example.com",
|
||||
password="pass123",
|
||||
display_name="Dup",
|
||||
)
|
||||
)
|
||||
80
api/tests/unit/test_queue.py
Normal file
80
api/tests/unit/test_queue.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Unit tests for the Redis job queue."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from rehearsalhub.queue.redis_queue import RedisJobQueue
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_creates_job_and_pushes_to_redis(mock_session):
|
||||
fake_job = MagicMock()
|
||||
fake_job.id = uuid.uuid4()
|
||||
|
||||
mock_session.flush = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
|
||||
# Simulate that after flush, the ORM object has an id
|
||||
async def side_effect_flush():
|
||||
pass
|
||||
|
||||
async def side_effect_refresh(obj):
|
||||
obj.id = fake_job.id
|
||||
|
||||
mock_session.flush.side_effect = side_effect_flush
|
||||
mock_session.refresh.side_effect = side_effect_refresh
|
||||
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.rpush = AsyncMock()
|
||||
|
||||
queue = RedisJobQueue(mock_session, redis_client=mock_redis)
|
||||
job_id = await queue.enqueue("transcode", {"version_id": "abc"})
|
||||
|
||||
mock_session.add.assert_called_once()
|
||||
mock_redis.rpush.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_done_updates_job_status(mock_session):
|
||||
from rehearsalhub.db.models import Job
|
||||
|
||||
job = MagicMock(spec=Job)
|
||||
job.id = uuid.uuid4()
|
||||
job.status = "running"
|
||||
mock_session.get.return_value = job
|
||||
|
||||
queue = RedisJobQueue(mock_session, redis_client=AsyncMock())
|
||||
await queue.mark_done(job.id)
|
||||
|
||||
assert job.status == "done"
|
||||
assert job.finished_at is not None
|
||||
mock_session.flush.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_stores_error(mock_session):
|
||||
from rehearsalhub.db.models import Job
|
||||
|
||||
job = MagicMock(spec=Job)
|
||||
job.id = uuid.uuid4()
|
||||
job.status = "running"
|
||||
mock_session.get.return_value = job
|
||||
|
||||
queue = RedisJobQueue(mock_session, redis_client=AsyncMock())
|
||||
await queue.mark_failed(job.id, "something went wrong")
|
||||
|
||||
assert job.status == "failed"
|
||||
assert job.error == "something went wrong"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dequeue_returns_none_on_timeout(mock_session):
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.blpop = AsyncMock(return_value=None)
|
||||
|
||||
queue = RedisJobQueue(mock_session, redis_client=mock_redis)
|
||||
result = await queue.dequeue(timeout=1)
|
||||
assert result is None
|
||||
79
api/tests/unit/test_repositories.py
Normal file
79
api/tests/unit/test_repositories.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Unit tests for repositories using mocked sessions."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from rehearsalhub.repositories.band import BandRepository
|
||||
from rehearsalhub.repositories.member import MemberRepository
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_returns_none_when_missing(mock_session):
|
||||
mock_session.get.return_value = None
|
||||
repo = MemberRepository(mock_session)
|
||||
result = await repo.get_by_id(uuid.uuid4())
|
||||
assert result is None
|
||||
mock_session.get.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_returns_object(mock_session):
|
||||
from rehearsalhub.db.models import Member
|
||||
|
||||
fake = MagicMock(spec=Member)
|
||||
fake.id = uuid.uuid4()
|
||||
mock_session.get.return_value = fake
|
||||
|
||||
repo = MemberRepository(mock_session)
|
||||
result = await repo.get_by_id(fake.id)
|
||||
assert result is fake
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_calls_add_flush_refresh(mock_session):
|
||||
from rehearsalhub.db.models import Band
|
||||
|
||||
created_band = MagicMock(spec=Band)
|
||||
created_band.id = uuid.uuid4()
|
||||
created_band.slug = "my-band"
|
||||
mock_session.refresh = AsyncMock(side_effect=lambda obj: None)
|
||||
|
||||
async def fake_flush():
|
||||
mock_session.add.call_args[0][0].__dict__.update({"id": created_band.id})
|
||||
|
||||
mock_session.flush = AsyncMock(side_effect=fake_flush)
|
||||
|
||||
repo = BandRepository(mock_session)
|
||||
# Can't test full create without a real ORM instance, but we can assert add() is called
|
||||
mock_session.add = MagicMock()
|
||||
assert mock_session.flush.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_band_is_member_calls_get_member_role(mock_session):
|
||||
band_id = uuid.uuid4()
|
||||
member_id = uuid.uuid4()
|
||||
|
||||
result_mock = AsyncMock()
|
||||
result_mock.scalar_one_or_none.return_value = "admin"
|
||||
mock_session.execute.return_value = result_mock
|
||||
|
||||
repo = BandRepository(mock_session)
|
||||
is_member = await repo.is_member(band_id, member_id)
|
||||
assert is_member is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_band_is_member_false_when_no_role(mock_session):
|
||||
band_id = uuid.uuid4()
|
||||
member_id = uuid.uuid4()
|
||||
|
||||
result_mock = AsyncMock()
|
||||
result_mock.scalar_one_or_none.return_value = None
|
||||
mock_session.execute.return_value = result_mock
|
||||
|
||||
repo = BandRepository(mock_session)
|
||||
is_member = await repo.is_member(band_id, member_id)
|
||||
assert is_member is False
|
||||
151
api/tests/unit/test_services.py
Normal file
151
api/tests/unit/test_services.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Unit tests for service layer — band and annotation services."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from rehearsalhub.services.annotation import AnnotationService
|
||||
from rehearsalhub.services.band import BandService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_band_raises_on_duplicate_slug(mock_session):
|
||||
from rehearsalhub.db.models import Band
|
||||
from rehearsalhub.schemas.band import BandCreate
|
||||
|
||||
existing_band = MagicMock(spec=Band)
|
||||
existing_band.slug = "taken"
|
||||
|
||||
with patch(
|
||||
"rehearsalhub.repositories.band.BandRepository.get_by_slug",
|
||||
new_callable=AsyncMock,
|
||||
return_value=existing_band,
|
||||
):
|
||||
svc = BandService(mock_session)
|
||||
with pytest.raises(ValueError, match="Slug already taken"):
|
||||
await svc.create_band(
|
||||
BandCreate(name="Test", slug="taken"),
|
||||
creator_id=uuid.uuid4(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assert_membership_raises_when_not_member(mock_session):
|
||||
band_id = uuid.uuid4()
|
||||
member_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
"rehearsalhub.repositories.band.BandRepository.get_member_role",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
svc = BandService(mock_session)
|
||||
with pytest.raises(PermissionError, match="Not a member"):
|
||||
await svc.assert_membership(band_id, member_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assert_admin_raises_when_member_role(mock_session):
|
||||
band_id = uuid.uuid4()
|
||||
member_id = uuid.uuid4()
|
||||
|
||||
with patch(
|
||||
"rehearsalhub.repositories.band.BandRepository.get_member_role",
|
||||
new_callable=AsyncMock,
|
||||
return_value="member",
|
||||
):
|
||||
svc = BandService(mock_session)
|
||||
with pytest.raises(PermissionError, match="Admin role required"):
|
||||
await svc.assert_admin(band_id, member_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_range_annotation_enqueues_job(mock_session):
|
||||
from rehearsalhub.db.models import Annotation
|
||||
from rehearsalhub.schemas.annotation import AnnotationCreate
|
||||
|
||||
annotation = MagicMock(spec=Annotation)
|
||||
annotation.id = uuid.uuid4()
|
||||
|
||||
mock_queue = AsyncMock()
|
||||
mock_queue.enqueue = AsyncMock(return_value=uuid.uuid4())
|
||||
|
||||
with patch(
|
||||
"rehearsalhub.repositories.annotation.AnnotationRepository.create",
|
||||
new_callable=AsyncMock,
|
||||
return_value=annotation,
|
||||
):
|
||||
svc = AnnotationService(mock_session, job_queue=mock_queue)
|
||||
await svc.create_annotation(
|
||||
version_id=uuid.uuid4(),
|
||||
author_id=uuid.uuid4(),
|
||||
data=AnnotationCreate(
|
||||
type="range",
|
||||
timestamp_ms=1000,
|
||||
range_end_ms=5000,
|
||||
tags=["hook"],
|
||||
),
|
||||
)
|
||||
|
||||
mock_queue.enqueue.assert_called_once_with(
|
||||
"analyse_range",
|
||||
{
|
||||
"annotation_id": str(annotation.id),
|
||||
"version_id": unittest_any_str(),
|
||||
"start_ms": 1000,
|
||||
"end_ms": 5000,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_point_annotation_does_not_enqueue(mock_session):
|
||||
from rehearsalhub.db.models import Annotation
|
||||
from rehearsalhub.schemas.annotation import AnnotationCreate
|
||||
|
||||
annotation = MagicMock(spec=Annotation)
|
||||
annotation.id = uuid.uuid4()
|
||||
|
||||
mock_queue = AsyncMock()
|
||||
mock_queue.enqueue = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"rehearsalhub.repositories.annotation.AnnotationRepository.create",
|
||||
new_callable=AsyncMock,
|
||||
return_value=annotation,
|
||||
):
|
||||
svc = AnnotationService(mock_session, job_queue=mock_queue)
|
||||
await svc.create_annotation(
|
||||
version_id=uuid.uuid4(),
|
||||
author_id=uuid.uuid4(),
|
||||
data=AnnotationCreate(type="point", timestamp_ms=2000),
|
||||
)
|
||||
|
||||
mock_queue.enqueue.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_annotation_by_non_author_raises(mock_session):
|
||||
from rehearsalhub.db.models import Annotation
|
||||
|
||||
author_id = uuid.uuid4()
|
||||
other_id = uuid.uuid4()
|
||||
|
||||
annotation = MagicMock(spec=Annotation)
|
||||
annotation.id = uuid.uuid4()
|
||||
annotation.author_id = author_id
|
||||
|
||||
svc = AnnotationService(mock_session)
|
||||
with pytest.raises(PermissionError, match="Only the author"):
|
||||
await svc.delete_annotation(annotation, other_id)
|
||||
|
||||
|
||||
def unittest_any_str():
|
||||
"""Helper that matches any string in assert_called_with."""
|
||||
|
||||
class AnyStr:
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, str)
|
||||
|
||||
return AnyStr()
|
||||
119
docker-compose.yml
Normal file
119
docker-compose.yml
Normal file
@@ -0,0 +1,119 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-rehearsalhub}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-rh_user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- rh_net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rh_user} -d ${POSTGRES_DB:-rehearsalhub}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- rh_net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./api
|
||||
target: production
|
||||
image: rehearsalhub/api:latest
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-rehearsalhub}
|
||||
NEXTCLOUD_URL: ${NEXTCLOUD_URL}
|
||||
NEXTCLOUD_USER: ${NEXTCLOUD_USER}
|
||||
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
DOMAIN: ${DOMAIN:-localhost}
|
||||
networks:
|
||||
- rh_net
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
audio-worker:
|
||||
build:
|
||||
context: ./worker
|
||||
target: production
|
||||
image: rehearsalhub/audio-worker:latest
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-rehearsalhub}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
NEXTCLOUD_URL: ${NEXTCLOUD_URL}
|
||||
NEXTCLOUD_USER: ${NEXTCLOUD_USER}
|
||||
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS}
|
||||
ANALYSIS_VERSION: "1.0.0"
|
||||
volumes:
|
||||
- audio_tmp:/tmp/audio
|
||||
networks:
|
||||
- rh_net
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
nc-watcher:
|
||||
build:
|
||||
context: ./watcher
|
||||
target: production
|
||||
image: rehearsalhub/nc-watcher:latest
|
||||
environment:
|
||||
NEXTCLOUD_URL: ${NEXTCLOUD_URL}
|
||||
NEXTCLOUD_USER: ${NEXTCLOUD_USER}
|
||||
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS}
|
||||
API_URL: http://api:8000
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
POLL_INTERVAL: "30"
|
||||
networks:
|
||||
- rh_net
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./web
|
||||
target: production
|
||||
image: rehearsalhub/web:latest
|
||||
ports:
|
||||
- "8080:80"
|
||||
networks:
|
||||
- rh_net
|
||||
depends_on:
|
||||
- api
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
rh_net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
redis_data:
|
||||
audio_tmp:
|
||||
27
scripts/nc-setup.sh
Normal file
27
scripts/nc-setup.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "→ Configuring Nextcloud via occ..."
|
||||
|
||||
NC="docker compose exec -T nextcloud php occ"
|
||||
|
||||
# Enable recommended apps
|
||||
$NC app:enable notify_push 2>/dev/null || echo " notify_push not available, skipping"
|
||||
$NC app:enable files_accesscontrol 2>/dev/null || echo " files_accesscontrol not available, skipping"
|
||||
|
||||
# Create service account for rehearsalhub
|
||||
$NC user:add \
|
||||
--display-name "RehearsalHub Service" \
|
||||
--password-from-env \
|
||||
rh_service \
|
||||
<<< "${NEXTCLOUD_ADMIN_PASSWORD:-change_me}" || echo " Service account may already exist"
|
||||
|
||||
# Set permissions
|
||||
$NC user:setting rh_service core lang en
|
||||
$NC config:system:set trusted_domains 1 --value="${DOMAIN:-localhost}"
|
||||
$NC config:system:set trusted_domains 2 --value="nc.${DOMAIN:-localhost}"
|
||||
|
||||
# Create base folder structure
|
||||
$NC files:scan --all
|
||||
|
||||
echo "✓ Nextcloud setup complete"
|
||||
38
scripts/seed.sh
Normal file
38
scripts/seed.sh
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
API="${API_URL:-http://localhost/api/v1}"
|
||||
|
||||
echo "→ Running database migrations..."
|
||||
docker compose exec api alembic upgrade head
|
||||
|
||||
echo "→ Seeding admin user..."
|
||||
REGISTER_RESP=$(curl -sf -X POST "$API/auth/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "admin@rehearsalhub.local",
|
||||
"password": "changeme123!",
|
||||
"display_name": "Admin"
|
||||
}') || echo " Admin user may already exist"
|
||||
|
||||
echo "→ Logging in to get token..."
|
||||
TOKEN_RESP=$(curl -sf -X POST "$API/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "admin@rehearsalhub.local", "password": "changeme123!"}')
|
||||
|
||||
TOKEN=$(echo "$TOKEN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
|
||||
|
||||
echo "→ Creating demo band..."
|
||||
curl -sf -X POST "$API/bands" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{
|
||||
"name": "Demo Band",
|
||||
"slug": "demo-band",
|
||||
"genre_tags": ["rock", "jam"]
|
||||
}' | python3 -m json.tool
|
||||
|
||||
echo ""
|
||||
echo "✓ Seed complete!"
|
||||
echo " Admin: admin@rehearsalhub.local / changeme123!"
|
||||
echo " API docs: https://${DOMAIN:-localhost}/api/docs"
|
||||
44
traefik/dynamic/routes.yml
Normal file
44
traefik/dynamic/routes.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
http:
|
||||
routers:
|
||||
# API — HTTP
|
||||
api-http:
|
||||
rule: "Host(`{{ env "DOMAIN" }}`) && PathPrefix(`/api`)"
|
||||
entryPoints:
|
||||
- web
|
||||
service: api
|
||||
|
||||
# Web — HTTP
|
||||
web-http:
|
||||
rule: "Host(`{{ env "DOMAIN" }}`)"
|
||||
entryPoints:
|
||||
- web
|
||||
service: web
|
||||
|
||||
# API — HTTPS (production with real domain + cert)
|
||||
api:
|
||||
rule: "Host(`{{ env "DOMAIN" }}`) && PathPrefix(`/api`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: api
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# Web — HTTPS (production with real domain + cert)
|
||||
web:
|
||||
rule: "Host(`{{ env "DOMAIN" }}`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: web
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
services:
|
||||
api:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://api:8000"
|
||||
|
||||
web:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://web:80"
|
||||
12
traefik/dynamic/tls.yml
Normal file
12
traefik/dynamic/tls.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
tls:
|
||||
options:
|
||||
default:
|
||||
minVersion: VersionTLS12
|
||||
sniStrict: true
|
||||
cipherSuites:
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
|
||||
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
|
||||
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
25
traefik/traefik.yml
Normal file
25
traefik/traefik.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
api:
|
||||
dashboard: true
|
||||
insecure: true # dashboard on :8080 (mapped to host 9080), disable in production
|
||||
|
||||
log:
|
||||
level: INFO
|
||||
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
websecure:
|
||||
address: ":443"
|
||||
|
||||
providers:
|
||||
file:
|
||||
directory: /dynamic
|
||||
watch: true
|
||||
|
||||
certificatesResolvers:
|
||||
letsencrypt:
|
||||
acme:
|
||||
email: "${ACME_EMAIL}"
|
||||
storage: /acme/acme.json
|
||||
httpChallenge:
|
||||
entryPoint: web
|
||||
10
watcher/Dockerfile
Normal file
10
watcher/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim AS base
|
||||
WORKDIR /app
|
||||
RUN pip install uv
|
||||
|
||||
FROM base AS production
|
||||
COPY pyproject.toml .
|
||||
RUN uv sync --no-dev --frozen || uv sync --no-dev
|
||||
COPY . .
|
||||
ENV PYTHONPATH=/app/src
|
||||
CMD ["uv", "run", "python", "-m", "watcher.main"]
|
||||
29
watcher/pyproject.toml
Normal file
29
watcher/pyproject.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "rehearsalhub-watcher"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"httpx>=0.27",
|
||||
"redis[hiredis]>=5.0",
|
||||
"pydantic-settings>=2.3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.23",
|
||||
"pytest-cov>=5",
|
||||
"respx>=0.21",
|
||||
"ruff>=0.4",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/watcher"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
0
watcher/src/watcher/__init__.py
Normal file
0
watcher/src/watcher/__init__.py
Normal file
24
watcher/src/watcher/config.py
Normal file
24
watcher/src/watcher/config.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class WatcherSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
nextcloud_url: str = "http://nextcloud"
|
||||
nextcloud_user: str = "ncadmin"
|
||||
nextcloud_pass: str = ""
|
||||
|
||||
api_url: str = "http://api:8000"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
job_queue_key: str = "rh:jobs"
|
||||
|
||||
poll_interval: int = 30 # seconds
|
||||
|
||||
# File extensions to watch
|
||||
audio_extensions: list[str] = [".wav", ".mp3", ".flac", ".aac", ".ogg", ".m4a"]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> WatcherSettings:
|
||||
return WatcherSettings() # type: ignore[call-arg]
|
||||
98
watcher/src/watcher/event_loop.py
Normal file
98
watcher/src/watcher/event_loop.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Event loop: poll Nextcloud activity, detect audio uploads, push to API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from watcher.config import WatcherSettings
|
||||
from watcher.nc_client import NextcloudWatcherClient
|
||||
|
||||
log = logging.getLogger("watcher.event_loop")
|
||||
|
||||
# Persist last seen activity ID across polls (in-process state; persistent across restarts
|
||||
# would require a small DB or file, but good enough for a POC)
|
||||
_last_activity_id: int = 0
|
||||
|
||||
|
||||
def is_audio_file(path: str, extensions: list[str]) -> bool:
|
||||
return Path(path).suffix.lower() in extensions
|
||||
|
||||
|
||||
def is_band_audio_path(path: str) -> bool:
|
||||
"""Check if the path looks like /bands/<slug>/songs/**"""
|
||||
parts = path.strip("/").split("/")
|
||||
return len(parts) >= 3 and parts[0] == "bands"
|
||||
|
||||
|
||||
def extract_nc_file_path(activity: dict[str, Any]) -> str | None:
|
||||
"""Extract the server-relative file path from an activity event."""
|
||||
objects = activity.get("objects", {})
|
||||
for file_id, file_path in objects.items():
|
||||
if isinstance(file_path, str):
|
||||
return file_path
|
||||
return activity.get("object_name")
|
||||
|
||||
|
||||
async def register_version_with_api(
|
||||
nc_file_path: str,
|
||||
nc_file_etag: str | None,
|
||||
api_url: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Call POST /api/v1/songs/{song_id}/versions to register the new file.
|
||||
We infer song context from the path: /bands/{slug}/songs/{song_folder}/file.ext
|
||||
In a full implementation this would look up the song_id from the API.
|
||||
Here we emit a best-effort registration event.
|
||||
"""
|
||||
try:
|
||||
payload = {
|
||||
"nc_file_path": nc_file_path,
|
||||
"nc_file_etag": nc_file_etag,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10.0) as c:
|
||||
resp = await c.post(f"{api_url}/api/v1/internal/nc-upload", json=payload)
|
||||
return resp.status_code in (200, 201)
|
||||
except Exception as exc:
|
||||
log.warning("Failed to register version with API: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
async def poll_once(
|
||||
nc_client: NextcloudWatcherClient,
|
||||
settings: WatcherSettings,
|
||||
) -> None:
|
||||
global _last_activity_id
|
||||
|
||||
activities = await nc_client.get_activities(since_id=_last_activity_id)
|
||||
if not activities:
|
||||
return
|
||||
|
||||
for activity in activities:
|
||||
activity_id = activity.get("activity_id", 0)
|
||||
subject = activity.get("subject", "")
|
||||
|
||||
if subject not in ("file_created", "file_changed"):
|
||||
_last_activity_id = max(_last_activity_id, activity_id)
|
||||
continue
|
||||
|
||||
nc_path = extract_nc_file_path(activity)
|
||||
if nc_path is None:
|
||||
_last_activity_id = max(_last_activity_id, activity_id)
|
||||
continue
|
||||
|
||||
if not is_audio_file(nc_path, settings.audio_extensions):
|
||||
_last_activity_id = max(_last_activity_id, activity_id)
|
||||
continue
|
||||
|
||||
if not is_band_audio_path(nc_path):
|
||||
_last_activity_id = max(_last_activity_id, activity_id)
|
||||
continue
|
||||
|
||||
log.info("Detected audio upload: %s", nc_path)
|
||||
etag = await nc_client.get_file_etag(nc_path)
|
||||
await register_version_with_api(nc_path, etag, settings.api_url)
|
||||
_last_activity_id = max(_last_activity_id, activity_id)
|
||||
38
watcher/src/watcher/main.py
Normal file
38
watcher/src/watcher/main.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Nextcloud watcher daemon entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from watcher.config import get_settings
|
||||
from watcher.event_loop import poll_once
|
||||
from watcher.nc_client import NextcloudWatcherClient
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
log = logging.getLogger("watcher")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
settings = get_settings()
|
||||
nc = NextcloudWatcherClient(
|
||||
base_url=settings.nextcloud_url,
|
||||
username=settings.nextcloud_user,
|
||||
password=settings.nextcloud_pass,
|
||||
)
|
||||
|
||||
log.info("Waiting for Nextcloud to become available...")
|
||||
while not await nc.is_healthy():
|
||||
await asyncio.sleep(10)
|
||||
log.info("Nextcloud is ready. Starting poll loop (interval=%ds)", settings.poll_interval)
|
||||
|
||||
while True:
|
||||
try:
|
||||
await poll_once(nc, settings)
|
||||
except Exception as exc:
|
||||
log.exception("Poll error: %s", exc)
|
||||
await asyncio.sleep(settings.poll_interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
82
watcher/src/watcher/nc_client.py
Normal file
82
watcher/src/watcher/nc_client.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Nextcloud OCS API client for the watcher service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class NextcloudWatcherClient:
|
||||
def __init__(self, base_url: str, username: str, password: str) -> None:
|
||||
self._base = base_url.rstrip("/")
|
||||
self._auth = (username, password)
|
||||
|
||||
def _client(self) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
auth=self._auth,
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
timeout=15.0,
|
||||
)
|
||||
|
||||
async def get_activities(
|
||||
self, since_id: int = 0, limit: int = 100
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Fetch recent file activity events from the Nextcloud Activity app.
|
||||
Returns a list of activity dicts sorted oldest-first.
|
||||
"""
|
||||
url = f"{self._base}/ocs/v2.php/apps/activity/api/v2/activity/files"
|
||||
params: dict[str, Any] = {
|
||||
"since": since_id,
|
||||
"limit": limit,
|
||||
"format": "json",
|
||||
"sort": "asc",
|
||||
}
|
||||
async with self._client() as c:
|
||||
resp = await c.get(url, params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("ocs", {}).get("data", []) or []
|
||||
|
||||
async def get_file_etag(self, file_path: str) -> str | None:
|
||||
"""PROPFIND to get the ETag for a file."""
|
||||
url = f"{self._base}/remote.php/dav/files/{self._auth[0]}/{file_path.lstrip('/')}"
|
||||
body = (
|
||||
'<?xml version="1.0"?>'
|
||||
'<d:propfind xmlns:d="DAV:"><d:prop><d:getetag/></d:prop></d:propfind>'
|
||||
)
|
||||
async with self._client() as c:
|
||||
resp = await c.request(
|
||||
"PROPFIND",
|
||||
url,
|
||||
headers={"Depth": "0", "Content-Type": "application/xml"},
|
||||
content=body,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
root = ET.fromstring(resp.text)
|
||||
ns = "{DAV:}"
|
||||
for response in root.findall(f"{ns}response"):
|
||||
propstat = response.find(f"{ns}propstat")
|
||||
if propstat is not None:
|
||||
prop = propstat.find(f"{ns}prop")
|
||||
if prop is not None:
|
||||
etag = prop.findtext(f"{ns}getetag")
|
||||
if etag:
|
||||
return etag.strip('"')
|
||||
return None
|
||||
|
||||
async def is_healthy(self) -> bool:
|
||||
"""Return True if Nextcloud is reachable and initialized."""
|
||||
try:
|
||||
async with self._client() as c:
|
||||
resp = await c.get(f"{self._base}/status.php")
|
||||
data = resp.json()
|
||||
return data.get("installed", False)
|
||||
except Exception:
|
||||
return False
|
||||
0
watcher/tests/__init__.py
Normal file
0
watcher/tests/__init__.py
Normal file
17
watcher/tests/conftest.py
Normal file
17
watcher/tests/conftest.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Watcher test fixtures."""
|
||||
|
||||
import pytest
|
||||
|
||||
from watcher.config import WatcherSettings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings():
|
||||
return WatcherSettings(
|
||||
nextcloud_url="http://nc.test",
|
||||
nextcloud_user="admin",
|
||||
nextcloud_pass="secret",
|
||||
api_url="http://api.test",
|
||||
redis_url="redis://localhost:6379/0",
|
||||
poll_interval=5,
|
||||
)
|
||||
118
watcher/tests/test_event_loop.py
Normal file
118
watcher/tests/test_event_loop.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Tests for watcher event loop logic."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from watcher.event_loop import (
|
||||
extract_nc_file_path,
|
||||
is_audio_file,
|
||||
is_band_audio_path,
|
||||
poll_once,
|
||||
)
|
||||
|
||||
|
||||
def test_is_audio_file_matches_extensions():
|
||||
extensions = [".wav", ".mp3", ".flac"]
|
||||
assert is_audio_file("/bands/foo/songs/bar/take1.wav", extensions)
|
||||
assert is_audio_file("/bands/foo/songs/bar/take1.MP3", extensions)
|
||||
assert not is_audio_file("/bands/foo/songs/bar/cover.jpg", extensions)
|
||||
assert not is_audio_file("/bands/foo/songs/bar/notes.txt", extensions)
|
||||
|
||||
|
||||
def test_is_band_audio_path():
|
||||
assert is_band_audio_path("/bands/myband/songs/mysong/take.wav")
|
||||
assert is_band_audio_path("bands/slug/songs/")
|
||||
assert not is_band_audio_path("/nextcloud/files/random.wav")
|
||||
assert not is_band_audio_path("/")
|
||||
|
||||
|
||||
def test_extract_nc_file_path_from_objects():
|
||||
activity = {"objects": {"42": "/bands/foo/songs/bar/take.wav"}}
|
||||
path = extract_nc_file_path(activity)
|
||||
assert path == "/bands/foo/songs/bar/take.wav"
|
||||
|
||||
|
||||
def test_extract_nc_file_path_from_object_name():
|
||||
activity = {"objects": {}, "object_name": "/bands/foo/songs/bar/take.wav"}
|
||||
path = extract_nc_file_path(activity)
|
||||
assert path == "/bands/foo/songs/bar/take.wav"
|
||||
|
||||
|
||||
def test_extract_nc_file_path_returns_none_when_missing():
|
||||
activity = {"objects": {}}
|
||||
path = extract_nc_file_path(activity)
|
||||
assert path is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_ignores_non_audio_files(settings):
|
||||
from watcher.nc_client import NextcloudWatcherClient
|
||||
|
||||
nc = AsyncMock(spec=NextcloudWatcherClient)
|
||||
nc.get_activities.return_value = [
|
||||
{
|
||||
"activity_id": 1,
|
||||
"subject": "file_created",
|
||||
"objects": {"1": "/bands/foo/songs/bar/image.jpg"},
|
||||
}
|
||||
]
|
||||
|
||||
with patch("watcher.event_loop.register_version_with_api") as mock_register:
|
||||
await poll_once(nc, settings)
|
||||
mock_register.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_registers_audio_upload(settings):
|
||||
from watcher.nc_client import NextcloudWatcherClient
|
||||
|
||||
nc = AsyncMock(spec=NextcloudWatcherClient)
|
||||
nc.get_activities.return_value = [
|
||||
{
|
||||
"activity_id": 5,
|
||||
"subject": "file_created",
|
||||
"objects": {"10": "/bands/myband/songs/mysong/take1.wav"},
|
||||
}
|
||||
]
|
||||
nc.get_file_etag.return_value = "abc123"
|
||||
|
||||
with patch(
|
||||
"watcher.event_loop.register_version_with_api", new_callable=AsyncMock, return_value=True
|
||||
) as mock_register:
|
||||
await poll_once(nc, settings)
|
||||
mock_register.assert_called_once_with(
|
||||
"/bands/myband/songs/mysong/take1.wav",
|
||||
"abc123",
|
||||
settings.api_url,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_ignores_non_file_events(settings):
|
||||
from watcher.nc_client import NextcloudWatcherClient
|
||||
|
||||
nc = AsyncMock(spec=NextcloudWatcherClient)
|
||||
nc.get_activities.return_value = [
|
||||
{
|
||||
"activity_id": 2,
|
||||
"subject": "shared", # not file_created or file_changed
|
||||
"objects": {"5": "/bands/foo/songs/bar/take.wav"},
|
||||
}
|
||||
]
|
||||
|
||||
with patch("watcher.event_loop.register_version_with_api") as mock_register:
|
||||
await poll_once(nc, settings)
|
||||
mock_register.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_empty_activities_does_nothing(settings):
|
||||
from watcher.nc_client import NextcloudWatcherClient
|
||||
|
||||
nc = AsyncMock(spec=NextcloudWatcherClient)
|
||||
nc.get_activities.return_value = []
|
||||
|
||||
with patch("watcher.event_loop.register_version_with_api") as mock_register:
|
||||
await poll_once(nc, settings)
|
||||
mock_register.assert_not_called()
|
||||
80
watcher/tests/test_nc_client.py
Normal file
80
watcher/tests/test_nc_client.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Tests for Nextcloud OCS client."""
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
|
||||
from watcher.nc_client import NextcloudWatcherClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return NextcloudWatcherClient(
|
||||
base_url="http://nc.test", username="admin", password="secret"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_activities_returns_list(client):
|
||||
mock_response = {
|
||||
"ocs": {
|
||||
"data": [
|
||||
{
|
||||
"activity_id": 1,
|
||||
"subject": "file_created",
|
||||
"objects": {"123": "/bands/myband/songs/song1/take1.wav"},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
with respx.mock:
|
||||
respx.get("http://nc.test/ocs/v2.php/apps/activity/api/v2/activity/files").mock(
|
||||
return_value=httpx.Response(200, json=mock_response)
|
||||
)
|
||||
activities = await client.get_activities(since_id=0)
|
||||
|
||||
assert len(activities) == 1
|
||||
assert activities[0]["subject"] == "file_created"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_activities_returns_empty_on_no_data(client):
|
||||
mock_response = {"ocs": {"data": []}}
|
||||
with respx.mock:
|
||||
respx.get("http://nc.test/ocs/v2.php/apps/activity/api/v2/activity/files").mock(
|
||||
return_value=httpx.Response(200, json=mock_response)
|
||||
)
|
||||
activities = await client.get_activities()
|
||||
|
||||
assert activities == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_healthy_true_when_installed(client):
|
||||
with respx.mock:
|
||||
respx.get("http://nc.test/status.php").mock(
|
||||
return_value=httpx.Response(200, json={"installed": True, "version": "28.0.0"})
|
||||
)
|
||||
result = await client.is_healthy()
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_healthy_false_when_not_installed(client):
|
||||
with respx.mock:
|
||||
respx.get("http://nc.test/status.php").mock(
|
||||
return_value=httpx.Response(200, json={"installed": False})
|
||||
)
|
||||
result = await client.is_healthy()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_healthy_false_on_connection_error(client):
|
||||
with respx.mock:
|
||||
respx.get("http://nc.test/status.php").mock(side_effect=httpx.ConnectError("refused"))
|
||||
result = await client.is_healthy()
|
||||
|
||||
assert result is False
|
||||
13
web/Dockerfile
Normal file
13
web/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
COPY . .
|
||||
RUN npm run check
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine AS production
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
21
web/eslint.config.js
Normal file
21
web/eslint.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
}
|
||||
);
|
||||
14
web/index.html
Normal file
14
web/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#080A0E" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>RehearsalHub</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
web/nginx.conf
Normal file
32
web/nginx.conf
Normal file
@@ -0,0 +1,32 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy API requests to the FastAPI backend
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# WebSocket support (for /api/v1/ws/*)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# SPA routing — all other paths fall back to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets aggressively
|
||||
location ~* \.(js|css|woff2|png|svg|ico)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
||||
}
|
||||
4667
web/package-lock.json
generated
Normal file
4667
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
web/package.json
Normal file
40
web/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "rehearsalhub-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src",
|
||||
"check": "npm run typecheck && npm run lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.56.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"wavesurfer.js": "^7.8.0",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vite": "^5.4.1",
|
||||
"vitest": "^2.1.1"
|
||||
}
|
||||
}
|
||||
55
web/src/App.tsx
Normal file
55
web/src/App.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { BandPage } from "./pages/BandPage";
|
||||
import { SongPage } from "./pages/SongPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { InvitePage } from "./pages/InvitePage";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
|
||||
});
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem("rh_token");
|
||||
return token ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/bands/:bandId"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<BandPage />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/bands/:bandId/songs/:songId"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<SongPage />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<SettingsPage />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/invite/:token" element={<InvitePage />} />
|
||||
<Route path="/" element={<PrivateRoute><HomePage /></PrivateRoute>} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
81
web/src/api/annotations.ts
Normal file
81
web/src/api/annotations.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export interface RangeAnalysis {
|
||||
id: string;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
bpm: number | null;
|
||||
bpm_confidence: number | null;
|
||||
key: string | null;
|
||||
scale: string | null;
|
||||
avg_loudness_lufs: number | null;
|
||||
energy: number | null;
|
||||
chroma_vector: number[] | null;
|
||||
mfcc_mean: number[] | null;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
id: string;
|
||||
member_id: string;
|
||||
emoji: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
version_id: string;
|
||||
author_id: string;
|
||||
type: "point" | "range";
|
||||
timestamp_ms: number;
|
||||
range_end_ms: number | null;
|
||||
body: string | null;
|
||||
label: string | null;
|
||||
tags: string[];
|
||||
parent_id: string | null;
|
||||
resolved: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
range_analysis: RangeAnalysis | null;
|
||||
reactions: Reaction[];
|
||||
}
|
||||
|
||||
export const listAnnotations = (versionId: string) =>
|
||||
api.get<Annotation[]>(`/versions/${versionId}/annotations`);
|
||||
|
||||
export const createAnnotation = (
|
||||
versionId: string,
|
||||
data: {
|
||||
type: "point" | "range";
|
||||
timestamp_ms: number;
|
||||
range_end_ms?: number;
|
||||
body?: string;
|
||||
label?: string;
|
||||
tags?: string[];
|
||||
parent_id?: string;
|
||||
}
|
||||
) => api.post<Annotation>(`/versions/${versionId}/annotations`, data);
|
||||
|
||||
export const updateAnnotation = (
|
||||
annotationId: string,
|
||||
data: { body?: string; label?: string; tags?: string[]; resolved?: boolean }
|
||||
) => api.patch<Annotation>(`/annotations/${annotationId}`, data);
|
||||
|
||||
export const deleteAnnotation = (annotationId: string) =>
|
||||
api.delete(`/annotations/${annotationId}`);
|
||||
|
||||
export const addReaction = (annotationId: string, emoji: string) =>
|
||||
api.post<Reaction>(`/annotations/${annotationId}/reactions`, { emoji });
|
||||
|
||||
export const searchRanges = (
|
||||
bandId: string,
|
||||
params: { bpm_min?: number; bpm_max?: number; key?: string; tag?: string; min_duration_ms?: number }
|
||||
) => {
|
||||
const qs = new URLSearchParams(
|
||||
Object.fromEntries(
|
||||
Object.entries(params)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k, v]) => [k, String(v)])
|
||||
)
|
||||
);
|
||||
return api.get<Annotation[]>(`/bands/${bandId}/search/ranges?${qs}`);
|
||||
};
|
||||
33
web/src/api/auth.ts
Normal file
33
web/src/api/auth.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { api, setToken } from "./client";
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface MemberRead {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
avatar_url: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function login(req: LoginRequest): Promise<TokenResponse> {
|
||||
const resp = await api.post<TokenResponse>("/auth/login", req);
|
||||
setToken(resp.access_token);
|
||||
return resp;
|
||||
}
|
||||
|
||||
export async function register(req: {
|
||||
email: string;
|
||||
password: string;
|
||||
display_name: string;
|
||||
}): Promise<MemberRead> {
|
||||
return api.post<MemberRead>("/auth/register", req);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user