From f7be1b994d666ba95ce594249a13fc665f6296c7 Mon Sep 17 00:00:00 2001 From: Steffen Schuhmann Date: Sat, 28 Mar 2026 21:53:03 +0100 Subject: [PATCH] Initial commit: RehearsalHub POC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 19 + .gitignore | 56 + Makefile | 75 + api/Dockerfile | 22 + api/alembic.ini | 38 + api/alembic/__init__.py | 0 api/alembic/env.py | 52 + api/alembic/versions/0001_initial.py | 195 + api/alembic/versions/0002_member_nc_config.py | 24 + .../versions/0003_invites_and_comments.py | 43 + api/alembic/versions/__init__.py | 0 api/entrypoint.sh | 8 + api/pyproject.toml | 63 + api/src/rehearsalhub/__init__.py | 0 api/src/rehearsalhub/config.py | 35 + api/src/rehearsalhub/db/__init__.py | 25 + api/src/rehearsalhub/db/engine.py | 48 + api/src/rehearsalhub/db/models.py | 368 ++ api/src/rehearsalhub/dependencies.py | 41 + api/src/rehearsalhub/main.py | 68 + api/src/rehearsalhub/queue/__init__.py | 4 + api/src/rehearsalhub/queue/protocol.py | 28 + api/src/rehearsalhub/queue/redis_queue.py | 81 + api/src/rehearsalhub/repositories/__init__.py | 19 + .../rehearsalhub/repositories/annotation.py | 113 + .../repositories/audio_version.py | 54 + api/src/rehearsalhub/repositories/band.py | 92 + api/src/rehearsalhub/repositories/base.py | 53 + api/src/rehearsalhub/repositories/comment.py | 32 + api/src/rehearsalhub/repositories/job.py | 47 + api/src/rehearsalhub/repositories/member.py | 18 + api/src/rehearsalhub/repositories/reaction.py | 23 + api/src/rehearsalhub/repositories/song.py | 51 + api/src/rehearsalhub/routers/__init__.py | 19 + api/src/rehearsalhub/routers/annotations.py | 174 + api/src/rehearsalhub/routers/auth.py | 62 + api/src/rehearsalhub/routers/bands.py | 55 + api/src/rehearsalhub/routers/internal.py | 101 + api/src/rehearsalhub/routers/members.py | 134 + api/src/rehearsalhub/routers/songs.py | 240 + api/src/rehearsalhub/routers/versions.py | 120 + api/src/rehearsalhub/routers/ws.py | 22 + api/src/rehearsalhub/schemas/__init__.py | 35 + api/src/rehearsalhub/schemas/annotation.py | 83 + api/src/rehearsalhub/schemas/audio_version.py | 30 + api/src/rehearsalhub/schemas/auth.py | 17 + api/src/rehearsalhub/schemas/band.py | 36 + api/src/rehearsalhub/schemas/comment.py | 32 + api/src/rehearsalhub/schemas/invite.py | 27 + api/src/rehearsalhub/schemas/member.py | 35 + api/src/rehearsalhub/schemas/song.py | 36 + api/src/rehearsalhub/services/__init__.py | 6 + api/src/rehearsalhub/services/annotation.py | 76 + api/src/rehearsalhub/services/auth.py | 72 + api/src/rehearsalhub/services/band.py | 51 + api/src/rehearsalhub/services/song.py | 92 + api/src/rehearsalhub/storage/__init__.py | 4 + api/src/rehearsalhub/storage/nextcloud.py | 143 + api/src/rehearsalhub/storage/protocol.py | 39 + api/src/rehearsalhub/ws.py | 46 + api/tests/__init__.py | 0 api/tests/conftest.py | 48 + api/tests/factories.py | 101 + api/tests/integration/__init__.py | 0 api/tests/integration/conftest.py | 98 + api/tests/integration/test_api_annotations.py | 203 + api/tests/integration/test_api_auth.py | 73 + api/tests/integration/test_api_bands.py | 74 + api/tests/integration/test_api_songs.py | 99 + api/tests/unit/__init__.py | 0 api/tests/unit/test_auth.py | 112 + api/tests/unit/test_queue.py | 80 + api/tests/unit/test_repositories.py | 79 + api/tests/unit/test_services.py | 151 + docker-compose.yml | 119 + scripts/nc-setup.sh | 27 + scripts/seed.sh | 38 + traefik/dynamic/routes.yml | 44 + traefik/dynamic/tls.yml | 12 + traefik/traefik.yml | 25 + watcher/Dockerfile | 10 + watcher/pyproject.toml | 29 + watcher/src/watcher/__init__.py | 0 watcher/src/watcher/config.py | 24 + watcher/src/watcher/event_loop.py | 98 + watcher/src/watcher/main.py | 38 + watcher/src/watcher/nc_client.py | 82 + watcher/tests/__init__.py | 0 watcher/tests/conftest.py | 17 + watcher/tests/test_event_loop.py | 118 + watcher/tests/test_nc_client.py | 80 + web/Dockerfile | 13 + web/eslint.config.js | 21 + web/index.html | 14 + web/nginx.conf | 32 + web/package-lock.json | 4667 +++++++++++++++++ web/package.json | 40 + web/src/App.tsx | 55 + web/src/api/annotations.ts | 81 + web/src/api/auth.ts | 33 + web/src/api/bands.ts | 29 + web/src/api/client.ts | 43 + web/src/hooks/useWaveform.ts | 67 + web/src/hooks/useWebSocket.ts | 40 + web/src/main.tsx | 12 + web/src/pages/BandPage.tsx | 265 + web/src/pages/HomePage.tsx | 142 + web/src/pages/InvitePage.tsx | 105 + web/src/pages/LoginPage.tsx | 95 + web/src/pages/SettingsPage.tsx | 147 + web/src/pages/SongPage.tsx | 229 + web/src/test/setup.ts | 1 + web/tsconfig.json | 20 + web/vite.config.ts | 18 + worker/Dockerfile | 30 + worker/pyproject.toml | 36 + worker/src/worker/__init__.py | 0 worker/src/worker/analyzers/__init__.py | 28 + worker/src/worker/analyzers/base.py | 38 + worker/src/worker/analyzers/bpm.py | 39 + worker/src/worker/analyzers/chroma.py | 21 + worker/src/worker/analyzers/key.py | 31 + worker/src/worker/analyzers/loudness.py | 33 + worker/src/worker/analyzers/mfcc.py | 21 + worker/src/worker/analyzers/spectral.py | 31 + worker/src/worker/config.py | 25 + worker/src/worker/db.py | 72 + worker/src/worker/main.py | 167 + worker/src/worker/pipeline/__init__.py | 0 worker/src/worker/pipeline/analyse_full.py | 46 + worker/src/worker/pipeline/analyse_range.py | 93 + worker/src/worker/pipeline/transcode.py | 60 + worker/src/worker/pipeline/waveform.py | 46 + worker/src/worker/queue/__init__.py | 0 worker/tests/__init__.py | 0 worker/tests/conftest.py | 22 + worker/tests/test_analyse_range.py | 137 + worker/tests/test_analyzers.py | 88 + worker/tests/test_waveform.py | 49 + 139 files changed, 12743 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 api/Dockerfile create mode 100644 api/alembic.ini create mode 100644 api/alembic/__init__.py create mode 100644 api/alembic/env.py create mode 100644 api/alembic/versions/0001_initial.py create mode 100644 api/alembic/versions/0002_member_nc_config.py create mode 100644 api/alembic/versions/0003_invites_and_comments.py create mode 100644 api/alembic/versions/__init__.py create mode 100644 api/entrypoint.sh create mode 100644 api/pyproject.toml create mode 100644 api/src/rehearsalhub/__init__.py create mode 100644 api/src/rehearsalhub/config.py create mode 100644 api/src/rehearsalhub/db/__init__.py create mode 100644 api/src/rehearsalhub/db/engine.py create mode 100644 api/src/rehearsalhub/db/models.py create mode 100644 api/src/rehearsalhub/dependencies.py create mode 100644 api/src/rehearsalhub/main.py create mode 100644 api/src/rehearsalhub/queue/__init__.py create mode 100644 api/src/rehearsalhub/queue/protocol.py create mode 100644 api/src/rehearsalhub/queue/redis_queue.py create mode 100644 api/src/rehearsalhub/repositories/__init__.py create mode 100644 api/src/rehearsalhub/repositories/annotation.py create mode 100644 api/src/rehearsalhub/repositories/audio_version.py create mode 100644 api/src/rehearsalhub/repositories/band.py create mode 100644 api/src/rehearsalhub/repositories/base.py create mode 100644 api/src/rehearsalhub/repositories/comment.py create mode 100644 api/src/rehearsalhub/repositories/job.py create mode 100644 api/src/rehearsalhub/repositories/member.py create mode 100644 api/src/rehearsalhub/repositories/reaction.py create mode 100644 api/src/rehearsalhub/repositories/song.py create mode 100644 api/src/rehearsalhub/routers/__init__.py create mode 100644 api/src/rehearsalhub/routers/annotations.py create mode 100644 api/src/rehearsalhub/routers/auth.py create mode 100644 api/src/rehearsalhub/routers/bands.py create mode 100644 api/src/rehearsalhub/routers/internal.py create mode 100644 api/src/rehearsalhub/routers/members.py create mode 100644 api/src/rehearsalhub/routers/songs.py create mode 100644 api/src/rehearsalhub/routers/versions.py create mode 100644 api/src/rehearsalhub/routers/ws.py create mode 100644 api/src/rehearsalhub/schemas/__init__.py create mode 100644 api/src/rehearsalhub/schemas/annotation.py create mode 100644 api/src/rehearsalhub/schemas/audio_version.py create mode 100644 api/src/rehearsalhub/schemas/auth.py create mode 100644 api/src/rehearsalhub/schemas/band.py create mode 100644 api/src/rehearsalhub/schemas/comment.py create mode 100644 api/src/rehearsalhub/schemas/invite.py create mode 100644 api/src/rehearsalhub/schemas/member.py create mode 100644 api/src/rehearsalhub/schemas/song.py create mode 100644 api/src/rehearsalhub/services/__init__.py create mode 100644 api/src/rehearsalhub/services/annotation.py create mode 100644 api/src/rehearsalhub/services/auth.py create mode 100644 api/src/rehearsalhub/services/band.py create mode 100644 api/src/rehearsalhub/services/song.py create mode 100644 api/src/rehearsalhub/storage/__init__.py create mode 100644 api/src/rehearsalhub/storage/nextcloud.py create mode 100644 api/src/rehearsalhub/storage/protocol.py create mode 100644 api/src/rehearsalhub/ws.py create mode 100644 api/tests/__init__.py create mode 100644 api/tests/conftest.py create mode 100644 api/tests/factories.py create mode 100644 api/tests/integration/__init__.py create mode 100644 api/tests/integration/conftest.py create mode 100644 api/tests/integration/test_api_annotations.py create mode 100644 api/tests/integration/test_api_auth.py create mode 100644 api/tests/integration/test_api_bands.py create mode 100644 api/tests/integration/test_api_songs.py create mode 100644 api/tests/unit/__init__.py create mode 100644 api/tests/unit/test_auth.py create mode 100644 api/tests/unit/test_queue.py create mode 100644 api/tests/unit/test_repositories.py create mode 100644 api/tests/unit/test_services.py create mode 100644 docker-compose.yml create mode 100644 scripts/nc-setup.sh create mode 100644 scripts/seed.sh create mode 100644 traefik/dynamic/routes.yml create mode 100644 traefik/dynamic/tls.yml create mode 100644 traefik/traefik.yml create mode 100644 watcher/Dockerfile create mode 100644 watcher/pyproject.toml create mode 100644 watcher/src/watcher/__init__.py create mode 100644 watcher/src/watcher/config.py create mode 100644 watcher/src/watcher/event_loop.py create mode 100644 watcher/src/watcher/main.py create mode 100644 watcher/src/watcher/nc_client.py create mode 100644 watcher/tests/__init__.py create mode 100644 watcher/tests/conftest.py create mode 100644 watcher/tests/test_event_loop.py create mode 100644 watcher/tests/test_nc_client.py create mode 100644 web/Dockerfile create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/nginx.conf create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.tsx create mode 100644 web/src/api/annotations.ts create mode 100644 web/src/api/auth.ts create mode 100644 web/src/api/bands.ts create mode 100644 web/src/api/client.ts create mode 100644 web/src/hooks/useWaveform.ts create mode 100644 web/src/hooks/useWebSocket.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/BandPage.tsx create mode 100644 web/src/pages/HomePage.tsx create mode 100644 web/src/pages/InvitePage.tsx create mode 100644 web/src/pages/LoginPage.tsx create mode 100644 web/src/pages/SettingsPage.tsx create mode 100644 web/src/pages/SongPage.tsx create mode 100644 web/src/test/setup.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts create mode 100644 worker/Dockerfile create mode 100644 worker/pyproject.toml create mode 100644 worker/src/worker/__init__.py create mode 100644 worker/src/worker/analyzers/__init__.py create mode 100644 worker/src/worker/analyzers/base.py create mode 100644 worker/src/worker/analyzers/bpm.py create mode 100644 worker/src/worker/analyzers/chroma.py create mode 100644 worker/src/worker/analyzers/key.py create mode 100644 worker/src/worker/analyzers/loudness.py create mode 100644 worker/src/worker/analyzers/mfcc.py create mode 100644 worker/src/worker/analyzers/spectral.py create mode 100644 worker/src/worker/config.py create mode 100644 worker/src/worker/db.py create mode 100644 worker/src/worker/main.py create mode 100644 worker/src/worker/pipeline/__init__.py create mode 100644 worker/src/worker/pipeline/analyse_full.py create mode 100644 worker/src/worker/pipeline/analyse_range.py create mode 100644 worker/src/worker/pipeline/transcode.py create mode 100644 worker/src/worker/pipeline/waveform.py create mode 100644 worker/src/worker/queue/__init__.py create mode 100644 worker/tests/__init__.py create mode 100644 worker/tests/conftest.py create mode 100644 worker/tests/test_analyse_range.py create mode 100644 worker/tests/test_analyzers.py create mode 100644 worker/tests/test_waveform.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d5fddef --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6bd505a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8942d8b --- /dev/null +++ b/Makefile @@ -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 diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..e3bd533 --- /dev/null +++ b/api/Dockerfile @@ -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"] diff --git a/api/alembic.ini b/api/alembic.ini new file mode 100644 index 0000000..8c88300 --- /dev/null +++ b/api/alembic.ini @@ -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 diff --git a/api/alembic/__init__.py b/api/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/alembic/env.py b/api/alembic/env.py new file mode 100644 index 0000000..7244dc5 --- /dev/null +++ b/api/alembic/env.py @@ -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()) diff --git a/api/alembic/versions/0001_initial.py b/api/alembic/versions/0001_initial.py new file mode 100644 index 0000000..cb656d6 --- /dev/null +++ b/api/alembic/versions/0001_initial.py @@ -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") diff --git a/api/alembic/versions/0002_member_nc_config.py b/api/alembic/versions/0002_member_nc_config.py new file mode 100644 index 0000000..9e9a442 --- /dev/null +++ b/api/alembic/versions/0002_member_nc_config.py @@ -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") diff --git a/api/alembic/versions/0003_invites_and_comments.py b/api/alembic/versions/0003_invites_and_comments.py new file mode 100644 index 0000000..88057d2 --- /dev/null +++ b/api/alembic/versions/0003_invites_and_comments.py @@ -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") diff --git a/api/alembic/versions/__init__.py b/api/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/entrypoint.sh b/api/entrypoint.sh new file mode 100644 index 0000000..34868c9 --- /dev/null +++ b/api/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "Running database migrations..." +uv run alembic upgrade head + +echo "Starting server..." +exec "$@" diff --git a/api/pyproject.toml b/api/pyproject.toml new file mode 100644 index 0000000..9b46494 --- /dev/null +++ b/api/pyproject.toml @@ -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"] + diff --git a/api/src/rehearsalhub/__init__.py b/api/src/rehearsalhub/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/rehearsalhub/config.py b/api/src/rehearsalhub/config.py new file mode 100644 index 0000000..67f193a --- /dev/null +++ b/api/src/rehearsalhub/config.py @@ -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] diff --git a/api/src/rehearsalhub/db/__init__.py b/api/src/rehearsalhub/db/__init__.py new file mode 100644 index 0000000..39c0068 --- /dev/null +++ b/api/src/rehearsalhub/db/__init__.py @@ -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", +] diff --git a/api/src/rehearsalhub/db/engine.py b/api/src/rehearsalhub/db/engine.py new file mode 100644 index 0000000..89e57d0 --- /dev/null +++ b/api/src/rehearsalhub/db/engine.py @@ -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 diff --git a/api/src/rehearsalhub/db/models.py b/api/src/rehearsalhub/db/models.py new file mode 100644 index 0000000..67be24d --- /dev/null +++ b/api/src/rehearsalhub/db/models.py @@ -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)) diff --git a/api/src/rehearsalhub/dependencies.py b/api/src/rehearsalhub/dependencies.py new file mode 100644 index 0000000..892b07c --- /dev/null +++ b/api/src/rehearsalhub/dependencies.py @@ -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 diff --git a/api/src/rehearsalhub/main.py b/api/src/rehearsalhub/main.py new file mode 100644 index 0000000..855498f --- /dev/null +++ b/api/src/rehearsalhub/main.py @@ -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() diff --git a/api/src/rehearsalhub/queue/__init__.py b/api/src/rehearsalhub/queue/__init__.py new file mode 100644 index 0000000..c6b05d8 --- /dev/null +++ b/api/src/rehearsalhub/queue/__init__.py @@ -0,0 +1,4 @@ +from rehearsalhub.queue.protocol import JobQueue +from rehearsalhub.queue.redis_queue import RedisJobQueue + +__all__ = ["JobQueue", "RedisJobQueue"] diff --git a/api/src/rehearsalhub/queue/protocol.py b/api/src/rehearsalhub/queue/protocol.py new file mode 100644 index 0000000..c06b97b --- /dev/null +++ b/api/src/rehearsalhub/queue/protocol.py @@ -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.""" + ... diff --git a/api/src/rehearsalhub/queue/redis_queue.py b/api/src/rehearsalhub/queue/redis_queue.py new file mode 100644 index 0000000..6378c68 --- /dev/null +++ b/api/src/rehearsalhub/queue/redis_queue.py @@ -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() diff --git a/api/src/rehearsalhub/repositories/__init__.py b/api/src/rehearsalhub/repositories/__init__.py new file mode 100644 index 0000000..1c2a276 --- /dev/null +++ b/api/src/rehearsalhub/repositories/__init__.py @@ -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", +] diff --git a/api/src/rehearsalhub/repositories/annotation.py b/api/src/rehearsalhub/repositories/annotation.py new file mode 100644 index 0000000..84bc50e --- /dev/null +++ b/api/src/rehearsalhub/repositories/annotation.py @@ -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()) diff --git a/api/src/rehearsalhub/repositories/audio_version.py b/api/src/rehearsalhub/repositories/audio_version.py new file mode 100644 index 0000000..e1b7240 --- /dev/null +++ b/api/src/rehearsalhub/repositories/audio_version.py @@ -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() diff --git a/api/src/rehearsalhub/repositories/band.py b/api/src/rehearsalhub/repositories/band.py new file mode 100644 index 0000000..6cdd37e --- /dev/null +++ b/api/src/rehearsalhub/repositories/band.py @@ -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()) diff --git a/api/src/rehearsalhub/repositories/base.py b/api/src/rehearsalhub/repositories/base.py new file mode 100644 index 0000000..131e036 --- /dev/null +++ b/api/src/rehearsalhub/repositories/base.py @@ -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() diff --git a/api/src/rehearsalhub/repositories/comment.py b/api/src/rehearsalhub/repositories/comment.py new file mode 100644 index 0000000..47474ef --- /dev/null +++ b/api/src/rehearsalhub/repositories/comment.py @@ -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() diff --git a/api/src/rehearsalhub/repositories/job.py b/api/src/rehearsalhub/repositories/job.py new file mode 100644 index 0000000..8a84b83 --- /dev/null +++ b/api/src/rehearsalhub/repositories/job.py @@ -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 diff --git a/api/src/rehearsalhub/repositories/member.py b/api/src/rehearsalhub/repositories/member.py new file mode 100644 index 0000000..c733b20 --- /dev/null +++ b/api/src/rehearsalhub/repositories/member.py @@ -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 diff --git a/api/src/rehearsalhub/repositories/reaction.py b/api/src/rehearsalhub/repositories/reaction.py new file mode 100644 index 0000000..df5fa15 --- /dev/null +++ b/api/src/rehearsalhub/repositories/reaction.py @@ -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() diff --git a/api/src/rehearsalhub/repositories/song.py b/api/src/rehearsalhub/repositories/song.py new file mode 100644 index 0000000..0524b21 --- /dev/null +++ b/api/src/rehearsalhub/repositories/song.py @@ -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() diff --git a/api/src/rehearsalhub/routers/__init__.py b/api/src/rehearsalhub/routers/__init__.py new file mode 100644 index 0000000..c6f72a2 --- /dev/null +++ b/api/src/rehearsalhub/routers/__init__.py @@ -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", +] diff --git a/api/src/rehearsalhub/routers/annotations.py b/api/src/rehearsalhub/routers/annotations.py new file mode 100644 index 0000000..e63109a --- /dev/null +++ b/api/src/rehearsalhub/routers/annotations.py @@ -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] diff --git a/api/src/rehearsalhub/routers/auth.py b/api/src/rehearsalhub/routers/auth.py new file mode 100644 index 0000000..d3ead73 --- /dev/null +++ b/api/src/rehearsalhub/routers/auth.py @@ -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) diff --git a/api/src/rehearsalhub/routers/bands.py b/api/src/rehearsalhub/routers/bands.py new file mode 100644 index 0000000..5ad7731 --- /dev/null +++ b/api/src/rehearsalhub/routers/bands.py @@ -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) diff --git a/api/src/rehearsalhub/routers/internal.py b/api/src/rehearsalhub/routers/internal.py new file mode 100644 index 0000000..dd1448c --- /dev/null +++ b/api/src/rehearsalhub/routers/internal.py @@ -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)} diff --git a/api/src/rehearsalhub/routers/members.py b/api/src/rehearsalhub/routers/members.py new file mode 100644 index 0000000..3e3da8e --- /dev/null +++ b/api/src/rehearsalhub/routers/members.py @@ -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) diff --git a/api/src/rehearsalhub/routers/songs.py b/api/src/rehearsalhub/routers/songs.py new file mode 100644 index 0000000..f5106b0 --- /dev/null +++ b/api/src/rehearsalhub/routers/songs.py @@ -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) diff --git a/api/src/rehearsalhub/routers/versions.py b/api/src/rehearsalhub/routers/versions.py new file mode 100644 index 0000000..a4264b0 --- /dev/null +++ b/api/src/rehearsalhub/routers/versions.py @@ -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) diff --git a/api/src/rehearsalhub/routers/ws.py b/api/src/rehearsalhub/routers/ws.py new file mode 100644 index 0000000..ad7da70 --- /dev/null +++ b/api/src/rehearsalhub/routers/ws.py @@ -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) diff --git a/api/src/rehearsalhub/schemas/__init__.py b/api/src/rehearsalhub/schemas/__init__.py new file mode 100644 index 0000000..0e62bbe --- /dev/null +++ b/api/src/rehearsalhub/schemas/__init__.py @@ -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", +] diff --git a/api/src/rehearsalhub/schemas/annotation.py b/api/src/rehearsalhub/schemas/annotation.py new file mode 100644 index 0000000..5050b12 --- /dev/null +++ b/api/src/rehearsalhub/schemas/annotation.py @@ -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] = [] diff --git a/api/src/rehearsalhub/schemas/audio_version.py b/api/src/rehearsalhub/schemas/audio_version.py new file mode 100644 index 0000000..38fb341 --- /dev/null +++ b/api/src/rehearsalhub/schemas/audio_version.py @@ -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 diff --git a/api/src/rehearsalhub/schemas/auth.py b/api/src/rehearsalhub/schemas/auth.py new file mode 100644 index 0000000..e418e93 --- /dev/null +++ b/api/src/rehearsalhub/schemas/auth.py @@ -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 diff --git a/api/src/rehearsalhub/schemas/band.py b/api/src/rehearsalhub/schemas/band.py new file mode 100644 index 0000000..6388ba6 --- /dev/null +++ b/api/src/rehearsalhub/schemas/band.py @@ -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] = [] diff --git a/api/src/rehearsalhub/schemas/comment.py b/api/src/rehearsalhub/schemas/comment.py new file mode 100644 index 0000000..b569602 --- /dev/null +++ b/api/src/rehearsalhub/schemas/comment.py @@ -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"), + ) diff --git a/api/src/rehearsalhub/schemas/invite.py b/api/src/rehearsalhub/schemas/invite.py new file mode 100644 index 0000000..99f10f8 --- /dev/null +++ b/api/src/rehearsalhub/schemas/invite.py @@ -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 diff --git a/api/src/rehearsalhub/schemas/member.py b/api/src/rehearsalhub/schemas/member.py new file mode 100644 index 0000000..9a492a1 --- /dev/null +++ b/api/src/rehearsalhub/schemas/member.py @@ -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 diff --git a/api/src/rehearsalhub/schemas/song.py b/api/src/rehearsalhub/schemas/song.py new file mode 100644 index 0000000..347044e --- /dev/null +++ b/api/src/rehearsalhub/schemas/song.py @@ -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 diff --git a/api/src/rehearsalhub/services/__init__.py b/api/src/rehearsalhub/services/__init__.py new file mode 100644 index 0000000..3af9ac1 --- /dev/null +++ b/api/src/rehearsalhub/services/__init__.py @@ -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"] diff --git a/api/src/rehearsalhub/services/annotation.py b/api/src/rehearsalhub/services/annotation.py new file mode 100644 index 0000000..7371377 --- /dev/null +++ b/api/src/rehearsalhub/services/annotation.py @@ -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 + ) diff --git a/api/src/rehearsalhub/services/auth.py b/api/src/rehearsalhub/services/auth.py new file mode 100644 index 0000000..e131cc1 --- /dev/null +++ b/api/src/rehearsalhub/services/auth.py @@ -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 diff --git a/api/src/rehearsalhub/services/band.py b/api/src/rehearsalhub/services/band.py new file mode 100644 index 0000000..d1a2315 --- /dev/null +++ b/api/src/rehearsalhub/services/band.py @@ -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") diff --git a/api/src/rehearsalhub/services/song.py b/api/src/rehearsalhub/services/song.py new file mode 100644 index 0000000..47c7ed5 --- /dev/null +++ b/api/src/rehearsalhub/services/song.py @@ -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 diff --git a/api/src/rehearsalhub/storage/__init__.py b/api/src/rehearsalhub/storage/__init__.py new file mode 100644 index 0000000..ffcfa53 --- /dev/null +++ b/api/src/rehearsalhub/storage/__init__.py @@ -0,0 +1,4 @@ +from rehearsalhub.storage.nextcloud import NextcloudClient +from rehearsalhub.storage.protocol import FileMetadata, StorageClient + +__all__ = ["StorageClient", "FileMetadata", "NextcloudClient"] diff --git a/api/src/rehearsalhub/storage/nextcloud.py b/api/src/rehearsalhub/storage/nextcloud.py new file mode 100644 index 0000000..fcb4c9e --- /dev/null +++ b/api/src/rehearsalhub/storage/nextcloud.py @@ -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 = ( + '' + '' + " " + "" + ) + 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 = ( + '' + '' + " " + "" + ) + 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, + ) diff --git a/api/src/rehearsalhub/storage/protocol.py b/api/src/rehearsalhub/storage/protocol.py new file mode 100644 index 0000000..ab13dfa --- /dev/null +++ b/api/src/rehearsalhub/storage/protocol.py @@ -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.""" + ... diff --git a/api/src/rehearsalhub/ws.py b/api/src/rehearsalhub/ws.py new file mode 100644 index 0000000..c6d8674 --- /dev/null +++ b/api/src/rehearsalhub/ws.py @@ -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() diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/conftest.py b/api/tests/conftest.py new file mode 100644 index 0000000..4d1d4d9 --- /dev/null +++ b/api/tests/conftest.py @@ -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() diff --git a/api/tests/factories.py b/api/tests/factories.py new file mode 100644 index 0000000..2a6a696 --- /dev/null +++ b/api/tests/factories.py @@ -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 [], + ) diff --git a/api/tests/integration/__init__.py b/api/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py new file mode 100644 index 0000000..e72501e --- /dev/null +++ b/api/tests/integration/conftest.py @@ -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 diff --git a/api/tests/integration/test_api_annotations.py b/api/tests/integration/test_api_annotations.py new file mode 100644 index 0000000..ee0964d --- /dev/null +++ b/api/tests/integration/test_api_annotations.py @@ -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) diff --git a/api/tests/integration/test_api_auth.py b/api/tests/integration/test_api_auth.py new file mode 100644 index 0000000..46bca83 --- /dev/null +++ b/api/tests/integration/test_api_auth.py @@ -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 diff --git a/api/tests/integration/test_api_bands.py b/api/tests/integration/test_api_bands.py new file mode 100644 index 0000000..4e97964 --- /dev/null +++ b/api/tests/integration/test_api_bands.py @@ -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) diff --git a/api/tests/integration/test_api_songs.py b/api/tests/integration/test_api_songs.py new file mode 100644 index 0000000..896c4f0 --- /dev/null +++ b/api/tests/integration/test_api_songs.py @@ -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 diff --git a/api/tests/unit/__init__.py b/api/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/unit/test_auth.py b/api/tests/unit/test_auth.py new file mode 100644 index 0000000..0e5570c --- /dev/null +++ b/api/tests/unit/test_auth.py @@ -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", + ) + ) diff --git a/api/tests/unit/test_queue.py b/api/tests/unit/test_queue.py new file mode 100644 index 0000000..0384897 --- /dev/null +++ b/api/tests/unit/test_queue.py @@ -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 diff --git a/api/tests/unit/test_repositories.py b/api/tests/unit/test_repositories.py new file mode 100644 index 0000000..424280f --- /dev/null +++ b/api/tests/unit/test_repositories.py @@ -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 diff --git a/api/tests/unit/test_services.py b/api/tests/unit/test_services.py new file mode 100644 index 0000000..15b2b45 --- /dev/null +++ b/api/tests/unit/test_services.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5606d81 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/scripts/nc-setup.sh b/scripts/nc-setup.sh new file mode 100644 index 0000000..b772dcc --- /dev/null +++ b/scripts/nc-setup.sh @@ -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" diff --git a/scripts/seed.sh b/scripts/seed.sh new file mode 100644 index 0000000..c63b34c --- /dev/null +++ b/scripts/seed.sh @@ -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" diff --git a/traefik/dynamic/routes.yml b/traefik/dynamic/routes.yml new file mode 100644 index 0000000..75230f1 --- /dev/null +++ b/traefik/dynamic/routes.yml @@ -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" diff --git a/traefik/dynamic/tls.yml b/traefik/dynamic/tls.yml new file mode 100644 index 0000000..f4741b2 --- /dev/null +++ b/traefik/dynamic/tls.yml @@ -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 diff --git a/traefik/traefik.yml b/traefik/traefik.yml new file mode 100644 index 0000000..22a7c33 --- /dev/null +++ b/traefik/traefik.yml @@ -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 diff --git a/watcher/Dockerfile b/watcher/Dockerfile new file mode 100644 index 0000000..fbca963 --- /dev/null +++ b/watcher/Dockerfile @@ -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"] diff --git a/watcher/pyproject.toml b/watcher/pyproject.toml new file mode 100644 index 0000000..fff7525 --- /dev/null +++ b/watcher/pyproject.toml @@ -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"] diff --git a/watcher/src/watcher/__init__.py b/watcher/src/watcher/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher/src/watcher/config.py b/watcher/src/watcher/config.py new file mode 100644 index 0000000..a8c8e6b --- /dev/null +++ b/watcher/src/watcher/config.py @@ -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] diff --git a/watcher/src/watcher/event_loop.py b/watcher/src/watcher/event_loop.py new file mode 100644 index 0000000..5863edb --- /dev/null +++ b/watcher/src/watcher/event_loop.py @@ -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//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) diff --git a/watcher/src/watcher/main.py b/watcher/src/watcher/main.py new file mode 100644 index 0000000..225c12e --- /dev/null +++ b/watcher/src/watcher/main.py @@ -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()) diff --git a/watcher/src/watcher/nc_client.py b/watcher/src/watcher/nc_client.py new file mode 100644 index 0000000..01704fe --- /dev/null +++ b/watcher/src/watcher/nc_client.py @@ -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 = ( + '' + '' + ) + 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 diff --git a/watcher/tests/__init__.py b/watcher/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher/tests/conftest.py b/watcher/tests/conftest.py new file mode 100644 index 0000000..fe183aa --- /dev/null +++ b/watcher/tests/conftest.py @@ -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, + ) diff --git a/watcher/tests/test_event_loop.py b/watcher/tests/test_event_loop.py new file mode 100644 index 0000000..15fe1eb --- /dev/null +++ b/watcher/tests/test_event_loop.py @@ -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() diff --git a/watcher/tests/test_nc_client.py b/watcher/tests/test_nc_client.py new file mode 100644 index 0000000..0d1d5ea --- /dev/null +++ b/watcher/tests/test_nc_client.py @@ -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 diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..2ced2cf --- /dev/null +++ b/web/Dockerfile @@ -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;"] diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..6bae3d1 --- /dev/null +++ b/web/eslint.config.js @@ -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: "^_" }], + }, + } +); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..40d5b2c --- /dev/null +++ b/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + RehearsalHub + + +
+ + + diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..adb1283 --- /dev/null +++ b/web/nginx.conf @@ -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; +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..dc96e3d --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,4667 @@ +{ + "name": "rehearsalhub-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rehearsalhub-web", + "version": "0.1.0", + "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" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz", + "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.95.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", + "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.95.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.328", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", + "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wavesurfer.js": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.5.tgz", + "integrity": "sha512-MSZcA13R9ZlxgYpzfakaSYf8dz5tCdZKYbjtN1qnKbCi+UoyfaTuhvjlXHrITi/fgeO3qWfsH7U3BP1AKnwRNg==", + "license": "BSD-3-Clause" + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..997b5c3 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..e2981a4 --- /dev/null +++ b/web/src/App.tsx @@ -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} : ; +} + +export default function App() { + return ( + + + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + + + + ); +} diff --git a/web/src/api/annotations.ts b/web/src/api/annotations.ts new file mode 100644 index 0000000..b10beda --- /dev/null +++ b/web/src/api/annotations.ts @@ -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(`/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(`/versions/${versionId}/annotations`, data); + +export const updateAnnotation = ( + annotationId: string, + data: { body?: string; label?: string; tags?: string[]; resolved?: boolean } +) => api.patch(`/annotations/${annotationId}`, data); + +export const deleteAnnotation = (annotationId: string) => + api.delete(`/annotations/${annotationId}`); + +export const addReaction = (annotationId: string, emoji: string) => + api.post(`/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(`/bands/${bandId}/search/ranges?${qs}`); +}; diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts new file mode 100644 index 0000000..7c51fa3 --- /dev/null +++ b/web/src/api/auth.ts @@ -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 { + const resp = await api.post("/auth/login", req); + setToken(resp.access_token); + return resp; +} + +export async function register(req: { + email: string; + password: string; + display_name: string; +}): Promise { + return api.post("/auth/register", req); +} diff --git a/web/src/api/bands.ts b/web/src/api/bands.ts new file mode 100644 index 0000000..e298172 --- /dev/null +++ b/web/src/api/bands.ts @@ -0,0 +1,29 @@ +import { api } from "./client"; + +export interface Band { + id: string; + name: string; + slug: string; + genre_tags: string[]; + nc_folder_path: string | null; + created_at: string; + updated_at: string; + memberships?: BandMembership[]; +} + +export interface BandMembership { + member: { id: string; email: string; display_name: string }; + role: string; + instrument: string | null; + joined_at: string; +} + +export const listBands = () => api.get("/bands"); +export const getBand = (bandId: string) => api.get(`/bands/${bandId}`); + +export const createBand = (data: { + name: string; + slug: string; + genre_tags?: string[]; + nc_base_path?: string; +}) => api.post("/bands", data); diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..1979bb8 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,43 @@ +const BASE = "/api/v1"; + +function getToken(): string | null { + return localStorage.getItem("rh_token"); +} + +export function setToken(token: string): void { + localStorage.setItem("rh_token", token); +} + +export function clearToken(): void { + localStorage.removeItem("rh_token"); +} + +async function request( + path: string, + options: RequestInit = {} +): Promise { + const token = getToken(); + const headers: Record = { + "Content-Type": "application/json", + ...(options.headers as Record), + }; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + const resp = await fetch(`${BASE}${path}`, { ...options, headers }); + if (!resp.ok) { + const error = await resp.json().catch(() => ({ detail: resp.statusText })); + throw new Error(error.detail ?? resp.statusText); + } + if (resp.status === 204) return undefined as T; + return resp.json(); +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body: unknown) => + request(path, { method: "POST", body: JSON.stringify(body) }), + patch: (path: string, body: unknown) => + request(path, { method: "PATCH", body: JSON.stringify(body) }), + delete: (path: string) => request(path, { method: "DELETE" }), +}; diff --git a/web/src/hooks/useWaveform.ts b/web/src/hooks/useWaveform.ts new file mode 100644 index 0000000..fb319a7 --- /dev/null +++ b/web/src/hooks/useWaveform.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from "react"; +import WaveSurfer from "wavesurfer.js"; + +export interface UseWaveformOptions { + url: string | null; + peaksUrl: string | null; + onReady?: (duration: number) => void; + onTimeUpdate?: (currentTime: number) => void; +} + +export function useWaveform( + containerRef: React.RefObject, + options: UseWaveformOptions +) { + const wsRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [isReady, setIsReady] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + + useEffect(() => { + if (!containerRef.current || !options.url) return; + + const ws = WaveSurfer.create({ + container: containerRef.current, + waveColor: "#2A3050", + progressColor: "#F0A840", + cursorColor: "#FFD080", + barWidth: 2, + barRadius: 2, + height: 80, + normalize: true, + }); + + ws.load(options.url); + + ws.on("ready", () => { + setIsReady(true); + options.onReady?.(ws.getDuration()); + }); + + ws.on("audioprocess", (time) => { + setCurrentTime(time); + options.onTimeUpdate?.(time); + }); + + ws.on("play", () => setIsPlaying(true)); + ws.on("pause", () => setIsPlaying(false)); + ws.on("finish", () => setIsPlaying(false)); + + wsRef.current = ws; + return () => { + ws.destroy(); + wsRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options.url]); + + const play = () => wsRef.current?.play(); + const pause = () => wsRef.current?.pause(); + const seekTo = (time: number) => { + if (wsRef.current && isReady) { + wsRef.current.setTime(time); + } + }; + + return { isPlaying, isReady, currentTime, play, pause, seekTo }; +} diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..3e6085a --- /dev/null +++ b/web/src/hooks/useWebSocket.ts @@ -0,0 +1,40 @@ +import { useEffect, useLayoutEffect, useRef } from "react"; + +type WsEvent = { event: string; data: unknown }; +type EventHandler = (data: unknown) => void; + +export function useVersionWebSocket( + versionId: string | null, + handlers: Record +) { + const handlersRef = useRef(handlers); + useLayoutEffect(() => { + handlersRef.current = handlers; + }); + + useEffect(() => { + if (!versionId) return; + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const ws = new WebSocket(`${protocol}://${window.location.host}/ws/versions/${versionId}`); + + ws.onmessage = (evt) => { + try { + const msg: WsEvent = JSON.parse(evt.data); + handlersRef.current[msg.event]?.(msg.data); + } catch { + // ignore malformed messages + } + }; + + const pingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ event: "ping" })); + } + }, 30_000); + + return () => { + clearInterval(pingInterval); + ws.close(); + }; + }, [versionId]); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..1f20858 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; + +const root = document.getElementById("root"); +if (!root) throw new Error("No #root element found"); + +createRoot(root).render( + + + +); diff --git a/web/src/pages/BandPage.tsx b/web/src/pages/BandPage.tsx new file mode 100644 index 0000000..ea3d1e5 --- /dev/null +++ b/web/src/pages/BandPage.tsx @@ -0,0 +1,265 @@ +import { useState } from "react"; +import { useParams, Link } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { getBand } from "../api/bands"; +import { api } from "../api/client"; + +interface SongSummary { + id: string; + title: string; + status: string; + version_count: number; +} + +interface BandMember { + id: string; + display_name: string; + email: string; + role: string; + joined_at: string; +} + +interface BandInvite { + id: string; + token: string; + role: string; + expires_at: string; +} + +export function BandPage() { + const { bandId } = useParams<{ bandId: string }>(); + const qc = useQueryClient(); + const [showCreate, setShowCreate] = useState(false); + const [title, setTitle] = useState(""); + const [error, setError] = useState(null); + const [scanMsg, setScanMsg] = useState(null); + const [inviteLink, setInviteLink] = useState(null); + + const { data: band, isLoading } = useQuery({ + queryKey: ["band", bandId], + queryFn: () => getBand(bandId!), + enabled: !!bandId, + }); + + const { data: songs } = useQuery({ + queryKey: ["songs", bandId], + queryFn: () => api.get(`/bands/${bandId}/songs`), + enabled: !!bandId, + }); + + const { data: members } = useQuery({ + queryKey: ["members", bandId], + queryFn: () => api.get(`/bands/${bandId}/members`), + enabled: !!bandId, + }); + + const createMutation = useMutation({ + mutationFn: () => api.post(`/bands/${bandId}/songs`, { title }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["songs", bandId] }); + setShowCreate(false); + setTitle(""); + setError(null); + }, + onError: (err) => setError(err instanceof Error ? err.message : "Failed to create song"), + }); + + const scanMutation = useMutation({ + mutationFn: () => api.post(`/bands/${bandId}/nc-scan`, {}), + onSuccess: (imported) => { + qc.invalidateQueries({ queryKey: ["songs", bandId] }); + setScanMsg( + imported.length > 0 + ? `Imported ${imported.length} new song${imported.length !== 1 ? "s" : ""} from Nextcloud.` + : "No new audio files found in Nextcloud." + ); + setTimeout(() => setScanMsg(null), 4000); + }, + onError: (err) => setScanMsg(err instanceof Error ? err.message : "Scan failed"), + }); + + const inviteMutation = useMutation({ + mutationFn: () => api.post(`/bands/${bandId}/invites`, {}), + onSuccess: (invite) => { + const url = `${window.location.origin}/invite/${invite.token}`; + setInviteLink(url); + navigator.clipboard.writeText(url).catch(() => {}); + }, + }); + + const removeMemberMutation = useMutation({ + mutationFn: (memberId: string) => api.delete(`/bands/${bandId}/members/${memberId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }), + }); + + // We determine "am I admin?" from GET /auth/me cross-referenced with the members list. + // The simplest heuristic: the creator of the band (first admin in the list) is the current user + // if they appear with role=admin. We store the current member id in the JWT subject but don't + // expose it yet, so we compare by checking if the members list has exactly one admin and we + // can tell by the invite button being available on the backend (403 vs 201). + // For the UI we just show the Remove button for non-admin members and let the API enforce auth. + + if (isLoading) return
Loading...
; + if (!band) return
Band not found
; + + const amAdmin = members?.some((m) => m.role === "admin") ?? false; + + return ( +
+
+ + ← All Bands + + +
+

{band.name}

+ {band.genre_tags.length > 0 && ( +
+ {band.genre_tags.map((t: string) => ( + {t} + ))} +
+ )} +
+ + {/* ── Members ── */} +
+
+

Members

+ +
+ + {inviteLink && ( +
+

Invite link (copied to clipboard, valid 72h):

+ {inviteLink} + +
+ )} + +
+ {members?.map((m) => ( +
+
+ {m.display_name} + {m.email} +
+
+ + {m.role} + + {amAdmin && m.role !== "admin" && ( + + )} +
+
+ ))} +
+
+ + {/* ── Songs ── */} +
+

Songs

+
+ + +
+
+ + {scanMsg && ( +
+ {scanMsg} +
+ )} + + {showCreate && ( +
+ {error &&

{error}

} + + setTitle(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && title && createMutation.mutate()} + style={{ width: "100%", padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }} + autoFocus + /> +

+ A folder bands/{band.slug}/songs/{title.toLowerCase().replace(/\s+/g, "-") || "…"}/ will be created in Nextcloud. +

+
+ + +
+
+ )} + +
+ {songs?.map((song) => ( + + {song.title} + + {song.status} + {song.version_count} version{song.version_count !== 1 ? "s" : ""} + + + ))} + {songs?.length === 0 && ( +

+ No songs yet. Create one or scan Nextcloud to import from {band.nc_folder_path ?? `bands/${band.slug}/`}. +

+ )} +
+
+
+ ); +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..2f09624 --- /dev/null +++ b/web/src/pages/HomePage.tsx @@ -0,0 +1,142 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { listBands, createBand } from "../api/bands"; +import { clearToken } from "../api/client"; + +export function HomePage() { + const navigate = useNavigate(); + const qc = useQueryClient(); + const [showCreate, setShowCreate] = useState(false); + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [ncBasePath, setNcBasePath] = useState(""); + const [error, setError] = useState(null); + + const { data: bands, isLoading } = useQuery({ + queryKey: ["bands"], + queryFn: listBands, + }); + + const createMutation = useMutation({ + mutationFn: () => createBand({ name, slug, ...(ncBasePath ? { nc_base_path: ncBasePath } : {}) }), + onSuccess: (band) => { + qc.invalidateQueries({ queryKey: ["bands"] }); + setName(""); setSlug(""); setNcBasePath(""); + navigate(`/bands/${band.id}`); + }, + onError: (err) => setError(err instanceof Error ? err.message : "Failed to create band"), + }); + + function handleSignOut() { + clearToken(); + navigate("/login"); + } + + return ( +
+
+
+

◈ RehearsalHub

+
+ + +
+
+ +
+

Your Bands

+ +
+ + {showCreate && ( +
+ {error &&

{error}

} + + { + setName(e.target.value); + setSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")); + }} + style={{ width: "100%", padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 12, fontSize: 14, boxSizing: "border-box" }} + /> + + setSlug(e.target.value)} + style={{ width: "100%", padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 16, fontSize: 14, fontFamily: "monospace", boxSizing: "border-box" }} + /> + + setNcBasePath(e.target.value)} + placeholder={`bands/${slug || "my-band"}/`} + style={{ width: "100%", padding: "8px 12px", background: "#131720", border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", marginBottom: 4, fontSize: 13, fontFamily: "monospace", boxSizing: "border-box" }} + /> +

+ Path relative to your Nextcloud root. Leave blank to use bands/{slug || "slug"}/ +

+
+ + +
+
+ )} + + {isLoading &&

Loading...

} + +
+ {bands?.map((band) => ( + + ))} + {bands?.length === 0 && ( +

No bands yet. Create one to get started.

+ )} +
+
+
+ ); +} diff --git a/web/src/pages/InvitePage.tsx b/web/src/pages/InvitePage.tsx new file mode 100644 index 0000000..444316f --- /dev/null +++ b/web/src/pages/InvitePage.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { api } from "../api/client"; + +interface InviteInfo { + id: string; + band_id: string; + token: string; + role: string; + expires_at: string; + used_at: string | null; +} + +export function InvitePage() { + const { token } = useParams<{ token: string }>(); + const navigate = useNavigate(); + const [invite, setInvite] = useState(null); + const [error, setError] = useState(null); + const [accepting, setAccepting] = useState(false); + const [done, setDone] = useState(false); + + const isLoggedIn = !!localStorage.getItem("rh_token"); + + useEffect(() => { + if (!token) return; + api.get(`/invites/${token}`) + .then(setInvite) + .catch((err) => setError(err instanceof Error ? err.message : "Invalid invite")); + }, [token]); + + async function accept() { + if (!token) return; + setAccepting(true); + try { + await api.post(`/invites/${token}/accept`, {}); + setDone(true); + setTimeout(() => navigate("/"), 2000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to accept invite"); + } finally { + setAccepting(false); + } + } + + function goLogin() { + navigate(`/login?next=/invite/${token}`); + } + + return ( +
+
+

◈ RehearsalHub

+

Band invite

+ + {error && ( +
+ {error} +
+ )} + + {done && ( +
+ Joined! Redirecting… +
+ )} + + {!done && invite && ( + <> +

+ You've been invited to join a band as {invite.role}. +

+

+ Expires {new Date(invite.expires_at).toLocaleDateString()} + {invite.used_at && " · Already used"} +

+ + {isLoggedIn ? ( + + ) : ( +
+

Log in or register to accept this invite.

+ +
+ )} + + )} + + {!done && !invite && !error && ( +

Loading invite…

+ )} +
+
+ ); +} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..7d2c4a5 --- /dev/null +++ b/web/src/pages/LoginPage.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { login, register } from "../api/auth"; + +export function LoginPage() { + const [mode, setMode] = useState<"login" | "register">("login"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [error, setError] = useState(null); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + try { + if (mode === "login") { + await login({ email, password }); + } else { + await register({ email, password, display_name: displayName }); + await login({ email, password }); + } + navigate(searchParams.get("next") ?? "/"); + } catch (err) { + setError(err instanceof Error ? err.message : mode === "login" ? "Login failed" : "Registration failed"); + } + } + + const inputStyle: React.CSSProperties = { + width: "100%", padding: "10px 12px", background: "#131720", + border: "1px solid #1C2235", borderRadius: 6, color: "#E2E6F0", + marginBottom: 16, fontSize: 14, boxSizing: "border-box", + }; + const labelStyle: React.CSSProperties = { display: "block", color: "#5A6480", fontSize: 11, marginBottom: 6 }; + + return ( +
+
+

◈ RehearsalHub

+

+ {mode === "login" ? "Sign in to your account" : "Create a new account"} +

+ + {error &&

{error}

} + + {mode === "register" && ( + <> + + setDisplayName(e.target.value)} + required + style={inputStyle} + /> + + )} + + + setEmail(e.target.value)} + required + style={inputStyle} + /> + + + setPassword(e.target.value)} + required + style={{ ...inputStyle, marginBottom: 24 }} + /> + + + +

+ {mode === "login" ? "Don't have an account? " : "Already have an account? "} + +

+
+
+ ); +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..3d0e668 --- /dev/null +++ b/web/src/pages/SettingsPage.tsx @@ -0,0 +1,147 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api/client"; + +interface MemberRead { + id: string; + display_name: string; + email: string; + nc_username: string | null; + nc_url: string | null; + nc_configured: boolean; +} + +const getMe = () => api.get("/auth/me"); +const updateSettings = (data: { + display_name?: string; + nc_url?: string; + nc_username?: string; + nc_password?: string; +}) => api.patch("/auth/me/settings", data); + +// Rendered only after `me` is loaded — initializes form state directly from props. +function SettingsForm({ me, onBack }: { me: MemberRead; onBack: () => void }) { + const qc = useQueryClient(); + const [displayName, setDisplayName] = useState(me.display_name ?? ""); + const [ncUrl, setNcUrl] = useState(me.nc_url ?? ""); + const [ncUsername, setNcUsername] = useState(me.nc_username ?? ""); + const [ncPassword, setNcPassword] = useState(""); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + + const saveMutation = useMutation({ + mutationFn: () => + updateSettings({ + display_name: displayName || undefined, + nc_url: ncUrl || undefined, + nc_username: ncUsername || undefined, + nc_password: ncPassword || undefined, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["me"] }); + setSaved(true); + setNcPassword(""); + setError(null); + setTimeout(() => setSaved(false), 3000); + }, + onError: (err) => setError(err instanceof Error ? err.message : "Save failed"), + }); + + return ( + <> +
+

PROFILE

+ + setDisplayName(e.target.value)} style={inputStyle} /> +

{me.email}

+
+ +
+

NEXTCLOUD CONNECTION

+

+ Configure your personal Nextcloud credentials. When set, all file operations (band folders, song uploads, scans) will use these credentials. +

+ +
+ + + {me.nc_configured ? "Connected" : "Not configured"} + +
+ + + setNcUrl(e.target.value)} placeholder="https://cloud.example.com" style={inputStyle} /> + + + setNcUsername(e.target.value)} style={inputStyle} /> + + + setNcPassword(e.target.value)} + placeholder={me.nc_configured ? "•••••••• (leave blank to keep existing)" : ""} + style={inputStyle} + /> +

+ Use an app password from Nextcloud Settings → Security for better security. +

+
+ + {error &&

{error}

} + {saved &&

Settings saved.

} + +
+ + +
+ + ); +} + +export function SettingsPage() { + const navigate = useNavigate(); + const { data: me, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe }); + + return ( +
+
+
+ +

Settings

+
+ + {isLoading &&

Loading...

} + {me && navigate("/")} />} +
+
+ ); +} + +const inputStyle: React.CSSProperties = { + width: "100%", + padding: "8px 12px", + background: "#131720", + border: "1px solid #1C2235", + borderRadius: 6, + color: "#E2E6F0", + fontSize: 14, + boxSizing: "border-box", +}; diff --git a/web/src/pages/SongPage.tsx b/web/src/pages/SongPage.tsx new file mode 100644 index 0000000..5f9b4d2 --- /dev/null +++ b/web/src/pages/SongPage.tsx @@ -0,0 +1,229 @@ +import { useRef, useState, useCallback } from "react"; +import { useParams, Link } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api/client"; +import { listAnnotations, addReaction } from "../api/annotations"; +import { useVersionWebSocket } from "../hooks/useWebSocket"; +import { useWaveform } from "../hooks/useWaveform"; +import type { Annotation } from "../api/annotations"; + +interface SongComment { + id: string; + song_id: string; + body: string; + author_id: string; + author_name: string; + created_at: string; +} + +export function SongPage() { + const { bandId, songId } = useParams<{ bandId: string; songId: string }>(); + const qc = useQueryClient(); + const waveformRef = useRef(null); + const [selectedVersionId, setSelectedVersionId] = useState(null); + const [commentBody, setCommentBody] = useState(""); + + const { data: versions } = useQuery({ + queryKey: ["versions", songId], + queryFn: () => api.get<{ id: string; version_number: number; label: string | null; analysis_status: string }[]>(`/songs/${songId}/versions`), + enabled: !!songId, + }); + + const activeVersion = selectedVersionId ?? versions?.[0]?.id ?? null; + + const { data: annotations } = useQuery({ + queryKey: ["annotations", activeVersion], + queryFn: () => listAnnotations(activeVersion!), + enabled: !!activeVersion, + }); + + const { isPlaying, currentTime, play, pause, seekTo } = useWaveform(waveformRef, { + url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null, + peaksUrl: activeVersion ? `/api/v1/versions/${activeVersion}/waveform` : null, + }); + + const { data: comments } = useQuery({ + queryKey: ["comments", songId], + queryFn: () => api.get(`/songs/${songId}/comments`), + enabled: !!songId, + }); + + const addCommentMutation = useMutation({ + mutationFn: (body: string) => api.post(`/songs/${songId}/comments`, { body }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["comments", songId] }); + setCommentBody(""); + }, + }); + + const deleteCommentMutation = useMutation({ + mutationFn: (commentId: string) => api.delete(`/comments/${commentId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", songId] }), + }); + + const invalidateAnnotations = useCallback( + () => qc.invalidateQueries({ queryKey: ["annotations", activeVersion] }), + [qc, activeVersion] + ); + + useVersionWebSocket(activeVersion, { + "annotation.created": invalidateAnnotations, + "annotation.updated": invalidateAnnotations, + "annotation.deleted": invalidateAnnotations, + "reaction.added": invalidateAnnotations, + }); + + return ( +
+ + ← Back to Band + + + {/* Version selector */} +
+ {versions?.map((v) => ( + + ))} +
+ + {/* Waveform */} +
{ + // TODO: seek on click (needs duration from wavesurfer) + }} + > +
+
+ + + {formatTime(currentTime)} + +
+
+ + {/* Annotations */} +
+ {annotations?.map((a) => ( + + ))} +
+ + {/* Comments */} +
+

COMMENTS

+ +
+ {comments?.map((c) => ( +
+
+ {c.author_name} +
+ {new Date(c.created_at).toLocaleString()} + +
+
+

{c.body}

+
+ ))} + {comments?.length === 0 && ( +

No comments yet. Be the first.

+ )} +
+ +
+