Compare commits

21 Commits

Author SHA1 Message Date
Mistral Vibe
b2d6b4d113 Refactor storage to provider-agnostic band-scoped model
Replaces per-member Nextcloud credentials with a BandStorage model that
supports multiple providers. Credentials are Fernet-encrypted at rest;
worker receives audio via an internal streaming endpoint instead of
direct storage access.

- Add BandStorage DB model with partial unique index (one active per band)
- Add migrations 0007 (create band_storage) and 0008 (drop old nc columns)
- Add StorageFactory that builds the correct StorageClient from BandStorage
- Add storage router: connect/nextcloud, OAuth2 authorize/callback, list, disconnect
- Add Fernet encryption helpers in security/encryption.py
- Rewrite watcher for per-band polling via internal API config endpoint
- Update worker to stream audio from API instead of accessing storage directly
- Update frontend: new storage API in bands.ts, rewritten StorageSection,
  simplified band creation modal (no storage step)
- Add STORAGE_ENCRYPTION_KEY to all docker-compose files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 23:22:36 +02:00
Mistral Vibe
ba22853bc7 Wokring on Nextcloud scan 2026-04-10 13:01:31 +02:00
Mistral Vibe
4bab0a76f7 Build update 2026-04-10 12:23:27 +02:00
Mistral Vibe
5bb3f9c1f7 up 2026-04-10 12:09:13 +02:00
Mistral Vibe
7e7fd8c8f0 adding prod compose 2026-04-10 11:40:55 +02:00
Mistral Vibe
4d56ea0a4f Merge branch 'feature/pipeline-fix' into development 2026-04-10 11:33:01 +02:00
Mistral Vibe
9f552b47fd Fixing release pipeline 2026-04-10 11:31:29 +02:00
Mistral Vibe
411414b9c1 Fixing build 2026-04-10 10:23:32 +02:00
Mistral Vibe
6f77bb8c42 installing dev dependencies 2026-04-10 10:13:28 +02:00
Mistral Vibe
820a28f31c fix(worker): don't set cdn_hls_base until HLS is uploaded; add reindex
Two bugs fixed:

1. handle_transcode was writing cdn_hls_base = "hls/{version_id}" to the DB
   even though HLS files were only in a temp dir (never uploaded to Nextcloud).
   The stream endpoint then tried to serve this non-existent path, returning 404
   and breaking audio playback for every transcoded version. Removed the
   cdn_hls_base write — stream endpoint falls back to nc_file_path (raw file),
   which works correctly.

2. Added extract_peaks worker job type: lightweight job that downloads audio
   and computes waveform_peaks + waveform_peaks_mini only. No transcode, no HLS,
   no full analysis.

3. Added POST /internal/reindex-peaks endpoint (protected by internal secret):
   finds all audio_versions with null waveform_peaks and enqueues extract_peaks
   jobs. Safe to call multiple times. Use after a fresh DB scan or peak algorithm
   changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:46:38 +02:00
Mistral Vibe
efb16a096d feat(band): two-step create flow with Nextcloud storage setup
Band creation now starts with a Nextcloud credentials step when
storage is not yet configured. Users can save NC credentials
(or skip) before proceeding to band name/slug/folder entry.

- StorageStep: NC URL, username, app password; PATCH /auth/me/settings
- BandStep: name, slug (auto-generated), NC folder with warning when NC not set
- StepDots: animated pill indicators for current step
- Modal fetches /auth/me on open to determine starting step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:26:25 +02:00
Mistral Vibe
1a29e6f492 feat(band): add Nextcloud folder field to band creation modal
The "New band" button in TopBandBar previously navigated to the HomePage
which immediately redirected back if any bands already existed, making it
impossible to create additional bands.

Replaced the navigation with an inline modal that:
- Opens directly from the "New band" button in the band switcher dropdown
- Fields: band name (with auto-slug), slug, Nextcloud folder path
- NC folder input shows placeholder based on current slug, links to
  Settings → Storage so the user knows where to configure Nextcloud
- Validates: disabled submit until name + slug are filled
- On success: invalidates band list cache and navigates to the new band
- Closes on backdrop click or Escape key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:19:33 +02:00
Mistral Vibe
037881a821 feat(waveform): precompute and store peaks in DB for instant rendering
Store waveform peaks inline in audio_versions (JSONB columns) so WaveSurfer
can render the waveform immediately on page load without waiting for audio
decode. Adds a 100-point mini-waveform for version selector thumbnails.

Backend:
- Migration 0006: adds waveform_peaks and waveform_peaks_mini JSONB columns
- Worker generates both resolutions (500-pt full, 100-pt mini) during transcode
  and stores them directly in DB — replaces file-based waveform_url approach
- AudioVersionRead schema exposes both fields inline (no extra HTTP round-trip)
- GET /versions/{id}/waveform reads from DB; adds ?resolution=mini support

Frontend:
- audioService.initialize() accepts peaks and calls ws.load(url, Float32Array)
  so waveform renders instantly without audio decode
- useWaveform hook threads peaks option through to audioService
- PlayerPanel passes waveform_peaks from the active version to the hook
- New MiniWaveform SVG component (no WaveSurfer) renders mini peaks in the
  version selector buttons

Fix: docker-compose.dev.yml now runs alembic upgrade head before starting
the API server, so a fresh volume gets the full schema automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:16:00 +02:00
Mistral Vibe
6876bc1390 feat(theme): update wavesurfer colors to teal scheme
waveColor: subtle teal tint, progressColor/cursorColor match accent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:21:08 +02:00
Mistral Vibe
312f3dd161 feat(theme): replace purple accent with teal/turquoise color scheme
Swaps violet (#8b5cf6) for teal (#14b8a6/#0d9488) across all components
and updates dark backgrounds to have a green-tinted hue instead of blue-navy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:20:25 +02:00
Mistral Vibe
b9a83c39cd refactor(layout): replace two-pane split with single-pane navigation
Library and PlayerPanel now display one at a time on all screen sizes.
Selecting a song navigates to the player; the back button returns to
the library. Removes isMobile breakpoint logic and fixed 340px panel width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:14:56 +02:00
Mistral Vibe
d4aad3b8bc Cleanup 2026-04-10 00:37:41 +02:00
Mistral Vibe
21ff7167c4 build system updates 2026-04-10 00:35:15 +02:00
Mistral Vibe
8ea114755a view v2 update 2026-04-10 00:34:09 +02:00
Mistral Vibe
d73377ec2f feat(ui): implement v2 three-panel layout
- Collapsible sidebar (68px icons / 230px expanded, toggle via logo)
- LibraryPanel: sessions expand inline to show tracks, search + filter chips
- PlayerPanel: extracted from SongPage, used as embeddable panel
- BandPage: Library + Player side by side; song selection via ?song= URL param
- SongPage: thin wrapper around PlayerPanel (kept for direct deep-links)
- CSS palette updated to v2 violet/cyan/emerald scheme
- Mobile (<900px): BandPage shows library or player, never both

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:40:37 +02:00
Mistral Vibe
48a73246a1 fix(lint): resolve eslint errors and warnings
- audioService: replace 'as any' with 'as unknown as AudioService' in
  resetInstance() to satisfy @typescript-eslint/no-explicit-any
- SongPage: add isReady to spacebar useEffect deps so the handler always
  sees the current readiness state
- useWaveform: add containerRef to deps (stable ref, safe to include);
  suppress exhaustive-deps for options.onReady with explanation — adding
  an un-memoized callback would cause initialization on every render

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:52:44 +02:00
99 changed files with 6034 additions and 4177 deletions

View File

@@ -0,0 +1,20 @@
{
"auths": {
"git.sschuhmann.de": {
"auth": "BASE64_ENCODED_USERNAME_TOKEN"
}
}
}
# To use this file:
# 1. Copy to ~/.docker/config.json
# 2. Replace BASE64_ENCODED_USERNAME_TOKEN with your actual base64 encoded credentials
# 3. Run: docker login git.sschuhmann.de
# Generate base64 credentials:
# echo -n "username:token" | base64
# Example usage:
# cp .gitea-registry-auth.example ~/.docker/config.json
# # Edit the file with your credentials
# docker login git.sschuhmann.de

86
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: Container Release
on:
push:
tags:
- 'v*'
- '0.*'
- '1.*'
env:
REGISTRY: git.sschuhmann.de
REPOSITORY: sschuhmann/rehearsalhub
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.GITEA_USER }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Build and push API container
uses: docker/build-push-action@v5
with:
context: ./api
file: ./api/Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}/api:${{ github.ref_name }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push Web container
uses: docker/build-push-action@v5
with:
context: ./web
file: ./web/Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}/web:${{ github.ref_name }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push Worker container
uses: docker/build-push-action@v5
with:
context: ./worker
file: ./worker/Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}/worker:${{ github.ref_name }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push Watcher container
uses: docker/build-push-action@v5
with:
context: ./watcher
file: ./watcher/Dockerfile
push: true
tags: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}/watcher:${{ github.ref_name }}
labels: ${{ steps.meta.outputs.labels }}
- name: Summary
run: |
echo "✅ Container release complete!"
echo ""
echo "Pushed images:"
echo " - ${{ env.REGISTRY }}/${{ env.REPOSITORY }}/api:${{ github.ref_name }}"
echo " - ${{ env.REGISTRY }}/${{ env.REPOSITORY }}/web:${{ github.ref_name }}"
echo " - ${{ env.REGISTRY }}/${{ env.REPOSITORY }}/worker:${{ github.ref_name }}"
echo " - ${{ env.REGISTRY }}/${{ env.REPOSITORY }}/watcher:${{ github.ref_name }}"

View File

@@ -1,78 +0,0 @@
.PHONY: up down build logs migrate seed test test-api test-worker test-watcher lint check format
up: validate-env
docker compose up -d
validate-env:
bash scripts/validate-env.sh
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: validate-env 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

136
PLAN_waveform_precompute.md Normal file
View File

@@ -0,0 +1,136 @@
# Plan: Waveform Pre-computation
**Branch:** `feature/waveform-precompute`
**Goal:** Store waveform peaks in the database during indexing so WaveSurfer renders
the waveform instantly (no waiting for audio decode), and show a mini-waveform in
the library/overview song list.
## Background
WaveSurfer v7 supports `ws.load(url, channelData)` — when pre-computed peaks are
passed as a `Float32Array[]`, the waveform renders immediately and audio streams in
the background. Currently the frontend calls `ws.load(url)` which blocks until the
full audio is decoded.
The worker already generates a 500-point peaks JSON file (`waveform_url`), but:
- It is stored as a file on disk, not inline in the DB
- The frontend never reads it (the `peaksUrl` option in `useWaveform` is wired to
nothing)
## Architecture Decision
Add two JSONB columns to `audio_versions`:
- `waveform_peaks` — 500 points, returned inline with version data, passed to WaveSurfer
- `waveform_peaks_mini` — 100 points, returned inline, used for SVG mini-waveform in
library/song list
This eliminates a separate HTTP round-trip and lets the UI render the waveform the
moment the page loads.
---
## Checklist
### Backend
#### B1 — DB: Peaks columns + Alembic migration
- [ ] Write migration test: after upgrade, `audio_versions` table has `waveform_peaks`
and `waveform_peaks_mini` JSONB columns
- [ ] Create `api/alembic/versions/0006_waveform_peaks_in_db.py`
- [ ] Add `waveform_peaks` and `waveform_peaks_mini` JSONB columns to `AudioVersion`
model in `api/src/rehearsalhub/db/models.py`
#### B2 — Worker: Generate and store both peak resolutions
- [ ] Write unit tests for `extract_peaks()` in `worker/tests/test_waveform.py`:
- Returns exactly `num_points` values
- All values in [0.0, 1.0]
- Empty audio returns list of zeros (no crash)
- 100-point and 500-point both work
- [ ] Update `handle_transcode` in `worker/src/worker/main.py`:
- Generate `peaks_500 = extract_peaks(audio, 500)`
- Generate `peaks_100 = extract_peaks(audio, 100)`
- Store both on `AudioVersion` DB row
- [ ] Write integration test: after `handle_transcode`, row has non-null
`waveform_peaks` (len 500) and `waveform_peaks_mini` (len 100)
#### B3 — API Schema: Expose peaks in `AudioVersionRead`
- [ ] Write serialization test: `AudioVersionRead.model_validate(orm_obj)` includes
`waveform_peaks: list[float] | None` and `waveform_peaks_mini: list[float] | None`
- [ ] Update `api/src/rehearsalhub/schemas/audio_version.py` — add both fields
#### B4 — API Router: `/waveform` endpoint reads from DB
- [ ] Write endpoint tests:
- `GET /versions/{id}/waveform` returns `{"data": [...500 floats...]}` from DB
- `GET /versions/{id}/waveform?resolution=mini` returns 100-point peaks
- 404 when version has no peaks yet
- [ ] Update `api/src/rehearsalhub/routers/versions.py` — read from
`version.waveform_peaks` / `version.waveform_peaks_mini` instead of file I/O
#### B5 — API: Peaks inline on versions list (verify, no change expected)
- [ ] Write test: `GET /songs/{id}/versions` response includes `waveform_peaks` and
`waveform_peaks_mini` on each version object
- [ ] Confirm no router change needed (schema update in B3 is sufficient)
---
### Frontend
#### F1 — Types: Update `AudioVersionRead` TS type
- [ ] Add `waveform_peaks: number[] | null` and `waveform_peaks_mini: number[] | null`
to the TypeScript version type (wherever API types live)
#### F2 — `audioService`: Accept and use pre-computed peaks
- [ ] Write unit tests for `AudioService.initialize()`:
- With peaks: calls `ws.load(url, [Float32Array])` → waveform renders immediately
- Without peaks: calls `ws.load(url)` → falls back to audio decode
- Same URL + same peaks → no re-initialization
- [ ] Update `AudioService.initialize(container, url, peaks?: number[])` in
`web/src/services/audioService.ts`:
- Call `ws.load(url, peaks ? [new Float32Array(peaks)] : undefined)`
#### F3 — `useWaveform` hook: Thread peaks through
- [ ] Write hook test: when `peaks` option is provided, it is forwarded to
`audioService.initialize`
- [ ] Add `peaks?: number[] | null` to `UseWaveformOptions` in
`web/src/hooks/useWaveform.ts`
- [ ] Forward `options.peaks` to `audioService.initialize()` in the effect
#### F4 — `PlayerPanel`: Pass peaks to hook
- [ ] Write component test: `PlayerPanel` passes `version.waveform_peaks` to
`useWaveform`
- [ ] Update `web/src/components/PlayerPanel.tsx` to extract and forward
`waveform_peaks`
#### F5 — `MiniWaveform`: New SVG component for library overview
- [ ] Write unit tests:
- Renders SVG with correct number of bars matching peaks length
- Null/empty peaks renders a grey placeholder (no crash)
- Accepts `peaks`, `width`, `height`, `color` props
- [ ] Create `web/src/components/MiniWaveform.tsx` — pure SVG, no WaveSurfer
- [ ] Integrate into song list / library view using `waveform_peaks_mini`
---
## Testing Strategy
| Layer | Tool |
|------------------|----------------------------------------------|
| Backend unit | pytest, synthetic numpy arrays |
| Backend integration | Real Postgres via docker-compose test profile |
| Frontend unit | Vitest + Testing Library |
| E2E | Playwright — assert waveform visible before audio `canplay` fires |
---
## Implementation Order
1. B1 — migration + model
2. B2 — worker (TDD: unit tests → implementation → integration test)
3. B3 — schema
4. B4 — router
5. B5 — verify versions list
6. F1 — TS types
7. F2 — audioService
8. F3 — useWaveform
9. F4 — PlayerPanel
10. F5 — MiniWaveform

View File

@@ -48,6 +48,8 @@ Files are **never copied** to RehearsalHub servers. The platform reads recording
## Quick start
> **Docker Registry Setup**: For production deployments using Gitea registry, see [DOCKER_REGISTRY.md](DOCKER_REGISTRY.md)
### 1. Configure environment
```bash

View File

@@ -38,8 +38,8 @@ tasks:
build:
desc: Build all images
deps: [check]
cmds:
- task: check
- "{{.COMPOSE}} build"
logs:
@@ -209,12 +209,12 @@ tasks:
check:
desc: Run all linters and type checkers
deps: [lint, typecheck:web]
deps: [lint]
lint:
desc: Lint all services
cmds:
- cd api && uv run ruff check src/ tests/ && uv run mypy src/
- cd api && uv run ruff check src/ tests/
- cd worker && uv run ruff check src/ tests/
- cd watcher && uv run ruff check src/ tests/
- cd web && npm run lint
@@ -251,3 +251,20 @@ tasks:
interactive: true
cmds:
- "{{.COMPOSE}} exec redis redis-cli"
# ── Container Build & Release ──────────────────────────────────────────────
build:containers:
desc: Build all container images with current git tag
cmds:
- bash scripts/build-containers.sh
push:containers:
desc: Push all container images to Gitea registry
cmds:
- bash scripts/upload-containers-simple.sh
release:
desc: Build and push all containers for release (uses current git tag)
cmds:
- bash scripts/release.sh

View File

@@ -0,0 +1,35 @@
"""Store waveform peaks inline in audio_versions table.
Replaces file-based waveform_url approach with two JSONB columns:
- waveform_peaks: 500-point peaks for the player (passed directly to WaveSurfer)
- waveform_peaks_mini: 100-point peaks for library/overview mini-waveform SVG
Revision ID: 0006_waveform_peaks_in_db
Revises: 0005_comment_tag
Create Date: 2026-04-10
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0006_waveform_peaks_in_db"
down_revision = "0005_comment_tag"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"audio_versions",
sa.Column("waveform_peaks", JSONB, nullable=True),
)
op.add_column(
"audio_versions",
sa.Column("waveform_peaks_mini", JSONB, nullable=True),
)
def downgrade() -> None:
op.drop_column("audio_versions", "waveform_peaks_mini")
op.drop_column("audio_versions", "waveform_peaks")

View File

@@ -0,0 +1,68 @@
"""Add band_storage table for provider-agnostic, encrypted storage configs.
Each band can have one active storage provider (Nextcloud, Google Drive, etc.).
Credentials are Fernet-encrypted at the application layer — never stored in plaintext.
A partial unique index enforces at most one active config per band at the DB level.
Revision ID: 0007_band_storage
Revises: 0006_waveform_peaks_in_db
Create Date: 2026-04-10
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "0007_band_storage"
down_revision = "0006_waveform_peaks_in_db"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"band_storage",
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,
),
sa.Column("provider", sa.String(20), nullable=False),
sa.Column("label", sa.String(255), nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="false"),
sa.Column("root_path", sa.Text, nullable=True),
# Fernet-encrypted JSON — never plaintext
sa.Column("credentials", sa.Text, nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# Index for fast per-band lookups
op.create_index("ix_band_storage_band_id", "band_storage", ["band_id"])
# Partial unique index: at most one active storage per band
op.execute(
"""
CREATE UNIQUE INDEX uq_band_active_storage
ON band_storage (band_id)
WHERE is_active = true
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_band_active_storage")
op.drop_index("ix_band_storage_band_id", table_name="band_storage")
op.drop_table("band_storage")

View File

@@ -0,0 +1,42 @@
"""Remove Nextcloud-specific columns from members and bands.
Prior to this migration, storage credentials lived directly on the Member
and Band rows. They are now in the band_storage table (migration 0007),
encrypted at the application layer.
Run 0007 first; if you still need to migrate existing data, do it in a
separate script before applying this migration.
Revision ID: 0008_drop_nc_columns
Revises: 0007_band_storage
Create Date: 2026-04-10
"""
from alembic import op
import sqlalchemy as sa
revision = "0008_drop_nc_columns"
down_revision = "0007_band_storage"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Drop Nextcloud credential columns from members
op.drop_column("members", "nc_url")
op.drop_column("members", "nc_username")
op.drop_column("members", "nc_password")
# Drop Nextcloud-specific columns from bands
op.drop_column("bands", "nc_folder_path")
op.drop_column("bands", "nc_user")
def downgrade() -> None:
# Restore columns (data is lost — this is intentional)
op.add_column("bands", sa.Column("nc_user", sa.String(255), nullable=True))
op.add_column("bands", sa.Column("nc_folder_path", sa.Text, nullable=True))
op.add_column("members", sa.Column("nc_password", sa.Text, nullable=True))
op.add_column("members", sa.Column("nc_username", sa.String(255), nullable=True))
op.add_column("members", sa.Column("nc_url", sa.Text, nullable=True))

View File

@@ -15,6 +15,7 @@ dependencies = [
"pydantic[email]>=2.7",
"pydantic-settings>=2.3",
"python-jose[cryptography]>=3.3",
"cryptography>=42.0",
"bcrypt>=4.1",
"httpx>=0.27",
"redis[hiredis]>=5.0",
@@ -53,6 +54,9 @@ target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["B008", "B904", "UP046", "E501", "SIM102", "SIM211", "F841"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["F401", "F841", "SIM102", "SIM211", "UP017", "I001", "B017"]
[tool.mypy]
python_version = "3.12"
@@ -66,7 +70,9 @@ omit = ["src/rehearsalhub/db/models.py"]
[dependency-groups]
dev = [
"httpx>=0.28.1",
"mypy>=1.19.1",
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"ruff>=0.15.8",
]

View File

@@ -1,4 +1,5 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -11,6 +12,10 @@ class Settings(BaseSettings):
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 # 1 hour
# Storage credential encryption — generate once with: Fernet.generate_key().decode()
# NEVER commit this value; store in env / secrets manager only.
storage_encryption_key: str = ""
# Database
database_url: str # postgresql+asyncpg://...
@@ -27,6 +32,19 @@ class Settings(BaseSettings):
# Worker
analysis_version: str = "1.0.0"
# OAuth2 — Google Drive
google_client_id: str = ""
google_client_secret: str = ""
# OAuth2 — Dropbox
dropbox_app_key: str = ""
dropbox_app_secret: str = ""
# OAuth2 — OneDrive (Microsoft Graph)
onedrive_client_id: str = ""
onedrive_client_secret: str = ""
onedrive_tenant_id: str = "common" # 'common' for multi-tenant apps
@lru_cache
def get_settings() -> Settings:

View File

@@ -4,19 +4,20 @@ from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -35,10 +36,7 @@ class Member(Base):
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)
avatar_url: Mapped[str | None] = 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
@@ -68,8 +66,6 @@ class Band(Base):
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
@@ -87,6 +83,59 @@ class Band(Base):
sessions: Mapped[list[RehearsalSession]] = relationship(
"RehearsalSession", back_populates="band", cascade="all, delete-orphan"
)
storage_configs: Mapped[list[BandStorage]] = relationship(
"BandStorage", back_populates="band", cascade="all, delete-orphan"
)
class BandStorage(Base):
"""Storage provider configuration for a band.
Credentials are stored as a Fernet-encrypted JSON blob — never in plaintext.
Only one ``BandStorage`` row per band may be active at a time, enforced by
a partial unique index on ``(band_id) WHERE is_active``.
Supported providers and their credential shapes (all encrypted):
nextcloud: { "url": "...", "username": "...", "app_password": "..." }
googledrive: { "access_token": "...", "refresh_token": "...",
"token_expiry": "ISO-8601", "token_type": "Bearer" }
onedrive: { "access_token": "...", "refresh_token": "...",
"token_expiry": "ISO-8601", "token_type": "Bearer" }
dropbox: { "access_token": "...", "refresh_token": "...",
"token_expiry": "ISO-8601" }
"""
__tablename__ = "band_storage"
__table_args__ = (
# DB-enforced: at most one active storage config per band.
Index(
"uq_band_active_storage",
"band_id",
unique=True,
postgresql_where=text("is_active = true"),
),
)
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
)
# 'nextcloud' | 'googledrive' | 'onedrive' | 'dropbox'
provider: Mapped[str] = mapped_column(String(20), nullable=False)
label: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Root path within the provider's storage (e.g. "/bands/cool-band/"). Not sensitive.
root_path: Mapped[str | None] = mapped_column(Text)
# Fernet-encrypted JSON blob — shape depends on provider (see docstring above).
credentials: Mapped[str] = mapped_column(Text, 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
)
band: Mapped[Band] = relationship("Band", back_populates="storage_configs")
class BandMember(Base):
@@ -103,7 +152,7 @@ class BandMember(Base):
joined_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
instrument: Mapped[Optional[str]] = mapped_column(String(100))
instrument: Mapped[str | None] = mapped_column(String(100))
band: Mapped[Band] = relationship("Band", back_populates="memberships")
member: Mapped[Member] = relationship("Member", back_populates="band_memberships")
@@ -122,8 +171,8 @@ class BandInvite(Base):
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(
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
used_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
)
@@ -143,9 +192,9 @@ class RehearsalSession(Base):
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
)
date: Mapped[datetime] = mapped_column(DateTime(timezone=False), nullable=False)
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
label: Mapped[Optional[str]] = mapped_column(String(255))
notes: Mapped[Optional[str]] = mapped_column(Text)
nc_folder_path: Mapped[str | None] = mapped_column(Text)
label: Mapped[str | None] = mapped_column(String(255))
notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@@ -164,17 +213,17 @@ class Song(Base):
band_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
session_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("rehearsal_sessions.id", ondelete="SET NULL"), index=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
nc_folder_path: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="jam")
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
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(
global_key: Mapped[str | None] = mapped_column(String(30))
global_bpm: Mapped[float | None] = mapped_column(Numeric(6, 2))
notes: Mapped[str | None] = mapped_column(Text)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
)
created_at: Mapped[datetime] = mapped_column(
@@ -185,8 +234,8 @@ class Song(Base):
)
band: Mapped[Band] = relationship("Band", back_populates="songs")
session: Mapped[Optional[RehearsalSession]] = relationship("RehearsalSession", back_populates="songs")
creator: Mapped[Optional[Member]] = relationship("Member", back_populates="authored_songs")
session: Mapped[RehearsalSession | None] = relationship("RehearsalSession", back_populates="songs")
creator: Mapped[Member | None] = relationship("Member", back_populates="authored_songs")
versions: Mapped[list[AudioVersion]] = relationship(
"AudioVersion", back_populates="song", cascade="all, delete-orphan"
)
@@ -206,8 +255,8 @@ class SongComment(Base):
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
)
body: Mapped[str] = mapped_column(Text, nullable=False)
timestamp: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
tag: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
timestamp: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
tag: Mapped[str | None] = mapped_column(String(32), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@@ -227,16 +276,18 @@ class AudioVersion(Base):
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))
label: Mapped[str | None] = 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)
nc_file_etag: Mapped[str | None] = mapped_column(String(255))
cdn_hls_base: Mapped[str | None] = mapped_column(Text)
waveform_url: Mapped[str | None] = mapped_column(Text)
waveform_peaks: Mapped[list | None] = mapped_column(JSONB)
waveform_peaks_mini: Mapped[list | None] = mapped_column(JSONB)
duration_ms: Mapped[int | None] = mapped_column(Integer)
format: Mapped[str | None] = mapped_column(String(10))
file_size_bytes: Mapped[int | None] = mapped_column(BigInteger)
analysis_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
uploaded_by: Mapped[Optional[uuid.UUID]] = mapped_column(
uploaded_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
)
uploaded_at: Mapped[datetime] = mapped_column(
@@ -244,7 +295,7 @@ class AudioVersion(Base):
)
song: Mapped[Song] = relationship("Song", back_populates="versions")
uploader: Mapped[Optional[Member]] = relationship(
uploader: Mapped[Member | None] = relationship(
"Member", back_populates="uploaded_versions"
)
annotations: Mapped[list[Annotation]] = relationship(
@@ -273,16 +324,16 @@ class Annotation(Base):
)
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))
range_end_ms: Mapped[int | None] = mapped_column(Integer)
body: Mapped[str | None] = mapped_column(Text)
voice_note_url: Mapped[str | None] = mapped_column(Text)
label: Mapped[str | None] = mapped_column(String(255))
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
parent_id: Mapped[uuid.UUID | None] = 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))
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@@ -297,13 +348,13 @@ class Annotation(Base):
replies: Mapped[list[Annotation]] = relationship(
"Annotation", foreign_keys=[parent_id], back_populates="parent"
)
parent: Mapped[Optional[Annotation]] = relationship(
parent: Mapped[Annotation | None] = 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(
range_analysis: Mapped[RangeAnalysis | None] = relationship(
"RangeAnalysis", back_populates="annotation", uselist=False
)
@@ -329,19 +380,19 @@ class RangeAnalysis(Base):
)
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))
bpm: Mapped[float | None] = mapped_column(Numeric(7, 2))
bpm_confidence: Mapped[float | None] = mapped_column(Numeric(4, 3))
key: Mapped[str | None] = mapped_column(String(30))
key_confidence: Mapped[float | None] = mapped_column(Numeric(4, 3))
scale: Mapped[str | None] = mapped_column(String(10))
avg_loudness_lufs: Mapped[float | None] = mapped_column(Numeric(6, 2))
peak_loudness_dbfs: Mapped[float | None] = mapped_column(Numeric(6, 2))
spectral_centroid: Mapped[float | None] = mapped_column(Numeric(10, 2))
energy: Mapped[float | None] = mapped_column(Numeric(5, 4))
danceability: Mapped[float | None] = mapped_column(Numeric(5, 4))
chroma_vector: Mapped[list[float] | None] = mapped_column(ARRAY(Numeric))
mfcc_mean: Mapped[list[float] | None] = mapped_column(ARRAY(Numeric))
analysis_version: Mapped[str | None] = mapped_column(String(20))
computed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@@ -393,9 +444,9 @@ class Job(Base):
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)
error: Mapped[str | None] = 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))
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

View File

@@ -10,8 +10,8 @@ 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
from rehearsalhub.services.auth import decode_token
# auto_error=False so we can fall back to cookie auth without a 401 from the scheme itself
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)

View File

@@ -1,7 +1,7 @@
"""RehearsalHub FastAPI application entry point."""
from contextlib import asynccontextmanager
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
@@ -15,11 +15,12 @@ from rehearsalhub.routers import (
annotations_router,
auth_router,
bands_router,
invites_router,
internal_router,
invites_router,
members_router,
sessions_router,
songs_router,
storage_router,
versions_router,
ws_router,
)
@@ -94,6 +95,7 @@ def create_app() -> FastAPI:
app.include_router(annotations_router, prefix=prefix)
app.include_router(members_router, prefix=prefix)
app.include_router(internal_router, prefix=prefix)
app.include_router(storage_router, prefix=prefix)
app.include_router(ws_router) # WebSocket routes don't use /api/v1 prefix
@app.get("/api/health")

View File

@@ -11,7 +11,7 @@ never reads a job ID that isn't yet visible in the DB.
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
import redis.asyncio as aioredis
@@ -60,7 +60,7 @@ class RedisJobQueue:
job = await self._session.get(Job, job_id)
if job:
job.status = "running"
job.started_at = datetime.now(timezone.utc)
job.started_at = datetime.now(UTC)
job.attempt = (job.attempt or 0) + 1
await self._session.flush()
@@ -68,7 +68,7 @@ class RedisJobQueue:
job = await self._session.get(Job, job_id)
if job:
job.status = "done"
job.finished_at = datetime.now(timezone.utc)
job.finished_at = datetime.now(UTC)
await self._session.flush()
async def mark_failed(self, job_id: uuid.UUID, error: str) -> None:
@@ -76,7 +76,7 @@ class RedisJobQueue:
if job:
job.status = "failed"
job.error = error[:2000]
job.finished_at = datetime.now(timezone.utc)
job.finished_at = datetime.now(UTC)
await self._session.flush()
async def dequeue(self, timeout: int = 5) -> tuple[uuid.UUID, str, dict[str, Any]] | None:

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import uuid
from datetime import UTC
from typing import Any
from sqlalchemy import and_, select
@@ -31,9 +32,9 @@ class AnnotationRepository(BaseRepository[Annotation]):
return list(result.scalars().all())
async def soft_delete(self, annotation: Annotation) -> None:
from datetime import datetime, timezone
from datetime import datetime
annotation.deleted_at = datetime.now(timezone.utc)
annotation.deleted_at = datetime.now(UTC)
await self.session.flush()
async def search_ranges(
@@ -45,7 +46,7 @@ class AnnotationRepository(BaseRepository[Annotation]):
tag: str | None = None,
min_duration_ms: int | None = None,
) -> list[dict[str, Any]]:
from rehearsalhub.db.models import AudioVersion, RangeAnalysis, Song
from rehearsalhub.db.models import AudioVersion, Song
conditions = [
Song.band_id == band_id,

View File

@@ -17,6 +17,11 @@ class AudioVersionRepository(BaseRepository[AudioVersion]):
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_by_nc_file_path(self, nc_file_path: str) -> AudioVersion | None:
stmt = select(AudioVersion).where(AudioVersion.nc_file_path == nc_file_path)
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)
@@ -37,7 +42,7 @@ class AudioVersionRepository(BaseRepository[AudioVersion]):
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
from rehearsalhub.db.models import Annotation
stmt = (
select(AudioVersion)

View File

@@ -1,14 +1,13 @@
from __future__ import annotations
import secrets
import uuid
from datetime import UTC, datetime, timedelta
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.db.models import Band, BandInvite, BandMember, BandStorage
from rehearsalhub.repositories.base import BaseRepository
@@ -69,7 +68,7 @@ class BandRepository(BaseRepository[Band]):
token=secrets.token_urlsafe(32),
role=role,
created_by=created_by,
expires_at=datetime.now(timezone.utc) + timedelta(hours=ttl_hours),
expires_at=datetime.now(UTC) + timedelta(hours=ttl_hours),
)
self.session.add(invite)
await self.session.flush()
@@ -93,16 +92,27 @@ class BandRepository(BaseRepository[Band]):
return list(result.scalars().all())
async def get_by_nc_folder_prefix(self, path: str) -> Band | None:
"""Return the band whose nc_folder_path is a prefix of path."""
stmt = select(Band).where(Band.nc_folder_path.is_not(None))
"""Return the band whose active storage root_path is a prefix of *path*.
Longest match wins (most-specific prefix) so nested paths resolve correctly.
"""
stmt = (
select(Band, BandStorage.root_path)
.join(
BandStorage,
(BandStorage.band_id == Band.id) & BandStorage.is_active.is_(True),
)
.where(BandStorage.root_path.is_not(None))
)
result = await self.session.execute(stmt)
bands = result.scalars().all()
# Longest match wins (most specific prefix)
rows = result.all()
best: Band | None = None
for band in bands:
folder = band.nc_folder_path # type: ignore[union-attr]
if path.startswith(folder) and (best is None or len(folder) > len(best.nc_folder_path)): # type: ignore[arg-type]
best_len = 0
for band, root_path in rows:
folder = root_path.rstrip("/") + "/"
if path.startswith(folder) and len(folder) > best_len:
best = band
best_len = len(folder)
return best
async def list_for_member(self, member_id: uuid.UUID) -> list[Band]:

View File

@@ -0,0 +1,66 @@
"""Repository for BandStorage — per-band storage provider configuration."""
from __future__ import annotations
import uuid
from sqlalchemy import select, update
from rehearsalhub.db.models import BandStorage
from rehearsalhub.repositories.base import BaseRepository
class BandStorageRepository(BaseRepository[BandStorage]):
model = BandStorage
async def get_active_for_band(self, band_id: uuid.UUID) -> BandStorage | None:
"""Return the single active storage config for *band_id*, or None."""
result = await self.session.execute(
select(BandStorage).where(
BandStorage.band_id == band_id,
BandStorage.is_active.is_(True),
)
)
return result.scalar_one_or_none()
async def list_for_band(self, band_id: uuid.UUID) -> list[BandStorage]:
result = await self.session.execute(
select(BandStorage)
.where(BandStorage.band_id == band_id)
.order_by(BandStorage.created_at)
)
return list(result.scalars().all())
async def list_active_by_provider(self, provider: str) -> list[BandStorage]:
"""Return all active configs for a given provider (used by the watcher)."""
result = await self.session.execute(
select(BandStorage).where(
BandStorage.provider == provider,
BandStorage.is_active.is_(True),
)
)
return list(result.scalars().all())
async def activate(self, storage_id: uuid.UUID, band_id: uuid.UUID) -> BandStorage:
"""Deactivate all configs for *band_id*, then activate *storage_id*."""
await self.session.execute(
update(BandStorage)
.where(BandStorage.band_id == band_id)
.values(is_active=False)
)
storage = await self.get_by_id(storage_id)
if storage is None:
raise LookupError(f"BandStorage {storage_id} not found")
storage.is_active = True
await self.session.flush()
await self.session.refresh(storage)
return storage
async def deactivate_all(self, band_id: uuid.UUID) -> None:
"""Deactivate every storage config for a band (disconnect)."""
await self.session.execute(
update(BandStorage)
.where(BandStorage.band_id == band_id)
.values(is_active=False)
)
await self.session.flush()

View File

@@ -3,7 +3,8 @@
from __future__ import annotations
import uuid
from typing import Any, Generic, Sequence, TypeVar
from collections.abc import Sequence
from typing import Any, Generic, TypeVar
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from sqlalchemy import select
@@ -24,7 +24,7 @@ class JobRepository(BaseRepository[Job]):
job = await self.get_by_id(job_id)
if job:
job.status = "running"
job.started_at = datetime.now(timezone.utc)
job.started_at = datetime.now(UTC)
job.attempt = (job.attempt or 0) + 1
await self.session.flush()
return job
@@ -33,7 +33,7 @@ class JobRepository(BaseRepository[Job]):
job = await self.get_by_id(job_id)
if job:
job.status = "done"
job.finished_at = datetime.now(timezone.utc)
job.finished_at = datetime.now(UTC)
await self.session.flush()
return job
@@ -42,6 +42,6 @@ class JobRepository(BaseRepository[Job]):
if job:
job.status = "failed"
job.error = error[:2000]
job.finished_at = datetime.now(timezone.utc)
job.finished_at = datetime.now(UTC)
await self.session.flush()
return job

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@@ -32,12 +31,12 @@ class SongRepository(BaseRepository[Song]):
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":
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":
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()
@@ -53,9 +52,8 @@ class SongRepository(BaseRepository[Song]):
session_id: uuid.UUID | None = None,
unattributed: bool = False,
) -> list[Song]:
from sqlalchemy import cast, func
from sqlalchemy import Text, cast, func
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy import Text
stmt = (
select(Song)

View File

@@ -1,11 +1,12 @@
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.invites import router as invites_router
from rehearsalhub.routers.internal import router as internal_router
from rehearsalhub.routers.invites import router as invites_router
from rehearsalhub.routers.members import router as members_router
from rehearsalhub.routers.sessions import router as sessions_router
from rehearsalhub.routers.songs import router as songs_router
from rehearsalhub.routers.storage import router as storage_router
from rehearsalhub.routers.versions import router as versions_router
from rehearsalhub.routers.ws import router as ws_router
@@ -17,6 +18,7 @@ __all__ = [
"members_router",
"sessions_router",
"songs_router",
"storage_router",
"versions_router",
"annotations_router",
"ws_router",

View File

@@ -34,7 +34,7 @@ async def register(request: Request, req: RegisterRequest, session: AsyncSession
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)
return MemberRead.model_validate(member)
@router.post("/login", response_model=TokenResponse)
@@ -87,7 +87,7 @@ async def logout(response: Response):
@router.get("/me", response_model=MemberRead)
async def get_me(current_member: Member = Depends(get_current_member)):
return MemberRead.from_model(current_member)
return MemberRead.model_validate(current_member)
@router.patch("/me/settings", response_model=MemberRead)
@@ -100,12 +100,6 @@ async def update_settings(
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 data.avatar_url is not None:
updates["avatar_url"] = data.avatar_url or None
@@ -113,7 +107,7 @@ async def update_settings(
member = await repo.update(current_member, **updates)
else:
member = current_member
return MemberRead.from_model(member)
return MemberRead.model_validate(member)
@router.post("/me/avatar", response_model=MemberRead)
@@ -187,4 +181,4 @@ async def upload_avatar(
repo = MemberRepository(session)
avatar_url = f"/api/static/avatars/{filename}"
member = await repo.update(current_member, avatar_url=avatar_url)
return MemberRead.from_model(member)
return MemberRead.model_validate(member)

View File

@@ -1,17 +1,16 @@
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
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 BandInvite, Member
from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandUpdate
from rehearsalhub.schemas.invite import BandInviteList, BandInviteListItem, InviteInfoRead
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandUpdate
from rehearsalhub.schemas.invite import BandInviteList, BandInviteListItem
from rehearsalhub.services.band import BandService
from rehearsalhub.storage.nextcloud import NextcloudClient
router = APIRouter(prefix="/bands", tags=["bands"])
@@ -37,7 +36,7 @@ async def list_invites(
invites = await repo.get_invites_for_band(band_id)
# Filter for non-expired invites (optional - could also show expired)
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
pending_invites = [
invite for invite in invites
if invite.expires_at > now and invite.used_at is None
@@ -93,7 +92,7 @@ async def revoke_invite(
)
# Check if invite is still pending (not used and not expired)
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
if invite.used_at is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -126,10 +125,9 @@ async def create_band(
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
storage = NextcloudClient.for_member(current_member)
svc = BandService(session, storage)
svc = BandService(session)
try:
band = await svc.create_band(data, current_member.id, creator=current_member)
band = await svc.create_band(data, current_member.id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
except LookupError as e:
@@ -143,8 +141,7 @@ async def get_band(
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
storage = NextcloudClient.for_member(current_member)
svc = BandService(session, storage)
svc = BandService(session)
try:
await svc.assert_membership(band_id, current_member.id)
except PermissionError:
@@ -173,9 +170,10 @@ async def update_band(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
updates: dict = {}
if data.nc_folder_path is not None:
path = data.nc_folder_path.strip()
updates["nc_folder_path"] = (path.rstrip("/") + "/") if path else None
if data.name is not None:
updates["name"] = data.name.strip()
if data.genre_tags is not None:
updates["genre_tags"] = data.genre_tags
if updates:
band = await repo.update(band, **updates)

View File

@@ -1,24 +1,28 @@
"""Internal endpoints — called by trusted services (watcher) on the Docker network."""
"""Internal endpoints — called by trusted services (watcher, worker) on the Docker network."""
import logging
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.config import get_settings
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import BandMember, Member
from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.db.models import AudioVersion, BandMember
from rehearsalhub.queue.redis_queue import RedisJobQueue
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.repositories.band_storage import BandStorageRepository
from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.security.encryption import decrypt_credentials
from rehearsalhub.services.session import extract_session_folder, parse_rehearsal_date
from rehearsalhub.services.song import SongService
from rehearsalhub.storage.nextcloud import NextcloudClient
from rehearsalhub.storage.factory import StorageFactory
log = logging.getLogger(__name__)
@@ -34,6 +38,9 @@ async def _verify_internal_secret(x_internal_token: str | None = Header(None)) -
AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus"}
# ── Watcher: detect new audio file ────────────────────────────────────────────
class NcUploadEvent(BaseModel):
nc_file_path: str
nc_file_etag: str | None = None
@@ -45,10 +52,9 @@ async def nc_upload(
session: AsyncSession = Depends(get_session),
_: None = Depends(_verify_internal_secret),
):
"""
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.
"""Called by nc-watcher when a new audio file is detected in storage.
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("/")
@@ -58,13 +64,11 @@ async def nc_upload(
band_repo = BandRepository(session)
# Try slug-based lookup first (standard bands/{slug}/ layout)
parts = path.split("/")
band = None
if len(parts) >= 3 and parts[0] == "bands":
band = await band_repo.get_by_slug(parts[1])
# Fall back to prefix match for bands with custom nc_folder_path
if band is None:
band = await band_repo.get_by_nc_folder_prefix(path)
@@ -72,38 +76,28 @@ async def nc_upload(
log.info("nc-upload: no band found for path '%s' — skipping", path)
return {"status": "skipped", "reason": "band not found"}
# Determine song title and folder from path.
# The title is always the filename stem (e.g. "take1" from "take1.wav").
# The nc_folder groups all versions of the same recording (the parent directory).
#
# Examples:
# bands/my-band/take1.wav → folder=bands/my-band/, title=take1
# bands/my-band/231015/take1.wav → folder=bands/my-band/231015/, title=take1
# bands/my-band/songs/groove/take1.wav → folder=bands/my-band/songs/groove/, title=take1
parent = str(Path(path).parent)
nc_folder = parent.rstrip("/") + "/"
title = Path(path).stem
# If the file sits directly inside a dated session folder, give it a unique
# virtual folder so it becomes its own song (not merged with other takes).
session_folder_path = extract_session_folder(path)
if session_folder_path and session_folder_path.rstrip("/") == nc_folder.rstrip("/"):
nc_folder = nc_folder + title + "/"
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"}
# Resolve or create rehearsal session from YYMMDD folder segment
session_repo = RehearsalSessionRepository(session)
rehearsal_date = parse_rehearsal_date(path)
rehearsal_session_id = None
if rehearsal_date:
try:
rehearsal_session = await session_repo.get_or_create(band.id, rehearsal_date, nc_folder)
rehearsal_session_id = rehearsal_session.id
log.debug("nc-upload: linked to session %s (%s)", rehearsal_session_id, rehearsal_date)
except Exception as exc:
log.error("nc-upload: failed to resolve session for '%s': %s", path, exc, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to resolve rehearsal session") from exc
song_repo = SongRepository(session)
try:
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)
@@ -120,23 +114,17 @@ async def nc_upload(
log.info("nc-upload: created song '%s' for band '%s'", title, band.slug)
elif rehearsal_session_id and song.session_id is None:
song = await song_repo.update(song, session_id=rehearsal_session_id)
except Exception as exc:
log.error("nc-upload: failed to find/create song for '%s': %s", path, exc, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to resolve song") from exc
# Use first member of the band as uploader (best-effort for watcher uploads)
result = await session.execute(
select(BandMember.member_id).where(BandMember.band_id == band.id).limit(1)
)
uploader_id = result.scalar_one_or_none()
# Get the uploader's storage credentials
storage = None
if uploader_id:
uploader_result = await session.execute(
select(Member).where(Member.id == uploader_id).limit(1) # type: ignore[arg-type]
)
uploader = uploader_result.scalar_one_or_none()
storage = NextcloudClient.for_member(uploader) if uploader else None
song_svc = SongService(session, storage=storage)
try:
song_svc = SongService(session)
version = await song_svc.register_version(
song.id,
AudioVersionCreate(
@@ -146,5 +134,133 @@ async def nc_upload(
),
uploader_id,
)
except Exception as exc:
log.error(
"nc-upload: failed to register version for '%s' (song '%s'): %s",
path, song.title, exc, exc_info=True,
)
raise HTTPException(status_code=500, detail="Failed to register version") from exc
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)}
# ── Worker: stream audio ───────────────────────────────────────────────────────
@router.get("/audio/{version_id}/stream")
async def stream_audio(
version_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
_: None = Depends(_verify_internal_secret),
):
"""Proxy an audio file from the band's storage to the caller (audio-worker).
The worker never handles storage credentials. This endpoint resolves the
band's active storage config and streams the file transparently.
"""
result = await session.execute(
select(AudioVersion).where(AudioVersion.id == version_id)
)
version = result.scalar_one_or_none()
if version is None:
raise HTTPException(status_code=404, detail="Version not found")
# Resolve the band from the song
from sqlalchemy.orm import selectinload
from rehearsalhub.db.models import Song
song_result = await session.execute(
select(Song).where(Song.id == version.song_id)
)
song = song_result.scalar_one_or_none()
if song is None:
raise HTTPException(status_code=404, detail="Song not found")
try:
storage = await StorageFactory.create(session, song.band_id, get_settings())
except LookupError:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Band has no active storage configured",
)
log.info("stream_audio: streaming version %s from storage", version_id)
async def _stream():
data = await storage.download(version.nc_file_path)
yield data
return StreamingResponse(_stream(), media_type="application/octet-stream")
# ── Watcher: list active Nextcloud configs ─────────────────────────────────────
@router.get("/storage/nextcloud-watch-configs")
async def get_nextcloud_watch_configs(
session: AsyncSession = Depends(get_session),
_: None = Depends(_verify_internal_secret),
):
"""Return decrypted Nextcloud configs for all active NC bands.
Used exclusively by the nc-watcher service to know which Nextcloud
instances to poll and with what credentials. Traffic stays on the
internal Docker network and is never exposed externally.
"""
settings = get_settings()
if not settings.storage_encryption_key:
raise HTTPException(status_code=500, detail="Storage encryption key not configured")
repo = BandStorageRepository(session)
configs = await repo.list_active_by_provider("nextcloud")
result = []
for config in configs:
try:
creds = decrypt_credentials(settings.storage_encryption_key, config.credentials)
result.append({
"band_id": str(config.band_id),
"nc_url": creds["url"],
"nc_username": creds["username"],
"nc_app_password": creds["app_password"],
"root_path": config.root_path,
})
except Exception as exc:
log.error("Failed to decrypt credentials for band_storage %s: %s", config.id, exc)
# Skip this band rather than failing the whole response
return result
# ── Maintenance: reindex waveform peaks ───────────────────────────────────────
@router.post("/reindex-peaks", status_code=200)
async def reindex_peaks(
session: AsyncSession = Depends(get_session),
_: None = Depends(_verify_internal_secret),
):
"""Enqueue extract_peaks jobs for every audio_version that has no waveform_peaks yet.
Safe to call multiple times — only versions with null peaks are targeted.
"""
result = await session.execute(
select(AudioVersion).where(AudioVersion.waveform_peaks.is_(None)) # type: ignore[attr-defined]
)
versions = result.scalars().all()
if not versions:
return {"status": "ok", "queued": 0, "message": "All versions already have peaks"}
queue = RedisJobQueue(session)
queued = 0
for version in versions:
await queue.enqueue(
"extract_peaks",
{"version_id": str(version.id), "nc_file_path": version.nc_file_path},
)
queued += 1
log.info("reindex-peaks: queued %d extract_peaks jobs", queued)
return {"status": "ok", "queued": queued}

View File

@@ -1,16 +1,14 @@
"""
Invite management endpoints.
"""
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
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 BandInvite, Member
from rehearsalhub.schemas.invite import InviteInfoRead
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.schemas.invite import InviteInfoRead
router = APIRouter(prefix="/invites", tags=["invites"])
@@ -32,7 +30,7 @@ async def get_invite_info(
)
# Check if invite is already used or expired
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
if invite.used_at is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -96,7 +96,7 @@ async def accept_invite(
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):
if invite.expires_at < datetime.now(UTC):
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Invite expired")
# Idempotent — already a member
@@ -107,7 +107,7 @@ async def accept_invite(
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_at = datetime.now(UTC)
invite.used_by = current_member.id
await session.flush()
@@ -123,8 +123,9 @@ async def accept_invite(
@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 sqlalchemy.orm import selectinload
from rehearsalhub.db.models import BandInvite
stmt = select(BandInvite).options(selectinload(BandInvite.band)).where(BandInvite.token == token)
result = await session.execute(stmt)

View File

@@ -1,26 +1,28 @@
import json
import logging
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.config import get_settings
from rehearsalhub.db.engine import get_session, get_session_factory
from rehearsalhub.queue.redis_queue import flush_pending_pushes
from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member
from rehearsalhub.routers.versions import _member_from_request
from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.repositories.band_storage import BandStorageRepository
from rehearsalhub.repositories.comment import CommentRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.routers.versions import _member_from_request
from rehearsalhub.schemas.comment import SongCommentCreate, SongCommentRead
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate
from rehearsalhub.services.band import BandService
from rehearsalhub.services.nc_scan import scan_band_folder
from rehearsalhub.services.song import SongService
from rehearsalhub.storage.nextcloud import NextcloudClient
from rehearsalhub.storage.factory import StorageFactory
log = logging.getLogger(__name__)
@@ -48,8 +50,7 @@ async def list_songs(
await band_svc.assert_membership(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
storage = NextcloudClient.for_member(current_member)
song_svc = SongService(session, storage=storage)
song_svc = SongService(session)
return await song_svc.list_songs(band_id)
@@ -150,9 +151,8 @@ async def create_song(
if band is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
storage = NextcloudClient.for_member(current_member)
song_svc = SongService(session, storage=storage)
song = await song_svc.create_song(band_id, data, current_member.id, band.slug, creator=current_member)
song_svc = SongService(session)
song = await song_svc.create_song(band_id, data, current_member.id, band.slug)
read = SongRead.model_validate(song)
read.version_count = 0
return read
@@ -187,22 +187,28 @@ async def scan_nextcloud_stream(
Accepts ?token= for EventSource clients that can't set headers.
"""
band = await _get_band_and_assert_member(band_id, current_member, session)
band_folder = band.nc_folder_path or f"bands/{band.slug}/"
nc = NextcloudClient.for_member(current_member)
bs = await BandStorageRepository(session).get_active_for_band(band_id)
band_folder = (bs.root_path if bs and bs.root_path else None) or f"bands/{band.slug}/"
member_id = current_member.id
settings = get_settings()
async def event_generator():
async with get_session_factory()() as db:
try:
async for event in scan_band_folder(db, nc, band_id, band_folder, member_id):
storage = await StorageFactory.create(db, band_id, settings)
async for event in scan_band_folder(db, storage, band_id, band_folder, member_id):
yield json.dumps(event) + "\n"
if event.get("type") in ("song", "session"):
await db.commit()
await flush_pending_pushes(db)
except LookupError as exc:
yield json.dumps({"type": "error", "message": str(exc)}) + "\n"
except Exception:
log.exception("SSE scan error for band %s", band_id)
yield json.dumps({"type": "error", "message": "Scan failed due to an internal error."}) + "\n"
finally:
await db.commit()
await flush_pending_pushes(db)
return StreamingResponse(
event_generator(),
@@ -221,13 +227,18 @@ async def scan_nextcloud(
Prefer the SSE /nc-scan/stream endpoint for large folders.
"""
band = await _get_band_and_assert_member(band_id, current_member, session)
band_folder = band.nc_folder_path or f"bands/{band.slug}/"
nc = NextcloudClient.for_member(current_member)
bs = await BandStorageRepository(session).get_active_for_band(band_id)
band_folder = (bs.root_path if bs and bs.root_path else None) or f"bands/{band.slug}/"
try:
storage = await StorageFactory.create(session, band_id, get_settings())
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
songs: list[SongRead] = []
stats = {"found": 0, "imported": 0, "skipped": 0}
async for event in scan_band_folder(session, nc, band_id, band_folder, current_member.id):
async for event in scan_band_folder(session, storage, band_id, band_folder, current_member.id):
if event["type"] == "song":
songs.append(SongRead(**event["song"]))
elif event["type"] == "done":

View File

@@ -0,0 +1,336 @@
"""Storage provider management endpoints.
Bands connect to a storage provider (Nextcloud, Google Drive, OneDrive, Dropbox)
through this router. Credentials are encrypted before being written to the DB.
OAuth2 flow:
1. Admin calls GET /bands/{id}/storage/connect/{provider}/authorize
→ receives a redirect URL to the provider's consent page
2. After consent, provider redirects to GET /oauth/callback/{provider}?code=...&state=...
→ tokens are exchanged, encrypted, stored, and the admin is redirected to the frontend
Nextcloud (app-password) flow:
POST /bands/{id}/storage/connect/nextcloud
→ credentials validated and stored immediately (no OAuth redirect needed)
"""
from __future__ import annotations
import logging
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import RedirectResponse
from jose import JWTError, jwt
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.config import Settings, get_settings
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member
from rehearsalhub.repositories.band_storage import BandStorageRepository
from rehearsalhub.schemas.storage import BandStorageRead, NextcloudConnect, OAuthAuthorizeResponse
from rehearsalhub.security.encryption import encrypt_credentials
from rehearsalhub.services.band import BandService
log = logging.getLogger(__name__)
router = APIRouter(tags=["storage"])
# OAuth2 state JWT expires after 15 minutes (consent must happen in this window)
_STATE_TTL_MINUTES = 15
# ── OAuth2 provider definitions ────────────────────────────────────────────────
_OAUTH_CONFIGS: dict[str, dict] = {
"googledrive": {
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"scopes": "https://www.googleapis.com/auth/drive openid",
"extra_auth_params": {"access_type": "offline", "prompt": "consent"},
},
"onedrive": {
# tenant_id is injected at runtime from settings
"auth_url": "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize",
"token_url": "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
"scopes": "https://graph.microsoft.com/Files.ReadWrite offline_access",
"extra_auth_params": {},
},
"dropbox": {
"auth_url": "https://www.dropbox.com/oauth2/authorize",
"token_url": "https://api.dropboxapi.com/oauth2/token",
"scopes": "", # Dropbox uses app-level scopes set in the developer console
"extra_auth_params": {"token_access_type": "offline"},
},
}
def _get_client_id_and_secret(provider: str, settings: Settings) -> tuple[str, str]:
match provider:
case "googledrive":
return settings.google_client_id, settings.google_client_secret
case "onedrive":
return settings.onedrive_client_id, settings.onedrive_client_secret
case "dropbox":
return settings.dropbox_app_key, settings.dropbox_app_secret
case _:
raise ValueError(f"Unknown OAuth provider: {provider!r}")
def _redirect_uri(provider: str, settings: Settings) -> str:
scheme = "http" if settings.debug else "https"
return f"{scheme}://{settings.domain}/api/v1/oauth/callback/{provider}"
# ── State JWT helpers ──────────────────────────────────────────────────────────
def _encode_state(band_id: uuid.UUID, provider: str, settings: Settings) -> str:
payload = {
"band_id": str(band_id),
"provider": provider,
"nonce": secrets.token_hex(16),
"exp": datetime.now(timezone.utc) + timedelta(minutes=_STATE_TTL_MINUTES),
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
def _decode_state(state: str, settings: Settings) -> dict:
try:
return jwt.decode(state, settings.secret_key, algorithms=[settings.jwt_algorithm])
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid OAuth state: {exc}")
# ── Nextcloud (app-password) ───────────────────────────────────────────────────
@router.post(
"/bands/{band_id}/storage/connect/nextcloud",
response_model=BandStorageRead,
status_code=status.HTTP_201_CREATED,
)
async def connect_nextcloud(
band_id: uuid.UUID,
body: NextcloudConnect,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
settings: Settings = Depends(get_settings),
):
"""Connect a band to a Nextcloud instance using an app password."""
band_svc = BandService(session)
try:
await band_svc.assert_admin(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
# Smoke-test the credentials before storing them
from rehearsalhub.storage.nextcloud import NextcloudClient
nc = NextcloudClient(base_url=body.url, username=body.username, password=body.app_password)
try:
await nc.list_folder(body.root_path or "/")
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Could not connect to Nextcloud: {exc}",
)
creds = {
"url": body.url,
"username": body.username,
"app_password": body.app_password,
}
encrypted = encrypt_credentials(settings.storage_encryption_key, creds)
repo = BandStorageRepository(session)
# Deactivate any previous storage before creating the new one
await repo.deactivate_all(band_id)
band_storage = await repo.create(
band_id=band_id,
provider="nextcloud",
label=body.label,
is_active=True,
root_path=body.root_path,
credentials=encrypted,
)
await session.commit()
log.info("Band %s connected to Nextcloud (%s)", band_id, body.url)
return BandStorageRead.model_validate(band_storage)
# ── OAuth2 — authorize ─────────────────────────────────────────────────────────
@router.get(
"/bands/{band_id}/storage/connect/{provider}/authorize",
response_model=OAuthAuthorizeResponse,
)
async def oauth_authorize(
band_id: uuid.UUID,
provider: str,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
settings: Settings = Depends(get_settings),
):
"""Return the provider's OAuth2 authorization URL.
The frontend should redirect the user to ``redirect_url``.
After the user consents, the provider redirects to our callback endpoint.
"""
if provider not in _OAUTH_CONFIGS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown provider {provider!r}. Supported: {list(_OAUTH_CONFIGS)}",
)
band_svc = BandService(session)
try:
await band_svc.assert_admin(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
client_id, _ = _get_client_id_and_secret(provider, settings)
if not client_id:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail=f"OAuth2 for {provider!r} is not configured on this server",
)
cfg = _OAUTH_CONFIGS[provider]
auth_url = cfg["auth_url"].format(tenant_id=settings.onedrive_tenant_id)
state = _encode_state(band_id, provider, settings)
redirect_uri = _redirect_uri(provider, settings)
params: dict = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"state": state,
**cfg["extra_auth_params"],
}
if cfg["scopes"]:
params["scope"] = cfg["scopes"]
return OAuthAuthorizeResponse(
redirect_url=f"{auth_url}?{urlencode(params)}",
provider=provider,
)
# ── OAuth2 — callback ──────────────────────────────────────────────────────────
@router.get("/oauth/callback/{provider}")
async def oauth_callback(
provider: str,
code: str = Query(...),
state: str = Query(...),
session: AsyncSession = Depends(get_session),
settings: Settings = Depends(get_settings),
):
"""Exchange authorization code for tokens, encrypt, and store."""
if provider not in _OAUTH_CONFIGS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
state_data = _decode_state(state, settings)
band_id = uuid.UUID(state_data["band_id"])
client_id, client_secret = _get_client_id_and_secret(provider, settings)
cfg = _OAUTH_CONFIGS[provider]
token_url = cfg["token_url"].format(tenant_id=settings.onedrive_tenant_id)
redirect_uri = _redirect_uri(provider, settings)
payload = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"client_secret": client_secret,
}
try:
async with httpx.AsyncClient(timeout=15.0) as http:
resp = await http.post(token_url, data=payload)
resp.raise_for_status()
token_data = resp.json()
except Exception as exc:
log.error("OAuth token exchange failed for %s: %s", provider, exc)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Token exchange failed")
from datetime import timedelta
expires_in = int(token_data.get("expires_in", 3600))
expiry = datetime.now(timezone.utc) + timedelta(seconds=expires_in - 60)
creds = {
"access_token": token_data["access_token"],
"refresh_token": token_data.get("refresh_token", ""),
"token_expiry": expiry.isoformat(),
"token_type": token_data.get("token_type", "Bearer"),
}
encrypted = encrypt_credentials(settings.storage_encryption_key, creds)
repo = BandStorageRepository(session)
await repo.deactivate_all(band_id)
await repo.create(
band_id=band_id,
provider=provider,
label=None,
is_active=True,
root_path=None,
credentials=encrypted,
)
await session.commit()
log.info("Band %s connected to %s via OAuth2", band_id, provider)
# Redirect back to the frontend settings page
scheme = "http" if settings.debug else "https"
return RedirectResponse(
url=f"{scheme}://{settings.domain}/bands/{band_id}/settings?storage=connected",
status_code=status.HTTP_302_FOUND,
)
# ── Read / disconnect ──────────────────────────────────────────────────────────
@router.get("/bands/{band_id}/storage", response_model=list[BandStorageRead])
async def list_storage(
band_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
"""List all storage configs for a band (credentials never returned)."""
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")
repo = BandStorageRepository(session)
configs = await repo.list_for_band(band_id)
return [BandStorageRead.model_validate(c) for c in configs]
@router.delete("/bands/{band_id}/storage", status_code=status.HTTP_204_NO_CONTENT)
async def disconnect_storage(
band_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
"""Deactivate the band's active storage (does not delete historical records)."""
band_svc = BandService(session)
try:
await band_svc.assert_admin(band_id, current_member.id)
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
repo = BandStorageRepository(session)
await repo.deactivate_all(band_id)
await session.commit()
log.info("Band %s storage disconnected by member %s", band_id, current_member.id)

View File

@@ -1,5 +1,5 @@
import uuid
import asyncio
import uuid
from pathlib import Path
from typing import Any
@@ -17,9 +17,11 @@ from rehearsalhub.repositories.member import MemberRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate, AudioVersionRead
from rehearsalhub.services.auth import decode_token
from rehearsalhub.config import get_settings
from rehearsalhub.services.band import BandService
from rehearsalhub.services.song import SongService
from rehearsalhub.storage.nextcloud import NextcloudClient
from rehearsalhub.storage.factory import StorageFactory
from rehearsalhub.storage.protocol import StorageClient
router = APIRouter(tags=["versions"])
@@ -35,7 +37,7 @@ _AUDIO_CONTENT_TYPES: dict[str, str] = {
}
async def _download_with_retry(storage: NextcloudClient, file_path: str, max_retries: int = 3) -> bytes:
async def _download_with_retry(storage: StorageClient, file_path: str, max_retries: int = 3) -> bytes:
"""Download file from Nextcloud with retry logic for transient errors."""
last_error = None
@@ -171,8 +173,7 @@ async def create_version(
except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
storage = NextcloudClient.for_member(current_member)
song_svc = SongService(session, storage=storage)
song_svc = SongService(session)
version = await song_svc.register_version(song_id, data, current_member.id)
return AudioVersionRead.model_validate(version)
@@ -180,49 +181,27 @@ async def create_version(
@router.get("/versions/{version_id}/waveform")
async def get_waveform(
version_id: uuid.UUID,
resolution: str = Query("full", pattern="^(full|mini)$"),
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
) -> Any:
"""Return pre-computed waveform peaks from the database.
- `resolution=full` (default): 500-point peaks for the WaveSurfer player
- `resolution=mini`: 100-point peaks for the library overview thumbnail
"""
version, _ = await _get_version_and_assert_band_membership(version_id, session, current_member)
if not version.waveform_url:
if resolution == "mini":
peaks = version.waveform_peaks_mini
if peaks is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mini waveform not ready")
else:
peaks = version.waveform_peaks
if peaks is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waveform not ready")
# Use the uploader's NC credentials — invited members may not have NC configured
uploader: Member | None = None
if version.uploaded_by:
uploader = await MemberRepository(session).get_by_id(version.uploaded_by)
storage = NextcloudClient.for_member(uploader) if uploader else NextcloudClient.for_member(current_member)
if storage is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No storage provider configured for this account"
)
try:
data = await _download_with_retry(storage, version.waveform_url)
except httpx.ConnectError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Storage service unavailable."
)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Waveform file not found in storage."
)
else:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Storage returned an error."
)
except Exception:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to fetch waveform."
)
import json
return json.loads(data)
return {"version": 2, "channels": 1, "length": len(peaks), "data": peaks}
@router.get("/versions/{version_id}/stream")
@@ -241,15 +220,12 @@ async def stream_version(
else:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No audio file")
# Use the uploader's NC credentials — invited members may not have NC configured
uploader: Member | None = None
if version.uploaded_by:
uploader = await MemberRepository(session).get_by_id(version.uploaded_by)
storage = NextcloudClient.for_member(uploader) if uploader else NextcloudClient.for_member(current_member)
if storage is None:
try:
storage = await StorageFactory.create(session, song.band_id, get_settings())
except LookupError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No storage provider configured for this account"
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Band has no active storage configured",
)
try:
data = await _download_with_retry(storage, file_path)

View File

@@ -4,8 +4,8 @@ import uuid
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from rehearsalhub.repositories.member import MemberRepository
from rehearsalhub.db.engine import get_session
from rehearsalhub.repositories.member import MemberRepository
from rehearsalhub.services.auth import decode_token
from rehearsalhub.ws import manager

View File

@@ -8,7 +8,7 @@ from rehearsalhub.schemas.annotation import (
)
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.band import BandCreate, BandMemberRead, BandRead, BandReadWithMembers
from rehearsalhub.schemas.member import MemberRead
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate

View File

@@ -22,6 +22,8 @@ class AudioVersionRead(BaseModel):
nc_file_etag: str | None = None
cdn_hls_base: str | None = None
waveform_url: str | None = None
waveform_peaks: list[float] | None = None
waveform_peaks_mini: list[float] | None = None
duration_ms: int | None = None
format: str | None = None
file_size_bytes: int | None = None

View File

@@ -18,11 +18,11 @@ 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 BandUpdate(BaseModel):
nc_folder_path: str | None = None # update the Nextcloud base folder for scans
name: str | None = None
genre_tags: list[str] | None = None
class BandRead(BaseModel):
@@ -31,7 +31,6 @@ class BandRead(BaseModel):
name: str
slug: str
genre_tags: list[str]
nc_folder_path: str | None = None
created_at: datetime
updated_at: datetime

View File

@@ -26,15 +26,15 @@ class SongCommentRead(BaseModel):
created_at: datetime
@classmethod
def from_model(cls, c: object) -> "SongCommentRead":
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"),
author_avatar_url=getattr(getattr(c, "author"), "avatar_url"),
timestamp=getattr(c, "timestamp"),
id=c.id,
song_id=c.song_id,
body=c.body,
author_id=c.author_id,
author_name=c.author.display_name,
author_avatar_url=c.author.avatar_url,
timestamp=c.timestamp,
tag=getattr(c, "tag", None),
created_at=getattr(c, "created_at"),
created_at=c.created_at,
)

View File

@@ -1,8 +1,7 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, EmailStr, model_validator
from pydantic import BaseModel, ConfigDict, EmailStr
class MemberBase(BaseModel):
@@ -14,23 +13,9 @@ 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
avatar_url: str | None = None # URL to user's avatar image
avatar_url: str | None = None

View File

@@ -0,0 +1,56 @@
"""Pydantic schemas for storage provider configuration endpoints."""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, field_validator
# ── Request bodies ─────────────────────────────────────────────────────────────
class NextcloudConnect(BaseModel):
"""Connect a band to a Nextcloud instance via an app password.
Use an *app password* (generated in Nextcloud → Settings → Security),
not the account password. App passwords can be revoked without changing
the main account credentials.
"""
url: str
username: str
app_password: str
label: str | None = None
root_path: str | None = None
@field_validator("url")
@classmethod
def strip_trailing_slash(cls, v: str) -> str:
return v.rstrip("/")
# ── Response bodies ────────────────────────────────────────────────────────────
class BandStorageRead(BaseModel):
"""Public representation of a storage config — credentials are never exposed."""
id: uuid.UUID
band_id: uuid.UUID
provider: str
label: str | None
is_active: bool
root_path: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class OAuthAuthorizeResponse(BaseModel):
"""Returned by the authorize endpoint — frontend should redirect the user here."""
redirect_url: str
provider: str

View File

@@ -0,0 +1,38 @@
"""Fernet-based symmetric encryption for storage credentials.
The encryption key must be a 32-byte URL-safe base64-encoded string,
generated once via: Fernet.generate_key().decode()
and stored in the STORAGE_ENCRYPTION_KEY environment variable.
No credentials are ever stored in plaintext — only the encrypted blob
is written to the database.
"""
from __future__ import annotations
import json
from cryptography.fernet import Fernet, InvalidToken
def encrypt_credentials(key: str, data: dict) -> str:
"""Serialize *data* to JSON and encrypt it with Fernet.
Returns a URL-safe base64-encoded ciphertext string safe to store in TEXT columns.
"""
f = Fernet(key.encode())
plaintext = json.dumps(data, separators=(",", ":")).encode()
return f.encrypt(plaintext).decode()
def decrypt_credentials(key: str, blob: str) -> dict:
"""Decrypt and deserialize a blob previously created by :func:`encrypt_credentials`.
Raises ``cryptography.fernet.InvalidToken`` if the key is wrong or the blob is tampered.
"""
f = Fernet(key.encode())
try:
plaintext = f.decrypt(blob.encode())
except InvalidToken:
raise InvalidToken("Failed to decrypt storage credentials — wrong key or corrupted blob")
return json.loads(plaintext)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
import bcrypt
from jose import JWTError, jwt
@@ -25,12 +25,12 @@ def verify_password(plain: str, hashed: str) -> bool:
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)
expire = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {
"sub": member_id,
"email": email,
"exp": expire,
"iat": datetime.now(timezone.utc),
"iat": datetime.now(UTC),
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)

View File

@@ -1,7 +1,7 @@
"""Avatar generation service using DiceBear API."""
from typing import Optional
import httpx
from rehearsalhub.db.models import Member
@@ -38,7 +38,7 @@ class AvatarService:
"""
return await self.generate_avatar_url(str(member.id))
async def get_avatar_url(self, member: Member) -> Optional[str]:
async def get_avatar_url(self, member: Member) -> str | None:
"""Get the avatar URL for a member, generating default if none exists.
Args:

View File

@@ -7,54 +7,46 @@ 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
from rehearsalhub.schemas.band import BandCreate
log = logging.getLogger(__name__)
class BandService:
def __init__(self, session: AsyncSession, storage: NextcloudClient | None = None) -> None:
def __init__(self, session: AsyncSession) -> None:
self._repo = BandRepository(session)
self._storage = storage
self._session = session
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
if data.nc_base_path:
# User explicitly specified a folder — verify it actually exists in NC.
log.info("Checking NC folder existence: %s", nc_folder)
try:
await storage.get_file_metadata(nc_folder.rstrip("/"))
except Exception as exc:
log.warning("NC folder '%s' not accessible: %s", nc_folder, exc)
raise LookupError(f"Nextcloud folder '{nc_folder}' not found or not accessible")
else:
# Auto-generated path — create it (idempotent MKCOL).
log.info("Creating NC folder: %s", nc_folder)
try:
await storage.create_folder(nc_folder)
except Exception as exc:
# Not fatal — NC may be temporarily unreachable during dev/test.
log.warning("Could not create NC folder '%s': %s", nc_folder, exc)
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")
log.info("Created band '%s' (slug=%s, nc_folder=%s)", data.name, data.slug, nc_folder)
log.info("Created band '%s' (slug=%s)", data.name, data.slug)
# Storage is configured separately via POST /bands/{id}/storage/connect/*.
# If the band already has active storage, create the root folder now.
try:
from rehearsalhub.storage.factory import StorageFactory
from rehearsalhub.config import get_settings
storage = await StorageFactory.create(self._session, band.id, get_settings())
root = f"bands/{data.slug}/"
await storage.create_folder(root.strip("/") + "/")
log.info("Created storage folder '%s' for band '%s'", root, data.slug)
except LookupError:
log.info("Band '%s' has no active storage yet — skipping folder creation", data.slug)
except Exception as exc:
log.warning("Could not create storage folder for band '%s': %s", data.slug, exc)
return band
async def get_band_with_members(self, band_id: uuid.UUID) -> Band | None:

View File

@@ -1,15 +1,19 @@
"""Core nc-scan logic shared by the blocking and streaming endpoints."""
"""Storage scan logic: walk a band's storage folder and import audio files.
Works against any ``StorageClient`` implementation — Nextcloud, Google Drive, etc.
``StorageClient.list_folder`` must return ``FileMetadata`` objects whose ``path``
field is a *provider-relative* path (i.e. the DAV prefix has already been stripped
by the client implementation).
"""
from __future__ import annotations
import logging
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import AsyncGenerator
from urllib.parse import unquote
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.models import Member
from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository
from rehearsalhub.repositories.song import SongRepository
@@ -17,7 +21,7 @@ from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.schemas.song import SongRead
from rehearsalhub.services.session import extract_session_folder, parse_rehearsal_date
from rehearsalhub.services.song import SongService
from rehearsalhub.storage.nextcloud import NextcloudClient
from rehearsalhub.storage.protocol import StorageClient
log = logging.getLogger(__name__)
@@ -28,72 +32,53 @@ AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus"}
MAX_SCAN_DEPTH = 3
def _make_relative(dav_prefix: str):
"""Return a function that strips the WebDAV prefix and URL-decodes a href."""
def relative(href: str) -> str:
decoded = unquote(href)
if decoded.startswith(dav_prefix):
return decoded[len(dav_prefix):]
# Strip any leading slash for robustness
return decoded.lstrip("/")
return relative
async def collect_audio_files(
nc: NextcloudClient,
relative: object, # Callable[[str], str]
storage: StorageClient,
folder_path: str,
max_depth: int = MAX_SCAN_DEPTH,
_depth: int = 0,
) -> AsyncGenerator[str, None]:
"""
Recursively yield user-relative audio file paths under folder_path.
"""Recursively yield provider-relative audio file paths under *folder_path*.
Handles any depth:
bands/slug/take.wav depth 0
bands/slug/231015/take.wav depth 1
bands/slug/231015/groove/take.wav depth 2 ← was broken before
``storage.list_folder`` is expected to return ``FileMetadata`` with paths
already normalised to provider-relative form (no host, no DAV prefix).
"""
if _depth > max_depth:
log.debug("Max depth %d exceeded at '%s', stopping recursion", max_depth, folder_path)
return
try:
items = await nc.list_folder(folder_path)
items = await storage.list_folder(folder_path)
except Exception as exc:
log.warning("Could not list folder '%s': %s", folder_path, exc)
return
log.info(
"scan depth=%d folder='%s' entries=%d",
_depth, folder_path, len(items),
)
log.info("scan depth=%d folder='%s' entries=%d", _depth, folder_path, len(items))
for item in items:
rel = relative(item.path) # type: ignore[operator]
if rel.endswith("/"):
# It's a subdirectory — recurse
log.info(" → subdir: %s", rel)
async for subpath in collect_audio_files(nc, relative, rel, max_depth, _depth + 1):
path = item.path.lstrip("/")
if path.endswith("/"):
log.info(" → subdir: %s", path)
async for subpath in collect_audio_files(storage, path, max_depth, _depth + 1):
yield subpath
else:
ext = Path(rel).suffix.lower()
ext = Path(path).suffix.lower()
if ext in AUDIO_EXTENSIONS:
log.info(" → audio file: %s", rel)
yield rel
log.info(" → audio file: %s", path)
yield path
elif ext:
log.debug(" → skip (ext=%s): %s", ext, rel)
log.debug(" → skip (ext=%s): %s", ext, path)
async def scan_band_folder(
db_session: AsyncSession,
nc: NextcloudClient,
storage: StorageClient,
band_id,
band_folder: str,
member_id,
) -> AsyncGenerator[dict, None]:
"""
Async generator that scans band_folder and yields event dicts:
"""Async generator that scans *band_folder* and yields event dicts:
{"type": "progress", "message": str}
{"type": "song", "song": SongRead-dict, "is_new": bool}
{"type": "session", "session": {id, date, label}}
@@ -101,12 +86,9 @@ async def scan_band_folder(
{"type": "done", "stats": {found, imported, skipped}}
{"type": "error", "message": str}
"""
dav_prefix = f"/remote.php/dav/files/{nc._auth[0]}/"
relative = _make_relative(dav_prefix)
version_repo = AudioVersionRepository(db_session)
session_repo = RehearsalSessionRepository(db_session)
song_repo = SongRepository(db_session)
version_repo = AudioVersionRepository(db_session)
song_svc = SongService(db_session)
found = 0
@@ -115,37 +97,36 @@ async def scan_band_folder(
yield {"type": "progress", "message": f"Scanning {band_folder}"}
async for nc_file_path in collect_audio_files(nc, relative, band_folder):
async for nc_file_path in collect_audio_files(storage, band_folder):
found += 1
song_folder = str(Path(nc_file_path).parent).rstrip("/") + "/"
song_title = Path(nc_file_path).stem
# If the file sits directly inside a dated session folder (YYMMDD/file.wav),
# give it a unique virtual folder so each file becomes its own song rather
# than being merged as a new version of the first file in that folder.
# give it a unique virtual folder so each file becomes its own song.
session_folder_path = extract_session_folder(nc_file_path)
if session_folder_path and session_folder_path.rstrip("/") == song_folder.rstrip("/"):
song_folder = song_folder + song_title + "/"
yield {"type": "progress", "message": f"Checking {Path(nc_file_path).name}"}
# Fetch file metadata (etag + size) — one PROPFIND per file
existing = await version_repo.get_by_nc_file_path(nc_file_path)
if existing is not None:
log.debug("scan: skipping already-registered '%s' (version %s)", nc_file_path, existing.id)
skipped += 1
yield {"type": "skipped", "path": nc_file_path, "reason": "already imported"}
continue
try:
meta = await nc.get_file_metadata(nc_file_path)
meta = await storage.get_file_metadata(nc_file_path)
etag = meta.etag
except Exception as exc:
log.warning("Metadata error for '%s': %s", nc_file_path, exc)
log.error("Metadata fetch failed for '%s': %s", nc_file_path, exc, exc_info=True)
skipped += 1
yield {"type": "skipped", "path": nc_file_path, "reason": f"metadata error: {exc}"}
continue
# Skip if this exact version is already indexed
if etag and await version_repo.get_by_etag(etag):
log.info("Already registered (etag match): %s", nc_file_path)
skipped += 1
yield {"type": "skipped", "path": nc_file_path, "reason": "already registered"}
continue
# Resolve or create a RehearsalSession from a YYMMDD folder segment
try:
rehearsal_date = parse_rehearsal_date(nc_file_path)
rehearsal_session_id = None
if rehearsal_date:
@@ -162,7 +143,6 @@ async def scan_band_folder(
},
}
# Find or create the Song record
song = await song_repo.get_by_nc_folder_path(song_folder)
if song is None:
song = await song_repo.get_by_title_and_band(band_id, song_title)
@@ -181,8 +161,7 @@ async def scan_band_folder(
elif rehearsal_session_id and song.session_id is None:
song = await song_repo.update(song, session_id=rehearsal_session_id)
# Register the audio version
await song_svc.register_version(
version = await song_svc.register_version(
song.id,
AudioVersionCreate(
nc_file_path=nc_file_path,
@@ -192,11 +171,19 @@ async def scan_band_folder(
),
member_id,
)
log.info("Imported '%s' as version %s for song '%s'", nc_file_path, version.id, song.title)
imported += 1
read = SongRead.model_validate(song).model_copy(update={"version_count": 1, "session_id": rehearsal_session_id})
read = SongRead.model_validate(song).model_copy(
update={"version_count": 1, "session_id": rehearsal_session_id}
)
yield {"type": "song", "song": read.model_dump(mode="json"), "is_new": is_new}
except Exception as exc:
log.error("Failed to import '%s': %s", nc_file_path, exc, exc_info=True)
skipped += 1
yield {"type": "skipped", "path": nc_file_path, "reason": f"import error: {exc}"}
yield {
"type": "done",
"stats": {"found": found, "imported": imported, "skipped": skipped},

View File

@@ -1,16 +1,18 @@
from __future__ import annotations
import logging
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
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
from rehearsalhub.schemas.song import SongCreate, SongRead
class SongService:
@@ -18,25 +20,31 @@ class SongService:
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
async def create_song(
self, band_id: uuid.UUID, data: SongCreate, creator_id: uuid.UUID, band_slug: str,
creator: object | None = None,
self,
band_id: uuid.UUID,
data: SongCreate,
creator_id: uuid.UUID,
band_slug: str,
) -> 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:
from rehearsalhub.config import get_settings
from rehearsalhub.storage.factory import StorageFactory
storage = await StorageFactory.create(self._session, band_id, get_settings())
await storage.create_folder(nc_folder)
except LookupError:
log.info("Band %s has no active storage — skipping folder creation for '%s'", band_id, nc_folder)
nc_folder = None # type: ignore[assignment]
except Exception:
nc_folder = None # best-effort
nc_folder = None # best-effort; storage may be temporarily unreachable
song = await self._repo.create(
band_id=band_id,
@@ -67,11 +75,6 @@ class SongService:
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,
@@ -85,8 +88,15 @@ class SongService:
uploaded_by=uploader_id,
)
try:
await self._queue.enqueue(
"transcode",
{"version_id": str(version.id), "nc_file_path": data.nc_file_path},
)
except Exception as exc:
log.error(
"Failed to enqueue transcode job for version %s ('%s'): %s",
version.id, data.nc_file_path, exc, exc_info=True,
)
return version

View File

@@ -0,0 +1,175 @@
"""StorageFactory — creates the correct StorageClient from a BandStorage record.
Usage:
storage = await StorageFactory.create(session, band_id, settings)
await storage.list_folder("bands/my-band/")
Token refresh for OAuth2 providers is handled transparently: if the stored
access token is expired the factory refreshes it and persists the new tokens
before returning the client.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.config import Settings, get_settings
from rehearsalhub.db.models import BandStorage
from rehearsalhub.repositories.band_storage import BandStorageRepository
from rehearsalhub.security.encryption import decrypt_credentials, encrypt_credentials
from rehearsalhub.storage.nextcloud import NextcloudClient
from rehearsalhub.storage.protocol import StorageClient
log = logging.getLogger(__name__)
class StorageFactory:
@staticmethod
async def create(
session: AsyncSession,
band_id: uuid.UUID,
settings: Settings | None = None,
) -> StorageClient:
"""Return a ready-to-use ``StorageClient`` for *band_id*.
Raises ``LookupError`` if the band has no active storage configured.
"""
if settings is None:
settings = get_settings()
repo = BandStorageRepository(session)
band_storage = await repo.get_active_for_band(band_id)
if band_storage is None:
raise LookupError(f"Band {band_id} has no active storage configured")
return await StorageFactory._build(session, band_storage, settings)
@staticmethod
async def _build(
session: AsyncSession,
band_storage: BandStorage,
settings: Settings,
) -> StorageClient:
creds = decrypt_credentials(settings.storage_encryption_key, band_storage.credentials)
creds = await _maybe_refresh_token(session, band_storage, creds, settings)
match band_storage.provider:
case "nextcloud":
return NextcloudClient(
base_url=creds["url"],
username=creds["username"],
password=creds["app_password"],
)
case "googledrive":
raise NotImplementedError("Google Drive storage client not yet implemented")
case "onedrive":
raise NotImplementedError("OneDrive storage client not yet implemented")
case "dropbox":
raise NotImplementedError("Dropbox storage client not yet implemented")
case _:
raise ValueError(f"Unknown storage provider: {band_storage.provider!r}")
# ── OAuth2 token refresh ───────────────────────────────────────────────────────
_TOKEN_ENDPOINTS: dict[str, str] = {
"googledrive": "https://oauth2.googleapis.com/token",
"dropbox": "https://api.dropbox.com/oauth2/token",
# OneDrive token endpoint is tenant-specific; handled separately.
}
async def _maybe_refresh_token(
session: AsyncSession,
band_storage: BandStorage,
creds: dict,
settings: Settings,
) -> dict:
"""If the OAuth2 access token is expired, refresh it and persist the update."""
if band_storage.provider == "nextcloud":
return creds # Nextcloud uses app passwords — no expiry
expiry_str = creds.get("token_expiry")
if not expiry_str:
return creds # No expiry recorded — assume still valid
expiry = datetime.fromisoformat(expiry_str)
if expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) < expiry:
return creds # Still valid
log.info(
"Access token for band_storage %s (%s) expired — refreshing",
band_storage.id,
band_storage.provider,
)
try:
creds = await _do_refresh(band_storage, creds, settings)
# Persist refreshed tokens
from rehearsalhub.config import get_settings as _gs
_settings = settings or _gs()
band_storage.credentials = encrypt_credentials(_settings.storage_encryption_key, creds)
await session.flush()
except Exception:
log.exception("Token refresh failed for band_storage %s", band_storage.id)
raise
return creds
async def _do_refresh(band_storage: BandStorage, creds: dict, settings: Settings) -> dict:
"""Call the provider's token endpoint and return updated credentials."""
from datetime import timedelta
provider = band_storage.provider
if provider == "onedrive":
tenant = settings.onedrive_tenant_id
token_url = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
client_id = settings.onedrive_client_id
client_secret = settings.onedrive_client_secret
extra: dict = {"scope": "https://graph.microsoft.com/Files.ReadWrite offline_access"}
elif provider == "googledrive":
token_url = _TOKEN_ENDPOINTS["googledrive"]
client_id = settings.google_client_id
client_secret = settings.google_client_secret
extra = {}
elif provider == "dropbox":
token_url = _TOKEN_ENDPOINTS["dropbox"]
client_id = settings.dropbox_app_key
client_secret = settings.dropbox_app_secret
extra = {}
else:
raise ValueError(f"Token refresh not supported for provider: {provider!r}")
payload = {
"grant_type": "refresh_token",
"refresh_token": creds["refresh_token"],
"client_id": client_id,
"client_secret": client_secret,
**extra,
}
async with httpx.AsyncClient(timeout=15.0) as http:
resp = await http.post(token_url, data=payload)
resp.raise_for_status()
data = resp.json()
expires_in = int(data.get("expires_in", 3600))
expiry = datetime.now(timezone.utc) + timedelta(seconds=expires_in - 60) # 60s buffer
return {
**creds,
"access_token": data["access_token"],
"refresh_token": data.get("refresh_token", creds["refresh_token"]),
"token_expiry": expiry.isoformat(),
"token_type": data.get("token_type", "Bearer"),
}

View File

@@ -5,10 +5,10 @@ from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from typing import Any
from urllib.parse import unquote
import httpx
from rehearsalhub.config import get_settings
from rehearsalhub.storage.protocol import FileMetadata
logger = logging.getLogger(__name__)
@@ -26,19 +26,11 @@ class NextcloudClient:
if not base_url or not username:
raise ValueError("Nextcloud credentials must be provided explicitly")
self._base = base_url.rstrip("/")
self._username = username
self._auth = (username, password)
self._dav_root = f"{self._base}/remote.php/dav/files/{self._auth[0]}"
@classmethod
def for_member(cls, member: object) -> "NextcloudClient | None":
"""Return a client using member's personal NC credentials if configured.
Returns None if member has no Nextcloud configuration."""
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 None
self._dav_root = f"{self._base}/remote.php/dav/files/{username}"
# Prefix stripped from WebDAV hrefs to produce relative paths
self._dav_prefix = f"/remote.php/dav/files/{username}/"
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(auth=self._auth, timeout=30.0)
@@ -84,7 +76,17 @@ class NextcloudClient:
content=body,
)
resp.raise_for_status()
return _parse_propfind_multi(resp.text)
items = _parse_propfind_multi(resp.text)
# Normalise WebDAV absolute hrefs to provider-relative paths so callers
# never need to know about DAV internals. URL-decode to handle
# filenames that contain spaces or non-ASCII characters.
for item in items:
decoded = unquote(item.path)
if decoded.startswith(self._dav_prefix):
item.path = decoded[len(self._dav_prefix):]
else:
item.path = decoded.lstrip("/")
return items
async def download(self, path: str) -> bytes:
logger.debug("Downloading file from Nextcloud: %s", path)

View File

@@ -0,0 +1,49 @@
"""Integration tests for waveform peaks stored inline in audio_versions."""
import pytest
from tests.factories import create_audio_version, create_band, create_member, create_song
@pytest.mark.asyncio
@pytest.mark.integration
async def test_audio_version_stores_waveform_peaks(db_session, current_member):
"""AudioVersion can store waveform_peaks and waveform_peaks_mini JSONB data."""
from rehearsalhub.repositories.audio_version import AudioVersionRepository
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)
peaks_500 = [float(i) / 500 for i in range(500)]
peaks_100 = [float(i) / 100 for i in range(100)]
repo = AudioVersionRepository(db_session)
updated = await repo.update(
version,
waveform_peaks=peaks_500,
waveform_peaks_mini=peaks_100,
)
await db_session.commit()
fetched = await repo.get_by_id(updated.id)
assert fetched is not None
assert fetched.waveform_peaks is not None
assert len(fetched.waveform_peaks) == 500
assert fetched.waveform_peaks_mini is not None
assert len(fetched.waveform_peaks_mini) == 100
assert fetched.waveform_peaks[0] == pytest.approx(0.0)
assert fetched.waveform_peaks[1] == pytest.approx(1 / 500)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_audio_version_peaks_default_null(db_session, current_member):
"""waveform_peaks and waveform_peaks_mini are null by default."""
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()
assert version.waveform_peaks is None
assert version.waveform_peaks_mini is None

View File

@@ -0,0 +1,64 @@
"""Unit tests for AudioVersionRead schema — waveform peaks serialization."""
import uuid
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
from rehearsalhub.db.models import AudioVersion
from rehearsalhub.schemas.audio_version import AudioVersionRead
def _make_version(peaks=None, peaks_mini=None) -> MagicMock:
"""Build a mock AudioVersion ORM object."""
v = MagicMock(spec=AudioVersion)
v.id = uuid.uuid4()
v.song_id = uuid.uuid4()
v.version_number = 1
v.label = None
v.nc_file_path = "/bands/test/v1.wav"
v.nc_file_etag = "abc123"
v.cdn_hls_base = None
v.waveform_url = None
v.waveform_peaks = peaks
v.waveform_peaks_mini = peaks_mini
v.duration_ms = 5000
v.format = "wav"
v.file_size_bytes = 1024
v.analysis_status = "done"
v.uploaded_by = None
v.uploaded_at = datetime.now(timezone.utc)
return v
def test_audio_version_read_includes_waveform_peaks():
peaks = [float(i) / 500 for i in range(500)]
peaks_mini = [float(i) / 100 for i in range(100)]
v = _make_version(peaks=peaks, peaks_mini=peaks_mini)
schema = AudioVersionRead.model_validate(v)
assert schema.waveform_peaks is not None
assert len(schema.waveform_peaks) == 500
assert schema.waveform_peaks_mini is not None
assert len(schema.waveform_peaks_mini) == 100
def test_audio_version_read_peaks_default_null():
v = _make_version(peaks=None, peaks_mini=None)
schema = AudioVersionRead.model_validate(v)
assert schema.waveform_peaks is None
assert schema.waveform_peaks_mini is None
def test_audio_version_read_peaks_values_preserved():
peaks = [0.0, 0.5, 1.0]
v = _make_version(peaks=peaks, peaks_mini=[0.25, 0.75])
schema = AudioVersionRead.model_validate(v)
assert schema.waveform_peaks == [0.0, 0.5, 1.0]
assert schema.waveform_peaks_mini == [0.25, 0.75]

View File

@@ -0,0 +1,38 @@
"""Confirm that list_versions returns waveform_peaks inline (no extra request needed)."""
import uuid
from datetime import datetime, timezone
from unittest.mock import MagicMock
from rehearsalhub.db.models import AudioVersion
from rehearsalhub.schemas.audio_version import AudioVersionRead
def test_audio_version_read_includes_peaks_in_list_serialization():
"""AudioVersionRead (used by list_versions) serializes waveform_peaks inline."""
peaks = [0.1, 0.5, 0.9]
mini = [0.3, 0.7]
v = MagicMock(spec=AudioVersion)
v.id = uuid.uuid4()
v.song_id = uuid.uuid4()
v.version_number = 1
v.label = None
v.nc_file_path = "/test/v1.wav"
v.nc_file_etag = "etag"
v.cdn_hls_base = None
v.waveform_url = None
v.waveform_peaks = peaks
v.waveform_peaks_mini = mini
v.duration_ms = 3000
v.format = "wav"
v.file_size_bytes = 512
v.analysis_status = "done"
v.uploaded_by = None
v.uploaded_at = datetime.now(timezone.utc)
schema = AudioVersionRead.model_validate(v)
serialized = schema.model_dump()
assert serialized["waveform_peaks"] == peaks
assert serialized["waveform_peaks_mini"] == mini

View File

@@ -0,0 +1,119 @@
"""Unit tests for GET /versions/{id}/waveform endpoint — reads peaks from DB."""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from rehearsalhub.db.models import AudioVersion, Member, Song
def _make_member() -> MagicMock:
m = MagicMock(spec=Member)
m.id = uuid.uuid4()
m.nc_url = "http://nc.test"
m.nc_username = "user"
m.nc_password = "pass"
return m
def _make_version(peaks=None, peaks_mini=None, has_waveform_url=False) -> MagicMock:
v = MagicMock(spec=AudioVersion)
v.id = uuid.uuid4()
v.song_id = uuid.uuid4()
v.uploaded_by = None
v.waveform_url = "waveforms/test.json" if has_waveform_url else None
v.waveform_peaks = peaks
v.waveform_peaks_mini = peaks_mini
v.cdn_hls_base = None
v.nc_file_path = "/bands/test/v1.wav"
return v
def _make_song(band_id: uuid.UUID) -> MagicMock:
s = MagicMock(spec=Song)
s.id = uuid.uuid4()
s.band_id = band_id
return s
@pytest.mark.asyncio
async def test_waveform_returns_full_peaks_from_db(mock_session):
"""GET /versions/{id}/waveform returns 500-point peaks from DB column."""
from rehearsalhub.routers.versions import get_waveform
peaks = [float(i) / 500 for i in range(500)]
version = _make_version(peaks=peaks)
member = _make_member()
band_id = uuid.uuid4()
song = _make_song(band_id)
with (
patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(version, song)),
):
result = await get_waveform(version_id=version.id, session=mock_session, current_member=member)
assert result["data"] == peaks
assert result["length"] == 500
assert "mini" not in result
@pytest.mark.asyncio
async def test_waveform_returns_mini_peaks_with_resolution_param(mock_session):
"""GET /versions/{id}/waveform?resolution=mini returns 100-point peaks."""
from rehearsalhub.routers.versions import get_waveform
peaks_mini = [float(i) / 100 for i in range(100)]
version = _make_version(peaks=[0.5] * 500, peaks_mini=peaks_mini)
member = _make_member()
band_id = uuid.uuid4()
song = _make_song(band_id)
with (
patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(version, song)),
):
result = await get_waveform(version_id=version.id, session=mock_session, current_member=member, resolution="mini")
assert result["data"] == peaks_mini
assert result["length"] == 100
@pytest.mark.asyncio
async def test_waveform_404_when_no_peaks_in_db(mock_session):
"""GET /versions/{id}/waveform returns 404 when no peaks stored yet."""
from fastapi import HTTPException
from rehearsalhub.routers.versions import get_waveform
version = _make_version(peaks=None, peaks_mini=None)
member = _make_member()
song = _make_song(uuid.uuid4())
with (
patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(version, song)),pytest.raises(HTTPException) as exc_info
):
await get_waveform(version_id=version.id, session=mock_session, current_member=member)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_waveform_mini_404_when_no_mini_peaks(mock_session):
"""GET /versions/{id}/waveform?resolution=mini returns 404 when no mini peaks stored."""
from fastapi import HTTPException
from rehearsalhub.routers.versions import get_waveform
version = _make_version(peaks=[0.5] * 500, peaks_mini=None)
member = _make_member()
song = _make_song(uuid.uuid4())
with (
patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(version, song)),
pytest.raises(HTTPException) as exc_info,
):
await get_waveform(version_id=version.id, session=mock_session, current_member=member, resolution="mini")
assert exc_info.value.status_code == 404

4
api/uv.lock generated
View File

@@ -1348,8 +1348,10 @@ dev = [
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
@@ -1382,8 +1384,10 @@ provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mypy", specifier = ">=1.19.1" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "ruff", specifier = ">=0.15.8" },
]
[[package]]

View File

@@ -7,6 +7,8 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-default_secure_password}
volumes:
- pg_data_dev:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- rh_net
healthcheck:
@@ -20,11 +22,17 @@ services:
image: redis:7-alpine
networks:
- rh_net
healthcheck:
test: ["CMD-SHELL", "redis-cli ping || exit 1"]
interval: 5s
timeout: 3s
retries: 10
api:
build:
context: ./api
target: development
command: sh -c "alembic upgrade head && python3 -m uvicorn rehearsalhub.main:app --host 0.0.0.0 --port 8000 --reload"
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
@@ -33,6 +41,7 @@ services:
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY:-replace_me_with_32_byte_hex_default}
INTERNAL_SECRET: ${INTERNAL_SECRET:-replace_me_with_32_byte_hex_default}
STORAGE_ENCRYPTION_KEY: ${STORAGE_ENCRYPTION_KEY:-5vaaZQs4J7CFYZ7fqee37HgIt4xNxKHHX6OWd29Yh5E=}
DOMAIN: localhost
ports:
- "8000:8000"
@@ -42,6 +51,29 @@ services:
db:
condition: service_healthy
audio-worker:
build:
context: ./worker
target: development
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
REDIS_URL: redis://redis:6379/0
API_URL: http://api:8000
INTERNAL_SECRET: ${INTERNAL_SECRET:-replace_me_with_32_byte_hex_default}
ANALYSIS_VERSION: "1.0.0"
LOG_LEVEL: DEBUG
PYTHONUNBUFFERED: "1"
volumes:
- ./worker/src:/app/src:z
- audio_tmp:/tmp/audio
networks:
- rh_net
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
web:
build:
context: ./web
@@ -61,3 +93,4 @@ networks:
volumes:
pg_data_dev:
audio_tmp:

134
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,134 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-rehearsalhub}
POSTGRES_USER: ${POSTGRES_USER:-rh_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-default_secure_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} || exit 1"]
interval: 15s
timeout: 10s
retries: 30
start_period: 45s
restart: unless-stopped
command: ["postgres", "-c", "max_connections=200", "-c", "shared_buffers=256MB"]
redis:
image: redis:7-alpine
command: redis-server --save 60 1 --loglevel warning
volumes:
- redis_data:/data
networks:
- rh_net
healthcheck:
test: ["CMD-SHELL", "redis-cli ping || exit 1"]
interval: 10s
timeout: 5s
retries: 15
start_period: 25s
restart: unless-stopped
deploy:
resources:
limits:
memory: 256M
api:
image: git.sschuhmann.de/sschuhmann/rehearsalhub/api:0.1.0
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service}
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password}
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY:-replace_me_with_32_byte_hex_default}
INTERNAL_SECRET: ${INTERNAL_SECRET:-replace_me_with_32_byte_hex_default}
STORAGE_ENCRYPTION_KEY: ${STORAGE_ENCRYPTION_KEY}
DOMAIN: ${DOMAIN:-localhost}
networks:
- rh_net
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"]
interval: 20s
timeout: 10s
retries: 5
start_period: 60s
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
audio-worker:
image: git.sschuhmann.de/sschuhmann/rehearsalhub/worker:0.1.0
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
REDIS_URL: redis://redis:6379/0
API_URL: http://api:8000
INTERNAL_SECRET: ${INTERNAL_SECRET:-replace_me_with_32_byte_hex_default}
ANALYSIS_VERSION: "1.0.0"
volumes:
- audio_tmp:/tmp/audio
networks:
- rh_net
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
api:
condition: service_started
restart: unless-stopped
deploy:
replicas: ${WORKER_REPLICAS:-2}
nc-watcher:
image: git.sschuhmann.de/sschuhmann/rehearsalhub/watcher:0.1.0
environment:
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service}
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password}
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
api:
condition: service_started
restart: unless-stopped
web:
image: git.sschuhmann.de/sschuhmann/rehearsalhub/web:0.1.0
ports:
- "8080:80"
networks:
- frontend
- rh_net
depends_on:
- api
restart: unless-stopped
networks:
frontend:
name: proxy
external: true
rh_net:
volumes:
pg_data:
redis_data:
audio_tmp:

View File

@@ -41,7 +41,7 @@ services:
build:
context: ./api
target: production
image: rehearsalhub/api:latest
image: rehearshalhub/api:latest
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
@@ -50,6 +50,7 @@ services:
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY:-replace_me_with_32_byte_hex_default}
INTERNAL_SECRET: ${INTERNAL_SECRET:-replace_me_with_32_byte_hex_default}
STORAGE_ENCRYPTION_KEY: ${STORAGE_ENCRYPTION_KEY:-5vaaZQs4J7CFYZ7fqee37HgIt4xNxKHHX6OWd29Yh5E=}
DOMAIN: ${DOMAIN:-localhost}
networks:
- rh_net
@@ -74,13 +75,12 @@ services:
build:
context: ./worker
target: production
image: rehearsalhub/audio-worker:latest
image: rehearshalhub/audio-worker:latest
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
REDIS_URL: redis://redis:6379/0
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service}
NEXTCLOUD_PASS: ${NEXTCLOUD_PASS:-default_password}
API_URL: http://api:8000
INTERNAL_SECRET: ${INTERNAL_SECRET:-replace_me_with_32_byte_hex_default}
ANALYSIS_VERSION: "1.0.0"
volumes:
- audio_tmp:/tmp/audio
@@ -94,12 +94,14 @@ services:
api:
condition: service_started
restart: unless-stopped
deploy:
replicas: ${WORKER_REPLICAS:-2}
nc-watcher:
build:
context: ./watcher
target: production
image: rehearsalhub/nc-watcher:latest
image: rehearshalhub/nc-watcher:latest
environment:
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
NEXTCLOUD_USER: ${NEXTCLOUD_USER:-rh_service}
@@ -122,7 +124,7 @@ services:
build:
context: ./web
target: production
image: rehearsalhub/web:latest
image: rehearshalhub/web:latest
ports:
- "8080:80"
networks:

41
scripts/build-and-push.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
set -euo pipefail
# Configuration
REGISTRY="git.sschuhmann.de/sschuhmann/rehearshalhub"
COMPONENTS=("api" "web" "worker" "watcher")
# Get version from git tag
get_version() {
local tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [[ -z "$tag" ]]; then
echo "Error: No git tags found. Please create a tag first (e.g., git tag v1.0.0)" >&2
exit 1
fi
# Remove v prefix if present for semantic versioning
echo "${tag#v}"
}
# Main build and push function
build_and_push() {
local version=$1
echo "Building and pushing version: $version"
for component in "${COMPONENTS[@]}"; do
echo "Building $component..."
docker build -t "$REGISTRY/$component-$version" -f "$component/Dockerfile" --target production "$component"
echo "Pushing $component-$version..."
docker push "$REGISTRY/$component-$version"
# Also tag as latest for convenience
docker tag "$REGISTRY/$component-$version" "$REGISTRY/$component-latest"
docker push "$REGISTRY/$component-latest"
done
echo "All components built and pushed successfully!"
}
# Execute
VERSION=$(get_version)
build_and_push "$VERSION"

22
scripts/build-containers.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -euo pipefail
# Get current git tag, fall back to "latest" if no tags exist
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "latest")
echo "Building container images with tag: $TAG"
# Build all services using docker compose
docker compose build --no-cache
echo "Tagging images for Gitea registry..."
# Tag all images with the current git tag
# Format: git.sschuhmann.de/owner/rehearsalhub/service:tag
docker tag rehearsalhub/api:latest git.sschuhmann.de/sschuhmann/rehearshalhub/api:$TAG
docker tag rehearsalhub/web:latest git.sschuhmann.de/sschuhmann/rehearshalhub/web:$TAG
docker tag rehearsalhub/audio-worker:latest git.sschuhmann.de/sschuhmann/rehearshalhub/worker:$TAG
docker tag rehearsalhub/nc-watcher:latest git.sschuhmann.de/sschuhmann/rehearshalhub/watcher:$TAG
echo "Build complete! Images tagged as: $TAG"
echo "Ready for upload to git.sschuhmann.de/sschuhmann/rehearsalhub"

View File

@@ -1,36 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
echo "→ Checking for Nextcloud service..."
# Check if nextcloud service exists
if ! docker compose ps | grep -q nextcloud; then
echo " Nextcloud service not found in compose setup"
echo " Skipping Nextcloud configuration (external setup required)"
exit 0
fi
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"

29
scripts/release.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
set -euo pipefail
echo "=== RehearsalHub Container Release ==="
echo
# Get current git tag
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "latest")
echo "Releasing version: $TAG"
echo
# Build containers
echo "Step 1/2: Building containers..."
bash scripts/build-containers.sh
echo
# Upload containers
echo "Step 2/2: Uploading containers to Gitea..."
bash scripts/upload-containers-simple.sh
echo
echo "✅ Release complete!"
echo "All containers available at: git.sschuhmann.de/sschuhmann/rehearsalhub:$TAG"
echo
echo "Services:"
echo " - api: git.sschuhmann.de/sschuhmann/rehearsalhub/api:$TAG"
echo " - web: git.sschuhmann.de/sschuhmann/rehearsalhub/web:$TAG"
echo " - worker: git.sschuhmann.de/sschuhmann/rehearsalhub/worker:$TAG"
echo " - watcher: git.sschuhmann.de/sschuhmann/rehearsalhub/watcher:$TAG"

View File

@@ -1,38 +0,0 @@
#!/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"

47
scripts/test-auth.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/bin/bash
set -euo pipefail
echo "Testing Docker authentication with git.sschuhmann.de..."
# Test 1: Check if Docker is running
echo "1. Checking Docker daemon..."
if docker info >/dev/null 2>&1; then
echo " ✅ Docker daemon is running"
else
echo " ❌ Docker daemon is not running"
exit 1
fi
# Test 2: Check if we're logged in to any registry
echo "2. Checking Docker login status..."
if docker system df >/dev/null 2>&1; then
echo " ✅ Docker commands work"
else
echo " ❌ Docker commands failed"
exit 1
fi
# Test 3: Try to access the Gitea registry
echo "3. Testing Gitea registry access..."
echo " Trying to pull a test image (this may fail if image doesn't exist)..."
# Use a simple curl test instead of docker manifest
echo "4. Testing registry with curl..."
REGISTRY_URL="https://git.sschuhmann.de"
if command -v curl >/dev/null 2>&1; then
if curl -s -o /dev/null -w "%{http_code}" "$REGISTRY_URL" | grep -q "^[23]"; then
echo " ✅ Registry is accessible"
else
echo " ⚠️ Registry accessible but may require authentication"
fi
else
echo " ⚠️ curl not available, skipping HTTP test"
fi
echo ""
echo "Authentication test complete!"
echo "If you're still having issues, try:"
echo " 1. docker logout git.sschuhmann.de"
echo " 2. docker login git.sschuhmann.de"
echo " 3. cat ~/.docker/config.json (check credentials)"

View File

@@ -0,0 +1,39 @@
#!/bin/bash
set -euo pipefail
# Get current git tag, fall back to "latest" if no tags exist
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "latest")
echo "Uploading container images to Gitea registry with tag: $TAG"
# Simple check - just try to push and let Docker handle authentication
echo "Attempting to push images to git.sschuhmann.de..."
# Push all images to Gitea registry
echo "Pushing api image..."
docker push git.sschuhmann.de/sschuhmann/rehearsalhub/api:$TAG || {
echo "Failed to push api image. Check your authentication:"
echo " 1. Run: docker login git.sschuhmann.de"
echo " 2. Check: cat ~/.docker/config.json"
exit 1
}
echo "Pushing web image..."
docker push git.sschuhmann.de/sschuhmann/rehearsalhub/web:$TAG || {
echo "Failed to push web image"
exit 1
}
echo "Pushing worker image..."
docker push git.sschuhmann.de/sschuhmann/rehearsalhub/worker:$TAG || {
echo "Failed to push worker image"
exit 1
}
echo "Pushing watcher image..."
docker push git.sschuhmann.de/sschuhmann/rehearsalhub/watcher:$TAG || {
echo "Failed to push watcher image"
exit 1
}
echo "✅ Upload complete! All images pushed to git.sschuhmann.de/sschuhmann/rehearsalhub:$TAG"

42
scripts/upload-containers.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
set -euo pipefail
# Get current git tag, fall back to "latest" if no tags exist
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "latest")
echo "Uploading container images to Gitea registry with tag: $TAG"
# Simple authentication test - try to get registry info
if ! docker info >/dev/null 2>&1; then
echo "Error: Docker daemon is not running"
exit 1
fi
# Test authentication by trying to list repositories (this will fail if not authenticated)
echo "Testing Gitea registry authentication..."
if ! timeout 10s docker manifest inspect git.sschuhmann.de/sschuhmann/rehearsalhub/api:$TAG >/dev/null 2>&1; then
# Check if the error is specifically authentication related
TEST_OUTPUT=$(docker manifest inspect git.sschuhmann.de/sschuhmann/rehearsalhub/api:$TAG 2>&1 || true)
if echo "$TEST_OUTPUT" | grep -qi "401\|unauthorized\|authentication required"; then
echo "Error: Not authenticated with git.sschuhmann.de registry"
echo "Please run: docker login git.sschuhmann.de"
exit 1
fi
# If it's not an auth error, it's probably just that the image doesn't exist yet
echo "Registry accessible (image doesn't exist yet, will be created)"
fi
# Push all images to Gitea registry
echo "Pushing api image..."
docker push git.sschuhmann.de/sschuhmann/rehearsalhub/api:$TAG
echo "Pushing web image..."
docker push git.sschuhmann.de/sschuhmann/rehearsalhub/web:$TAG
echo "Pushing worker image..."
docker push git.sschuhmann.de/sschuhmann/rehearsalhub/worker:$TAG
echo "Pushing watcher image..."
docker push git.sschuhmann.de/sschuhmann/rehearsalhub/watcher:$TAG
echo "Upload complete! All images pushed to git.sschuhmann.de/sschuhmann/rehearsalhub:$TAG"

View File

@@ -27,3 +27,8 @@ packages = ["src/watcher"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[dependency-groups]
dev = [
"ruff>=0.15.10",
]

View File

@@ -5,11 +5,10 @@ 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"
# Shared secret for calling internal API endpoints
internal_secret: str = "dev-change-me-in-production"
redis_url: str = "redis://localhost:6379/0"
job_queue_key: str = "rh:jobs"
@@ -18,6 +17,10 @@ class WatcherSettings(BaseSettings):
# File extensions to watch
audio_extensions: list[str] = [".wav", ".mp3", ".flac", ".aac", ".ogg", ".m4a", ".opus"]
# How often (in poll cycles) to refresh the list of bands from the API.
# 0 = only on startup, N = every N poll cycles.
config_refresh_interval: int = 10
@lru_cache
def get_settings() -> WatcherSettings:

View File

@@ -1,149 +1,93 @@
"""Event loop: poll Nextcloud activity, detect audio uploads, push to API."""
"""Event loop: fetch per-band storage configs from the API, detect audio uploads."""
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
from watcher.nc_watcher import NextcloudWatcher
from watcher.protocol import FileEvent, WatcherClient
log = logging.getLogger("watcher.event_loop")
# Persist last seen activity ID in-process (good enough for a POC)
_last_activity_id: int = 0
# Nextcloud Activity API v2 filter sets.
#
# NC 22+ returns: type="file_created"|"file_changed" (subject is human-readable)
# NC <22 returns: type="files" (subject is a machine key like "created_self")
#
# We accept either style so the watcher works across NC versions.
_UPLOAD_TYPES = {"file_created", "file_changed"}
_UPLOAD_SUBJECTS = {
"created_by",
"changed_by",
"created_public",
"created_self",
"changed_self",
}
def is_audio_file(path: str, extensions: list[str]) -> bool:
return Path(path).suffix.lower() in extensions
def normalize_nc_path(raw_path: str, username: str) -> str:
"""
Strip the Nextcloud WebDAV/activity path prefix so we get a plain
user-relative path.
Activity objects can look like:
/username/files/bands/slug/...
/remote.php/dav/files/username/bands/slug/...
bands/slug/... (already relative)
"""
path = raw_path.strip("/")
# /remote.php/dav/files/<user>/...
dav_prefix = f"remote.php/dav/files/{username}/"
if path.startswith(dav_prefix):
return path[len(dav_prefix):]
# /<username>/files/... (activity app format)
user_files_prefix = f"{username}/files/"
if path.startswith(user_files_prefix):
return path[len(user_files_prefix):]
# files/...
if path.startswith("files/"):
return path[len("files/"):]
return path
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", {})
if isinstance(objects, dict):
for _file_id, file_path in objects.items():
if isinstance(file_path, str):
return file_path
# Fallback: older NC versions put it in object_name
return activity.get("object_name") or None
async def register_version_with_api(nc_file_path: str, nc_file_etag: str | None, api_url: str) -> bool:
async def fetch_nextcloud_configs(settings: WatcherSettings) -> list[dict]:
"""Fetch active Nextcloud configs for all bands from the internal API."""
url = f"{settings.api_url}/api/v1/internal/storage/nextcloud-watch-configs"
headers = {"X-Internal-Token": settings.internal_secret}
try:
payload = {"nc_file_path": nc_file_path, "nc_file_etag": nc_file_etag}
async with httpx.AsyncClient(timeout=15.0) as c:
resp = await c.post(f"{api_url}/api/v1/internal/nc-upload", json=payload)
resp = await c.get(url, headers=headers)
resp.raise_for_status()
return resp.json()
except Exception as exc:
log.warning("Failed to fetch NC configs from API: %s", exc)
return []
def build_nc_watchers(
configs: list[dict],
settings: WatcherSettings,
) -> dict[str, NextcloudWatcher]:
"""Build one NextcloudWatcher per band from the API config payload."""
watchers: dict[str, NextcloudWatcher] = {}
for cfg in configs:
band_id = cfg["band_id"]
try:
watchers[band_id] = NextcloudWatcher(
band_id=band_id,
nc_url=cfg["nc_url"],
nc_username=cfg["nc_username"],
nc_app_password=cfg["nc_app_password"],
audio_extensions=settings.audio_extensions,
)
except Exception as exc:
log.error("Failed to create watcher for band %s: %s", band_id, exc)
return watchers
async def register_event_with_api(event: FileEvent, settings: WatcherSettings) -> bool:
"""Forward a FileEvent to the API's internal nc-upload endpoint."""
payload = {"nc_file_path": event.file_path, "nc_file_etag": event.etag}
headers = {"X-Internal-Token": settings.internal_secret}
try:
async with httpx.AsyncClient(timeout=15.0) as c:
resp = await c.post(
f"{settings.api_url}/api/v1/internal/nc-upload",
json=payload,
headers=headers,
)
if resp.status_code in (200, 201):
log.info("Registered version via internal API: %s", nc_file_path)
log.info("Registered event via internal API: %s", event.file_path)
return True
log.warning(
"Internal API returned %d for %s: %s",
resp.status_code, nc_file_path, resp.text[:200],
resp.status_code, event.file_path, resp.text[:200],
)
return False
except Exception as exc:
log.warning("Failed to register version with API for %s: %s", nc_file_path, exc)
log.warning("Failed to register event with API for %s: %s", event.file_path, 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:
log.info("No new activities since id=%d", _last_activity_id)
return
log.info("Received %d activities (since id=%d)", len(activities), _last_activity_id)
for activity in activities:
activity_id = int(activity.get("activity_id", 0))
activity_type = activity.get("type", "")
subject = activity.get("subject", "")
raw_path = extract_nc_file_path(activity)
# Advance the cursor regardless of whether we act on this event
_last_activity_id = max(_last_activity_id, activity_id)
log.info(
"Activity id=%d type=%r subject=%r raw_path=%r",
activity_id, activity_type, subject, raw_path,
)
if raw_path is None:
log.info(" → skip: no file path in activity payload")
async def poll_all_once(
watchers: dict[str, WatcherClient],
cursors: dict[str, str | None],
settings: WatcherSettings,
) -> None:
"""Poll every watcher once and forward new events to the API."""
for band_id, watcher in watchers.items():
cursor = cursors.get(band_id)
try:
events, new_cursor = await watcher.poll_changes(cursor)
cursors[band_id] = new_cursor
if not events:
log.debug("Band %s: no new events (cursor=%s)", band_id, new_cursor)
continue
nc_path = normalize_nc_path(raw_path, nc_client.username)
log.info(" → normalized path: %r", nc_path)
# Only care about audio files — skip everything else immediately
if not is_audio_file(nc_path, settings.audio_extensions):
log.info(
" → skip: not an audio file (ext=%s)",
Path(nc_path).suffix.lower() or "<none>",
)
continue
if activity_type not in _UPLOAD_TYPES and subject not in _UPLOAD_SUBJECTS:
log.info(
" → skip: type=%r subject=%r is not a file upload event",
activity_type, subject,
)
continue
log.info(" → MATCH — registering audio upload: %s", nc_path)
etag = await nc_client.get_file_etag(nc_path)
success = await register_version_with_api(nc_path, etag, settings.api_url)
if not success:
log.warning(" → FAILED to register upload for activity %d (%s)", activity_id, nc_path)
log.info("Band %s: %d new event(s)", band_id, len(events))
for event in events:
await register_event_with_api(event, settings)
except Exception as exc:
log.exception("Poll error for band %s: %s", band_id, exc)

View File

@@ -6,11 +6,13 @@ import asyncio
import logging
from watcher.config import get_settings
from watcher.event_loop import poll_once
from watcher.nc_client import NextcloudWatcherClient
from watcher.event_loop import (
build_nc_watchers,
fetch_nextcloud_configs,
poll_all_once,
)
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(levelname)s %(name)s %(message)s")
# Quiet httpx's per-request noise at DEBUG; keep our own loggers verbose
logging.getLogger("httpx").setLevel(logging.INFO)
logging.getLogger("httpcore").setLevel(logging.WARNING)
log = logging.getLogger("watcher")
@@ -18,22 +20,39 @@ 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("Starting watcher (poll_interval=%ds)", settings.poll_interval)
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)
# Per-band WatcherClient instances; keyed by band_id string
watchers: dict = {}
# Per-band opaque cursors (last seen activity ID, page token, etc.)
cursors: dict[str, str | None] = {}
poll_cycle = 0
while True:
# Refresh the list of bands (and their storage configs) periodically.
refresh = (
poll_cycle == 0
or (settings.config_refresh_interval > 0 and poll_cycle % settings.config_refresh_interval == 0)
)
if refresh:
log.info("Refreshing storage configs from API…")
configs = await fetch_nextcloud_configs(settings)
if configs:
watchers = build_nc_watchers(configs, settings)
# Preserve cursors for bands that were already being watched
for band_id in watchers:
cursors.setdefault(band_id, None)
log.info("Watching %d Nextcloud band(s): %s", len(watchers), list(watchers))
else:
log.warning("No Nextcloud storage configs received — no bands to watch")
if watchers:
try:
await poll_once(nc, settings)
await poll_all_once(watchers, cursors, settings)
except Exception as exc:
log.exception("Poll error: %s", exc)
log.exception("Unexpected error in poll loop: %s", exc)
poll_cycle += 1
await asyncio.sleep(settings.poll_interval)

View File

@@ -0,0 +1,116 @@
"""Nextcloud WatcherClient implementation.
Polls the Nextcloud Activity API to detect new / modified audio files.
The cursor is the last seen ``activity_id`` (stored as a string for
protocol compatibility).
"""
from __future__ import annotations
import logging
from pathlib import Path
from watcher.nc_client import NextcloudWatcherClient
from watcher.protocol import FileEvent
log = logging.getLogger("watcher.nc_watcher")
_UPLOAD_TYPES = {"file_created", "file_changed"}
_UPLOAD_SUBJECTS = {
"created_by",
"changed_by",
"created_public",
"created_self",
"changed_self",
}
class NextcloudWatcher:
"""WatcherClient implementation backed by the Nextcloud Activity API."""
def __init__(
self,
band_id: str,
nc_url: str,
nc_username: str,
nc_app_password: str,
audio_extensions: list[str],
) -> None:
self.band_id = band_id
self._audio_extensions = audio_extensions
self._nc = NextcloudWatcherClient(
base_url=nc_url,
username=nc_username,
password=nc_app_password,
)
async def poll_changes(self, cursor: str | None) -> tuple[list[FileEvent], str]:
since_id = int(cursor) if cursor else 0
activities = await self._nc.get_activities(since_id=since_id)
events: list[FileEvent] = []
new_cursor = cursor or "0"
for activity in activities:
activity_id = int(activity.get("activity_id", 0))
new_cursor = str(max(int(new_cursor), activity_id))
activity_type = activity.get("type", "")
subject = activity.get("subject", "")
raw_path = _extract_file_path(activity)
if raw_path is None:
continue
nc_path = _normalize_path(raw_path, self._nc.username)
log.debug("Activity %d type=%r path=%r", activity_id, activity_type, nc_path)
if not _is_audio(nc_path, self._audio_extensions):
continue
if activity_type not in _UPLOAD_TYPES and subject not in _UPLOAD_SUBJECTS:
continue
etag = await self._nc.get_file_etag(nc_path)
events.append(
FileEvent(
band_id=self.band_id,
file_path=nc_path,
event_type="created" if "created" in activity_type else "modified",
etag=etag,
)
)
return events, new_cursor
async def is_healthy(self) -> bool:
return await self._nc.is_healthy()
# ── Helpers ────────────────────────────────────────────────────────────────────
def _extract_file_path(activity: dict) -> str | None:
objects = activity.get("objects", {})
if isinstance(objects, dict):
for _, file_path in objects.items():
if isinstance(file_path, str):
return file_path
return activity.get("object_name") or None
def _normalize_path(raw_path: str, username: str) -> str:
path = raw_path.strip("/")
dav_prefix = f"remote.php/dav/files/{username}/"
if path.startswith(dav_prefix):
return path[len(dav_prefix):]
user_files_prefix = f"{username}/files/"
if path.startswith(user_files_prefix):
return path[len(user_files_prefix):]
if path.startswith("files/"):
return path[len("files/"):]
return path
def _is_audio(path: str, extensions: list[str]) -> bool:
return Path(path).suffix.lower() in extensions

View File

@@ -0,0 +1,42 @@
"""WatcherClient protocol — abstracts provider-specific change-detection APIs.
Each storage provider implements its own change detection:
Nextcloud → Activity API (polling)
Google Drive → Changes API or webhook push
OneDrive → Microsoft Graph subscriptions
Dropbox → Long-poll or webhooks
All implementations must satisfy this protocol so the event loop can treat
them uniformly.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@dataclass
class FileEvent:
"""A file-change event emitted by a WatcherClient."""
band_id: str
file_path: str # Provider-relative path (no host, no DAV prefix)
event_type: str # 'created' | 'modified' | 'deleted'
etag: str | None = None
class WatcherClient(Protocol):
band_id: str
async def poll_changes(self, cursor: str | None) -> tuple[list[FileEvent], str]:
"""Return (events, new_cursor) since the given cursor.
``cursor`` is an opaque string whose meaning is implementation-defined
(e.g., an activity ID for Nextcloud, a page token for Google Drive).
Pass ``None`` to start from the current position (i.e. only new events).
"""
...
async def is_healthy(self) -> bool:
"""Return True if the storage backend is reachable."""
...

533
watcher/uv.lock generated Normal file
View File

@@ -0,0 +1,533 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "hiredis"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/97/d6/9bef6dc3052c168c93fbf7e6c0f2b12c45f0f741a2d30fd919096774343a/hiredis-3.3.1.tar.gz", hash = "sha256:da6f0302360e99d32bc2869772692797ebadd536e1b826d0103c72ba49d38698", size = 89101, upload-time = "2026-03-16T15:21:08.092Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/1d/1a7d925d886211948ab9cca44221b1d9dd4d3481d015511e98794e37d369/hiredis-3.3.1-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:60543f3b068b16a86e99ed96b7fdae71cdc1d8abdfe9b3f82032a555e52ece7e", size = 82023, upload-time = "2026-03-16T15:19:34.157Z" },
{ url = "https://files.pythonhosted.org/packages/13/2f/a6017fe1db47cd63a4aefc0dd21dd4dcb0c4e857bfbcfaa27329745f24a3/hiredis-3.3.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:2611bfaaadc5e8d43fb7967f9bbf1110c8beaa83aee2f2d812c76f11cfb56c6a", size = 46215, upload-time = "2026-03-16T15:19:35.068Z" },
{ url = "https://files.pythonhosted.org/packages/77/4b/35a71d088c6934e162aa81c7e289fa3110a3aca84ab695d88dbd488c74a2/hiredis-3.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e3754ce60e1b11b0afad9a053481ff184d2ee24bea47099107156d1b84a84aa", size = 41861, upload-time = "2026-03-16T15:19:36.32Z" },
{ url = "https://files.pythonhosted.org/packages/1f/54/904bc723a95926977764fefd6f0d46067579bac38fffc32b806f3f2c05c0/hiredis-3.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e89dabf436ee79b358fd970dcbed6333a36d91db73f27069ca24a02fb138a404", size = 170196, upload-time = "2026-03-16T15:19:37.274Z" },
{ url = "https://files.pythonhosted.org/packages/1d/01/4e840cd4cb53c28578234708b08fb9ec9e41c2880acc0e269a7264e1b3af/hiredis-3.3.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4f7e242eab698ad0be5a4b2ec616fa856569c57455cc67c625fd567726290e5f", size = 181808, upload-time = "2026-03-16T15:19:38.637Z" },
{ url = "https://files.pythonhosted.org/packages/87/0d/fc845f06f8203ab76c401d4d2b97f9fb768e644b053a40f441f7dcc71f2d/hiredis-3.3.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53148a4e21057541b6d8e493b2ea1b500037ddf34433c391970036f3cbce00e3", size = 180577, upload-time = "2026-03-16T15:19:39.749Z" },
{ url = "https://files.pythonhosted.org/packages/52/3a/859afe2620666bf6d58eb977870c47d98af4999d473b50528b323918f3f7/hiredis-3.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25132902d3eff38781e0d54f27a0942ec849e3c07dbdce83c4d92b7e43c8dce", size = 172507, upload-time = "2026-03-16T15:19:40.87Z" },
{ url = "https://files.pythonhosted.org/packages/60/a8/004349708ad8bf0d188d46049f846d3fe2d4a7a8d0d5a6a8ba024017d8b3/hiredis-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3fb6573efa15a29c12c0c0f7170b14e7c1347fe4bb39b6a15b779f46015cc929", size = 166339, upload-time = "2026-03-16T15:19:41.912Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fb/bfc6df29381830c99bfd9e97ed3b6d75d9303866a28c23d51ab8c50f63e3/hiredis-3.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:487658e1db83c1ee9fbbac6a43039ea76957767a5987ffb16b590613f9e68297", size = 176766, upload-time = "2026-03-16T15:19:42.981Z" },
{ url = "https://files.pythonhosted.org/packages/53/e7/f54aaad4559a413ec8b1043a89567a5a1f898426e4091b9af5e0f2120371/hiredis-3.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a1d190790ee39b8b7adeeb10fc4090dc4859eb4e75ed27bd8108710eef18f358", size = 170313, upload-time = "2026-03-16T15:19:44.082Z" },
{ url = "https://files.pythonhosted.org/packages/60/51/b80394db4c74d4cba342fa4208f690a2739c16f1125c2a62ba1701b8e2b7/hiredis-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a42c7becd4c9ec4ab5769c754eb61112777bdc6e1c1525e2077389e193b5f5aa", size = 167964, upload-time = "2026-03-16T15:19:45.237Z" },
{ url = "https://files.pythonhosted.org/packages/47/ef/5e438d1e058be57cdc1bafc1b1ec8ab43cc890c61447e88f8b878a0e32c3/hiredis-3.3.1-cp312-cp312-win32.whl", hash = "sha256:17ec8b524055a88b80d76c177dbbbe475a25c17c5bf4b67bdbdbd0629bcae838", size = 20532, upload-time = "2026-03-16T15:19:46.233Z" },
{ url = "https://files.pythonhosted.org/packages/e9/c6/39994b9c5646e7bf7d5e92170c07fd5f224ae9f34d95ff202f31845eb94b/hiredis-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:0fac4af8515e6cca74fc701169ae4dc9a71a90e9319c9d21006ec9454b43aa2f", size = 22381, upload-time = "2026-03-16T15:19:47.082Z" },
{ url = "https://files.pythonhosted.org/packages/d8/4b/c7f4d6d6643622f296395269e24b02c69d4ac72822f052b8cae16fa3af03/hiredis-3.3.1-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:afe3c3863f16704fb5d7c2c6ff56aaf9e054f6d269f7b4c9074c5476178d1aba", size = 82027, upload-time = "2026-03-16T15:19:48.002Z" },
{ url = "https://files.pythonhosted.org/packages/9b/45/198be960a7443d6eb5045751e929480929c0defbca316ce1a47d15187330/hiredis-3.3.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:f19ee7dc1ef8a6497570d91fa4057ba910ad98297a50b8c44ff37589f7c89d17", size = 46220, upload-time = "2026-03-16T15:19:48.953Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a4/6ab925177f289830008dbe1488a9858675e2e234f48c9c1653bd4d0eaddc/hiredis-3.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09f5e510f637f2c72d2a79fb3ad05f7b6211e057e367ca5c4f97bb3d8c9d71f4", size = 41858, upload-time = "2026-03-16T15:19:49.939Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c8/a0ddbb9e9c27fcb0022f7b7e93abc75727cb634c6a5273ca5171033dac78/hiredis-3.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b46e96b50dad03495447860510daebd2c96fd44ed25ba8ccb03e9f89eaa9d34", size = 170095, upload-time = "2026-03-16T15:19:51.216Z" },
{ url = "https://files.pythonhosted.org/packages/94/06/618d509cc454912028f71995f3dd6eb54606f0aa8163ff79c5b7ec1f2bda/hiredis-3.3.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b4fe7f38aa8956fcc1cea270e62601e0e11066aff78e384be70fd283d30293b6", size = 181745, upload-time = "2026-03-16T15:19:52.72Z" },
{ url = "https://files.pythonhosted.org/packages/06/14/75b2deb62a61fc75a41ce1a6a781fe239133bbc88fef404d32a148ad152a/hiredis-3.3.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b96da7e365d6488d2a75266a662cbe3cc14b28c23dd9b0c9aa04b5bc5c20192", size = 180465, upload-time = "2026-03-16T15:19:53.847Z" },
{ url = "https://files.pythonhosted.org/packages/7e/8c/8e03dcbfde8e2ca3f880fce06ad0877b3f098ed5fdfb17cf3b821a32323a/hiredis-3.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52d5641027d6731bc7b5e7d126a5158a99784a9f8c6de3d97ca89aca4969e9f8", size = 172419, upload-time = "2026-03-16T15:19:54.959Z" },
{ url = "https://files.pythonhosted.org/packages/03/05/843005d68403a3805309075efc6638360a3ababa6cb4545163bf80c8e7f7/hiredis-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eddeb9a153795cf6e615f9f3cef66a1d573ff3b6ee16df2b10d1d1c2f2baeaa8", size = 166398, upload-time = "2026-03-16T15:19:56.36Z" },
{ url = "https://files.pythonhosted.org/packages/f5/23/abe2476244fd792f5108009ec0ae666eaa5b2165ca19f2e86638d8324ac9/hiredis-3.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:011a9071c3df4885cac7f58a2623feac6c8e2ad30e6ba93c55195af05ce61ff5", size = 176844, upload-time = "2026-03-16T15:19:57.462Z" },
{ url = "https://files.pythonhosted.org/packages/c6/47/e1cdccc559b98e548bcff0868c3938d375663418c0adca465895ee1f72e7/hiredis-3.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:264ee7e9cb6c30dc78da4ecf71d74cf14ca122817c665d838eda8b4384bce1b0", size = 170366, upload-time = "2026-03-16T15:19:58.548Z" },
{ url = "https://files.pythonhosted.org/packages/a2/e1/fda8325f51d06877e8e92500b15d4aff3855b4c3c91dbd9636a82e4591f2/hiredis-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d1434d0bcc1b3ef048bae53f26456405c08aeed9827e65b24094f5f3a6793f1", size = 168023, upload-time = "2026-03-16T15:19:59.727Z" },
{ url = "https://files.pythonhosted.org/packages/cd/21/2839d1625095989c116470e2b6841bbe1a2a5509585e82a4f3f5cd47f511/hiredis-3.3.1-cp313-cp313-win32.whl", hash = "sha256:f915a34fb742e23d0d61573349aa45d6f74037fde9d58a9f340435eff8d62736", size = 20535, upload-time = "2026-03-16T15:20:00.938Z" },
{ url = "https://files.pythonhosted.org/packages/84/f9/534c2a89b24445a9a9623beb4697fd72b8c8f16286f6f3bda012c7af004a/hiredis-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:d8e56e0d1fe607bfff422633f313aec9191c3859ab99d11ff097e3e6e068000c", size = 22383, upload-time = "2026-03-16T15:20:01.865Z" },
{ url = "https://files.pythonhosted.org/packages/03/72/0450d6b449da58120c5497346eb707738f8f67b9e60c28a8ef90133fc81f/hiredis-3.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:439f9a5cc8f9519ce208a24cdebfa0440fef26aa682a40ba2c92acb10a53f5e0", size = 82112, upload-time = "2026-03-16T15:20:02.865Z" },
{ url = "https://files.pythonhosted.org/packages/22/c0/0be33a29bcd463e6cbb0282515dd4d0cdfe33c30c7afc6d4d8c460e23266/hiredis-3.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3724f0e58c6ff76fd683429945491de71324ab1bc0ad943a8d68cb0932d24075", size = 46238, upload-time = "2026-03-16T15:20:03.896Z" },
{ url = "https://files.pythonhosted.org/packages/62/f2/f999854bfaf3bcbee0f797f24706c182ecfaca825f6a582f6281a6aa97e0/hiredis-3.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29fe35e3c6fe03204e75c86514f452591957a1e06b05d86e10d795455b71c355", size = 41891, upload-time = "2026-03-16T15:20:04.939Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c8/cd9ab90fec3a301d864d8ab6167aea387add8e2287969d89cbcd45d6b0e0/hiredis-3.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d42f3a13290f89191568fc113d95a3d2c8759cdd8c3672f021d8b7436f909e75", size = 170485, upload-time = "2026-03-16T15:20:06.284Z" },
{ url = "https://files.pythonhosted.org/packages/ac/9a/1ddf9ea236a292963146cbaf6722abeb9d503ca47d821267bb8b3b81c4f7/hiredis-3.3.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2afc675b831f7552da41116fffffca4340f387dc03f56d6ec0c7895ab0b59a10", size = 182030, upload-time = "2026-03-16T15:20:07.857Z" },
{ url = "https://files.pythonhosted.org/packages/d4/b8/e070a1dbf8a1bbb8814baa0b00836fbe3f10c7af8e11f942cc739c64e062/hiredis-3.3.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4106201cd052d9eabe3cb7b5a24b0fe37307792bda4fcb3cf6ddd72f697828e8", size = 180543, upload-time = "2026-03-16T15:20:09.096Z" },
{ url = "https://files.pythonhosted.org/packages/0d/bb/b5f4f98e44626e2446cd8a52ce6cb1fc1c99786b6e2db3bf09cea97b90cd/hiredis-3.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8887bf0f31e4b550bd988c8863b527b6587d200653e9375cd91eea2b944b7424", size = 172356, upload-time = "2026-03-16T15:20:10.245Z" },
{ url = "https://files.pythonhosted.org/packages/ef/93/73a77b54ba94e82f76d02563c588d8a062513062675f483a033a43015f2c/hiredis-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ac7697365dbe45109273b34227fee6826b276ead9a4a007e0877e1d3f0fcf21", size = 166433, upload-time = "2026-03-16T15:20:11.789Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c2/1b2dcbe5dc53a46a8cb05bed67d190a7e30bad2ad1f727ebe154dfeededd/hiredis-3.3.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2b6da6e07359107c653a809b3cff2d9ccaeedbafe33c6f16434aef6f53ce4a2b", size = 177220, upload-time = "2026-03-16T15:20:12.991Z" },
{ url = "https://files.pythonhosted.org/packages/02/09/f4314cf096552568b5ea785ceb60c424771f4d35a76c410ad39d258f74bc/hiredis-3.3.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ce334915f5d31048f76a42c607bf26687cf045eb1bc852b7340f09729c6a64fc", size = 170475, upload-time = "2026-03-16T15:20:14.519Z" },
{ url = "https://files.pythonhosted.org/packages/b1/2e/3f56e438efc8fc27ed4a3dbad58c0280061466473ec35d8f86c90c841a84/hiredis-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee11fd431f83d8a5b29d370b9d79a814d3218d30113bdcd44657e9bdf715fc92", size = 167913, upload-time = "2026-03-16T15:20:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/56/34/053e5ee91d6dc478faac661996d1fd4886c5acb7a1b5ac30e7d3c794bb51/hiredis-3.3.1-cp314-cp314-win32.whl", hash = "sha256:e0356561b4a97c83b9ee3de657a41b8d1a1781226853adaf47b550bb988fda6f", size = 21167, upload-time = "2026-03-16T15:20:17.013Z" },
{ url = "https://files.pythonhosted.org/packages/ea/33/06776c641d17881a9031e337e81b3b934c38c2adbb83c85062d6b5f83b72/hiredis-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:80aba5f85d6227faee628ae28d1c3b69c661806a0636548ac56c68782606454f", size = 23000, upload-time = "2026-03-16T15:20:17.966Z" },
{ url = "https://files.pythonhosted.org/packages/dd/5a/94f9a505b2ff5376d4a05fb279b69d89bafa7219dd33f6944026e3e56f80/hiredis-3.3.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:907f7b5501a534030738f0f27459a612d2266fd0507b007bb8f3e6de08167920", size = 83039, upload-time = "2026-03-16T15:20:19.316Z" },
{ url = "https://files.pythonhosted.org/packages/93/ae/d3752a8f03a1fca43d402389d2a2d234d3db54c4d1f07f26c1041ca3c5de/hiredis-3.3.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:de94b409f49eb6a588ebdd5872e826caec417cd77c17af0fb94f2128427f1a2a", size = 46703, upload-time = "2026-03-16T15:20:20.401Z" },
{ url = "https://files.pythonhosted.org/packages/9f/76/e32c868a2fa23cd82bacaffd38649d938173244a0e717ec1c0c76874dbdd/hiredis-3.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79cd03e7ff550c17758a7520bf437c156d3d4c8bb74214deeafa69cda49c85a4", size = 42379, upload-time = "2026-03-16T15:20:21.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/f6/d687d36a74ce6cf448826cf2e8edfc1eb37cc965308f74eb696aa97c69df/hiredis-3.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ffa7ba2e2da1f806f3181b9730b3e87ba9dbfec884806725d4584055ba3faa6", size = 180311, upload-time = "2026-03-16T15:20:23.037Z" },
{ url = "https://files.pythonhosted.org/packages/db/ac/f520dc0066a62a15aa920c7dd0a2028c213f4862d5f901409ae92ee5d785/hiredis-3.3.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ee37fe8cf081b72dea72f96a0ee604f492ec02252eb77dc26ff6eec3f997b580", size = 190488, upload-time = "2026-03-16T15:20:24.357Z" },
{ url = "https://files.pythonhosted.org/packages/4d/f5/ae10fff82d0f291e90c41bf10a5d6543a96aae00cccede01bf2b6f7e178d/hiredis-3.3.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9bfdeff778d3f7ff449ca5922ab773899e7d31e26a576028b06a5e9cf0ed8c34", size = 189210, upload-time = "2026-03-16T15:20:25.51Z" },
{ url = "https://files.pythonhosted.org/packages/0f/8f/5be4344e542aa8d349a03d05486c59d9ca26f69c749d11e114bf34b84d50/hiredis-3.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:027ce4fabfeff5af5b9869d5524770877f9061d118bc36b85703ae3faf5aad8e", size = 180971, upload-time = "2026-03-16T15:20:26.631Z" },
{ url = "https://files.pythonhosted.org/packages/41/a2/29e230226ec2a31f13f8a832fbafe366e263f3b090553ebe49bb4581a7bd/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dcea8c3f53674ae68e44b12e853b844a1d315250ca6677b11ec0c06aff85e86c", size = 175314, upload-time = "2026-03-16T15:20:27.848Z" },
{ url = "https://files.pythonhosted.org/packages/89/2e/bf241707ad86b9f3ebfbc7ab89e19d5ec243ff92ca77644a383622e8740b/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0b5ff2f643f4b452b0597b7fe6aa35d398cb31d8806801acfafb1558610ea2aa", size = 185652, upload-time = "2026-03-16T15:20:29.364Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c1/b39170d8bcccd01febd45af4ac6b43ff38e134a868e2ec167a82a036fb35/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3586c8a5f56d34b9dddaaa9e76905f31933cac267251006adf86ec0eef7d0400", size = 179033, upload-time = "2026-03-16T15:20:30.549Z" },
{ url = "https://files.pythonhosted.org/packages/b7/3a/4fe39a169115434f911abff08ff485b9b6201c168500e112b3f6a8110c0a/hiredis-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a110d19881ca78a88583d3b07231e7c6864864f5f1f3491b638863ea45fa8708", size = 176126, upload-time = "2026-03-16T15:20:31.958Z" },
{ url = "https://files.pythonhosted.org/packages/44/99/c1d0b0bc4f9e9150e24beb0dca2e186e32d5e749d0022e0d26453749ed51/hiredis-3.3.1-cp314-cp314t-win32.whl", hash = "sha256:98fd5b39410e9d69e10e90d0330e35650becaa5dd2548f509b9598f1f3c6124d", size = 22028, upload-time = "2026-03-16T15:20:33.33Z" },
{ url = "https://files.pythonhosted.org/packages/35/d6/191e6741addc97bcf5e755661f8c82f0fd0aa35f07ece56e858da689b57e/hiredis-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ab1f646ff531d70bfd25f01e60708dfa3d105eb458b7dedd9fe9a443039fd809", size = 23811, upload-time = "2026-03-16T15:20:34.292Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "redis"
version = "7.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" },
]
[package.optional-dependencies]
hiredis = [
{ name = "hiredis" },
]
[[package]]
name = "rehearsalhub-watcher"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "pydantic-settings" },
{ name = "redis", extra = ["hiredis"] },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "respx" },
{ name = "ruff" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "pydantic-settings", specifier = ">=2.3" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5" },
{ name = "redis", extras = ["hiredis"], specifier = ">=5.0" },
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" },
]
provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.10" }]
[[package]]
name = "respx"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
]
[[package]]
name = "ruff"
version = "0.15.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
{ url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
{ url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
{ url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
{ url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
{ url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
{ url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
{ url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
{ url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
{ url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
{ url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
{ url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
{ url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]

View File

@@ -1,16 +1,17 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom";
import { BrowserRouter, Route, Routes, Navigate, useParams, useNavigate } from "react-router-dom";
import { useEffect } from "react";
import "./index.css";
import { isLoggedIn } from "./api/client";
import { AppShell } from "./components/AppShell";
import { LoginPage } from "./pages/LoginPage";
import { HomePage } from "./pages/HomePage";
import { BandPage } from "./pages/BandPage";
import { BandSettingsPage } from "./pages/BandSettingsPage";
import { SessionPage } from "./pages/SessionPage";
import { SongPage } from "./pages/SongPage";
import { SettingsPage } from "./pages/SettingsPage";
import { InvitePage } from "./pages/InvitePage";
import { useBandStore } from "./stores/bandStore";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
@@ -28,6 +29,20 @@ function ShellRoute({ children }: { children: React.ReactNode }) {
);
}
// Redirect /bands/:bandId/settings/:panel → /settings?section=:panel, setting bandStore
function BandSettingsRedirect() {
const { bandId, panel } = useParams<{ bandId: string; panel?: string }>();
const navigate = useNavigate();
const { setActiveBandId } = useBandStore();
useEffect(() => {
if (bandId) setActiveBandId(bandId);
navigate(`/settings${panel ? `?section=${panel}` : ""}`, { replace: true });
}, [bandId, panel, navigate, setActiveBandId]);
return null;
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
@@ -51,18 +66,8 @@ export default function App() {
</ShellRoute>
}
/>
<Route
path="/bands/:bandId/settings"
element={<Navigate to="members" replace />}
/>
<Route
path="/bands/:bandId/settings/:panel"
element={
<ShellRoute>
<BandSettingsPage />
</ShellRoute>
}
/>
<Route path="/bands/:bandId/settings" element={<BandSettingsRedirect />} />
<Route path="/bands/:bandId/settings/:panel" element={<BandSettingsRedirect />} />
<Route
path="/bands/:bandId/sessions/:sessionId"
element={

View File

@@ -5,7 +5,6 @@ export interface Band {
name: string;
slug: string;
genre_tags: string[];
nc_folder_path: string | null;
created_at: string;
updated_at: string;
memberships?: BandMembership[];
@@ -18,6 +17,25 @@ export interface BandMembership {
joined_at: string;
}
export interface BandStorage {
id: string;
band_id: string;
provider: string;
label: string | null;
is_active: boolean;
root_path: string | null;
created_at: string;
updated_at: string;
}
export interface NextcloudConnectData {
url: string;
username: string;
app_password: string;
label?: string;
root_path?: string;
}
export const listBands = () => api.get<Band[]>("/bands");
export const getBand = (bandId: string) => api.get<Band>(`/bands/${bandId}`);
@@ -25,5 +43,13 @@ export const createBand = (data: {
name: string;
slug: string;
genre_tags?: string[];
nc_base_path?: string;
}) => api.post<Band>("/bands", data);
export const listStorage = (bandId: string) =>
api.get<BandStorage[]>(`/bands/${bandId}/storage`);
export const connectNextcloud = (bandId: string, data: NextcloudConnectData) =>
api.post<BandStorage>(`/bands/${bandId}/storage/connect/nextcloud`, data);
export const disconnectStorage = (bandId: string) =>
api.delete(`/bands/${bandId}/storage`);

View File

@@ -0,0 +1,371 @@
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
// ── Types ─────────────────────────────────────────────────────────────────────
interface SessionSummary {
id: string;
date: string;
label: string | null;
recording_count: number;
}
interface SongSummary {
id: string;
title: string;
status: string;
tags: string[];
global_key: string | null;
global_bpm: number | null;
version_count: number;
}
interface SessionDetail {
id: string;
band_id: string;
date: string;
label: string | null;
notes: string | null;
recording_count: number;
songs: SongSummary[];
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatSessionDate(iso: string): string {
const d = new Date(iso.slice(0, 10) + "T12:00:00");
const today = new Date();
today.setHours(12, 0, 0, 0);
const diffDays = Math.round((today.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Today";
if (diffDays === 7) return "Last week";
return d.toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short", year: "numeric" });
}
function computeWaveBars(seed: string): number[] {
let s = seed.split("").reduce((acc, c) => acc + c.charCodeAt(0), 31337);
return Array.from({ length: 12 }, () => {
s = ((s * 1664525 + 1013904223) & 0xffffffff) >>> 0;
return Math.max(15, Math.floor((s / 0xffffffff) * 100));
});
}
function MiniWave({ songId, active }: { songId: string; active: boolean }) {
const bars = useMemo(() => computeWaveBars(songId), [songId]);
return (
<div style={{ display: "flex", alignItems: "flex-end", gap: "1.5px", height: 18, width: 26, flexShrink: 0 }}>
{bars.map((h, i) => (
<div
key={i}
style={{
width: 2,
height: `${h}%`,
borderRadius: 1,
background: active
? `rgba(20,184,166,${0.3 + (h / 100) * 0.5})`
: "rgba(255,255,255,0.1)",
transition: "background 0.15s",
}}
/>
))}
</div>
);
}
// ── Tag badge ─────────────────────────────────────────────────────────────────
const TAG_COLORS: Record<string, { bg: string; color: string }> = {
jam: { bg: "rgba(20,184,166,0.12)", color: "#2dd4bf" },
riff: { bg: "rgba(34,211,238,0.1)", color: "#67e8f9" },
idea: { bg: "rgba(52,211,153,0.1)", color: "#6ee7b7" },
};
function TagBadge({ tag }: { tag: string }) {
const style = TAG_COLORS[tag.toLowerCase()] ?? { bg: "rgba(255,255,255,0.06)", color: "rgba(232,233,240,0.45)" };
return (
<span style={{ fontSize: 9, fontWeight: 700, padding: "2px 6px", borderRadius: 4, letterSpacing: "0.02em", background: style.bg, color: style.color, flexShrink: 0 }}>
{tag}
</span>
);
}
// ── Track row ─────────────────────────────────────────────────────────────────
function TrackRow({
song,
index,
active,
onSelect,
}: {
song: SongSummary;
index: number;
active: boolean;
onSelect: () => void;
}) {
const [hovered, setHovered] = useState(false);
return (
<div
onClick={onSelect}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "9px 20px",
cursor: "pointer",
position: "relative",
background: active ? "rgba(20,184,166,0.06)" : hovered ? "rgba(255,255,255,0.025)" : "transparent",
transition: "background 0.12s",
}}
>
{active && (
<div style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 2, background: "linear-gradient(to bottom, #0d9488, #22d3ee)" }} />
)}
<span style={{ fontSize: 10, color: "rgba(232,233,240,0.2)", width: 20, textAlign: "right", flexShrink: 0, fontFamily: "monospace" }}>
{String(index + 1).padStart(2, "0")}
</span>
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, color: active ? "#2dd4bf" : "#e8e9f0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", transition: "color 0.12s" }}>
{song.title}
</span>
{song.tags[0] && <TagBadge tag={song.tags[0]} />}
<MiniWave songId={song.id} active={active} />
</div>
);
}
// ── Session group ─────────────────────────────────────────────────────────────
function SessionGroup({
bandId,
session,
selectedSongId,
search,
filterTag,
onSelectSong,
defaultOpen,
}: {
bandId: string;
session: SessionSummary;
selectedSongId: string | null;
search: string;
filterTag: string;
onSelectSong: (songId: string) => void;
defaultOpen: boolean;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const { data: detail } = useQuery({
queryKey: ["session", session.id],
queryFn: () => api.get<SessionDetail>(`/bands/${bandId}/sessions/${session.id}`),
enabled: isOpen,
});
const filteredSongs = useMemo(() => {
if (!detail?.songs) return [];
return detail.songs.filter((song) => {
const matchesSearch = !search || song.title.toLowerCase().includes(search.toLowerCase());
const matchesTag = !filterTag || song.tags.some((t) => t.toLowerCase() === filterTag);
return matchesSearch && matchesTag;
});
}, [detail, search, filterTag]);
return (
<div>
{/* Session header */}
<div
onClick={() => setIsOpen((o) => !o)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 20px 5px",
cursor: "pointer",
}}
>
<span style={{
fontSize: 10, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase",
background: "linear-gradient(135deg, #0d9488, #22d3ee)",
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
}}>
{formatSessionDate(session.date)}
</span>
{session.label && (
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.35)", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{session.label}
</span>
)}
{!session.label && <span style={{ flex: 1 }} />}
<span style={{ fontSize: 10, color: "rgba(232,233,240,0.28)", background: "rgba(255,255,255,0.04)", padding: "2px 8px", borderRadius: 20, flexShrink: 0 }}>
{session.recording_count}
</span>
<svg
width="12" height="12" viewBox="0 0 12 12" fill="none"
style={{ color: "rgba(232,233,240,0.28)", transform: isOpen ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.18s", flexShrink: 0 }}
>
<path d="M4 2l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
{/* Track list */}
{isOpen && (
<div>
{!detail && (
<div style={{ padding: "6px 20px 8px", fontSize: 11, color: "rgba(232,233,240,0.25)" }}>
Loading
</div>
)}
{detail && (filteredSongs.length > 0 ? filteredSongs : detail.songs).map((song, i) => (
<TrackRow
key={song.id}
song={song}
index={i}
active={song.id === selectedSongId}
onSelect={() => onSelectSong(song.id)}
/>
))}
{detail && detail.songs.length === 0 && (
<div style={{ padding: "6px 20px 8px", fontSize: 11, color: "rgba(232,233,240,0.25)" }}>
No recordings yet.
</div>
)}
{detail && search && filteredSongs.length === 0 && detail.songs.length > 0 && (
<div style={{ padding: "6px 20px 8px", fontSize: 11, color: "rgba(232,233,240,0.25)" }}>
No matches in this session.
</div>
)}
</div>
)}
</div>
);
}
// ── Filter chips ──────────────────────────────────────────────────────────────
const FILTER_CHIPS = [
{ label: "All", value: "" },
{ label: "Jam", value: "jam" },
{ label: "Riff", value: "riff" },
{ label: "Idea", value: "idea" },
];
// ── LibraryPanel ──────────────────────────────────────────────────────────────
interface LibraryPanelProps {
bandId: string;
selectedSongId: string | null;
onSelectSong: (songId: string) => void;
}
export function LibraryPanel({ bandId, selectedSongId, onSelectSong }: LibraryPanelProps) {
const [search, setSearch] = useState("");
const [filterTag, setFilterTag] = useState("");
const { data: sessions } = useQuery({
queryKey: ["sessions", bandId],
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
enabled: !!bandId,
});
const border = "rgba(255,255,255,0.06)";
return (
<div style={{
flex: 1,
display: "flex",
flexDirection: "column",
background: "#0c1612",
height: "100%",
overflow: "hidden",
}}>
{/* Header */}
<div style={{ padding: "22px 20px 12px", flexShrink: 0 }}>
<h2 style={{ margin: "0 0 14px", fontSize: 22, fontWeight: 800, letterSpacing: -0.5, background: "linear-gradient(135deg, #e8e9f0 30%, rgba(232,233,240,0.5))", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent" }}>
Library
</h2>
{/* Search */}
<div style={{ display: "flex", alignItems: "center", gap: 8, background: "#101c18", border: `1px solid ${border}`, borderRadius: 8, padding: "8px 12px", transition: "border-color 0.15s" }}
onFocusCapture={(e) => (e.currentTarget.style.borderColor = "rgba(20,184,166,0.4)")}
onBlurCapture={(e) => (e.currentTarget.style.borderColor = border)}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "rgba(232,233,240,0.25)", flexShrink: 0 }}>
<circle cx="6" cy="6" r="4.5" />
<path d="M10 10l2.5 2.5" strokeLinecap="round" />
</svg>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search recordings…"
style={{ background: "none", border: "none", outline: "none", fontFamily: "inherit", fontSize: 13, color: "#e8e9f0", flex: 1, caretColor: "#2dd4bf" }}
/>
{search && (
<button onClick={() => setSearch("")} style={{ background: "none", border: "none", cursor: "pointer", color: "rgba(232,233,240,0.3)", padding: 0, display: "flex", lineHeight: 1 }}>
</button>
)}
</div>
</div>
{/* Filter chips */}
<div style={{ display: "flex", gap: 5, padding: "0 20px 10px", borderBottom: `1px solid ${border}`, flexShrink: 0, overflowX: "auto" }}>
{FILTER_CHIPS.map((chip) => {
const on = filterTag === chip.value;
return (
<button
key={chip.value}
onClick={() => setFilterTag(chip.value)}
style={{
fontSize: 11, fontWeight: 600, padding: "4px 12px", borderRadius: 20, cursor: "pointer", whiteSpace: "nowrap",
border: on ? "1px solid rgba(20,184,166,0.4)" : `1px solid ${border}`,
background: on ? "rgba(20,184,166,0.1)" : "transparent",
color: on ? "#2dd4bf" : "rgba(232,233,240,0.35)",
fontFamily: "inherit",
transition: "all 0.12s",
}}
>
{chip.label}
</button>
);
})}
</div>
{/* Session list */}
<div style={{ flex: 1, overflowY: "auto" }}>
<style>{`
::-webkit-scrollbar { width: 3px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
`}</style>
{!sessions && (
<div style={{ padding: "20px", fontSize: 12, color: "rgba(232,233,240,0.3)" }}>Loading sessions</div>
)}
{sessions?.length === 0 && (
<div style={{ padding: "20px", fontSize: 12, color: "rgba(232,233,240,0.3)" }}>
No sessions yet. Go to Storage settings to scan your Nextcloud folder.
</div>
)}
{sessions?.map((session, i) => (
<SessionGroup
key={session.id}
bandId={bandId}
session={session}
selectedSongId={selectedSongId}
search={search}
filterTag={filterTag}
onSelectSong={onSelectSong}
defaultOpen={i === 0}
/>
))}
{/* Bottom padding for last item breathing room */}
<div style={{ height: 20 }} />
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { MiniWaveform } from "./MiniWaveform";
describe("MiniWaveform", () => {
it("renders an SVG element", () => {
const peaks = Array.from({ length: 100 }, (_, i) => i / 100);
const { container } = render(<MiniWaveform peaks={peaks} width={120} height={32} />);
const svg = container.querySelector("svg");
expect(svg).not.toBeNull();
});
it("renders the correct number of bars matching peaks length", () => {
const peaks = Array.from({ length: 100 }, (_, i) => i / 100);
const { container } = render(<MiniWaveform peaks={peaks} width={120} height={32} />);
const rects = container.querySelectorAll("rect");
expect(rects.length).toBe(100);
});
it("renders a placeholder when peaks is null", () => {
const { container } = render(<MiniWaveform peaks={null} width={120} height={32} />);
const svg = container.querySelector("svg");
expect(svg).not.toBeNull();
// With null peaks, no bars — just the placeholder rect
const rects = container.querySelectorAll("rect");
expect(rects.length).toBe(1); // single placeholder bar
});
it("renders a placeholder when peaks is empty array", () => {
const { container } = render(<MiniWaveform peaks={[]} width={120} height={32} />);
const rects = container.querySelectorAll("rect");
expect(rects.length).toBe(1);
});
it("applies correct SVG dimensions", () => {
const peaks = [0.5, 0.5];
const { container } = render(<MiniWaveform peaks={peaks} width={200} height={48} />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("width")).toBe("200");
expect(svg?.getAttribute("height")).toBe("48");
});
it("uses the provided color for bars", () => {
const peaks = [0.5, 0.5];
const { container } = render(
<MiniWaveform peaks={peaks} width={100} height={32} color="#ff0000" />
);
const rect = container.querySelector("rect");
expect(rect?.getAttribute("fill")).toBe("#ff0000");
});
});

View File

@@ -0,0 +1,62 @@
/**
* MiniWaveform — pure SVG component for rendering waveform_peaks_mini.
*
* Renders pre-computed 100-point peaks as vertical bars. No WaveSurfer dependency —
* lightweight enough to use in library/song list views for every song card.
*
* Props:
* peaks — array of 0-1 normalized peak values (ideally 100 points), or null
* width — SVG width in px
* height — SVG height in px
* color — bar fill color (default: teal accent)
*/
interface MiniWaveformProps {
peaks: number[] | null;
width: number;
height: number;
color?: string;
}
export function MiniWaveform({
peaks,
width,
height,
color = "#14b8a6",
}: MiniWaveformProps) {
const isEmpty = !peaks || peaks.length === 0;
if (isEmpty) {
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
<rect x={0} y={0} width={width} height={height} fill="rgba(255,255,255,0.06)" rx={2} />
</svg>
);
}
const barCount = peaks.length;
const gap = 1;
const barWidth = Math.max(1, (width - gap * (barCount - 1)) / barCount);
const midY = height / 2;
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden="true">
{peaks.map((peak, i) => {
const barHeight = Math.max(1, peak * height);
const x = i * (barWidth + gap);
const y = midY - barHeight / 2;
return (
<rect
key={i}
x={x}
y={y}
width={barWidth}
height={barHeight}
fill={color}
rx={0.5}
/>
);
})}
</svg>
);
}

View File

@@ -0,0 +1,485 @@
import { useRef, useState, useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client";
import type { MemberRead } from "../api/auth";
import { useWaveform } from "../hooks/useWaveform";
import { MiniWaveform } from "./MiniWaveform";
// ── Types ─────────────────────────────────────────────────────────────────────
interface SongRead {
id: string;
band_id: string;
session_id: string | null;
title: string;
status: string;
tags: string[];
global_key: string | null;
global_bpm: number | null;
version_count: number;
}
interface SongVersion {
id: string;
version_number: number;
label: string | null;
analysis_status: string;
waveform_peaks: number[] | null;
waveform_peaks_mini: number[] | null;
}
interface SongComment {
id: string;
song_id: string;
body: string;
author_id: string;
author_name: string;
author_avatar_url: string | null;
timestamp: number | null;
tag: string | null;
created_at: string;
}
interface SessionInfo {
id: string;
band_id: string;
date: string;
label: string | null;
songs: { id: string; title: string; status: string; tags: string[] }[];
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
function getInitials(name: string): string {
return name.split(/\s+/).map((w) => w[0]).join("").toUpperCase().slice(0, 2);
}
const MEMBER_COLORS = [
{ bg: "rgba(91,156,240,0.18)", border: "rgba(91,156,240,0.6)", text: "#7aabf0" },
{ bg: "rgba(200,90,180,0.18)", border: "rgba(200,90,180,0.6)", text: "#d070c0" },
{ bg: "rgba(52,211,153,0.18)", border: "rgba(52,211,153,0.6)", text: "#34d399" },
{ bg: "rgba(20,184,166,0.18)", border: "rgba(20,184,166,0.6)", text: "#2dd4bf" },
{ bg: "rgba(34,211,238,0.18)", border: "rgba(34,211,238,0.6)", text: "#22d3ee" },
];
function memberColor(authorId: string) {
let h = 0;
for (let i = 0; i < authorId.length; i++) h = (h * 31 + authorId.charCodeAt(i)) >>> 0;
return MEMBER_COLORS[h % MEMBER_COLORS.length];
}
const TAG_STYLES: Record<string, { bg: string; color: string }> = {
suggestion: { bg: "rgba(91,156,240,0.1)", color: "#7aabf0" },
issue: { bg: "rgba(244,63,94,0.1)", color: "#f87171" },
keeper: { bg: "rgba(52,211,153,0.1)", color: "#34d399" },
};
// ── Sub-components ────────────────────────────────────────────────────────────
function Avatar({ name, avatarUrl, authorId, size = 24 }: { name: string; avatarUrl: string | null; authorId: string; size?: number }) {
const mc = memberColor(authorId);
if (avatarUrl) return <img src={avatarUrl} alt={name} style={{ width: size, height: size, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} />;
return (
<div style={{ width: size, height: size, borderRadius: "50%", background: mc.bg, border: `1.5px solid ${mc.border}`, color: mc.text, display: "flex", alignItems: "center", justifyContent: "center", fontSize: size * 0.38, fontWeight: 700, flexShrink: 0 }}>
{getInitials(name)}
</div>
);
}
function TransportBtn({ onClick, title, children }: { onClick: () => void; title?: string; children: React.ReactNode }) {
const [hovered, setHovered] = useState(false);
return (
<button onClick={onClick} title={title} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}
style={{ width: 36, height: 36, borderRadius: "50%", background: hovered ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.07)", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", color: hovered ? "rgba(232,233,240,0.72)" : "rgba(232,233,240,0.35)", flexShrink: 0, transition: "all 0.12s" }}>
{children}
</button>
);
}
function WaveformPins({
comments, duration, containerWidth, onSeek, onScrollToComment,
}: {
comments: SongComment[]; duration: number; containerWidth: number;
onSeek: (t: number) => void; onScrollToComment: (id: string) => void;
}) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
const pinned = comments.filter((c) => c.timestamp != null);
return (
<div style={{ position: "relative", height: 44, overflow: "visible" }}>
{pinned.map((c) => {
const pct = duration > 0 ? c.timestamp! / duration : 0;
const left = Math.round(pct * containerWidth);
const mc = memberColor(c.author_id);
const isHovered = hoveredId === c.id;
return (
<div key={c.id}
style={{ position: "absolute", left, top: 0, transform: "translateX(-50%)", display: "flex", flexDirection: "column", alignItems: "center", cursor: "pointer", zIndex: 10, transition: "transform 0.12s", ...(isHovered ? { transform: "translateX(-50%) scale(1.15)" } : {}) }}
onMouseEnter={() => setHoveredId(c.id)} onMouseLeave={() => setHoveredId(null)}
onClick={() => { onSeek(c.timestamp!); onScrollToComment(c.id); }}
>
{isHovered && (
<div style={{ position: "absolute", bottom: "calc(100% + 6px)", left: "50%", transform: "translateX(-50%)", background: "#142420", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 8, padding: "8px 10px", width: 180, zIndex: 50, pointerEvents: "none" }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 4 }}>
<div style={{ width: 18, height: 18, borderRadius: "50%", background: mc.bg, color: mc.text, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 8, fontWeight: 700 }}>
{getInitials(c.author_name)}
</div>
<span style={{ fontSize: 11, fontWeight: 500, color: "rgba(232,233,240,0.72)" }}>{c.author_name}</span>
<span style={{ fontSize: 10, fontFamily: "monospace", color: "#2dd4bf", marginLeft: "auto" }}>{formatTime(c.timestamp!)}</span>
</div>
<div style={{ fontSize: 11, color: "rgba(232,233,240,0.42)", lineHeight: 1.4 }}>
{c.body.length > 80 ? c.body.slice(0, 80) + "…" : c.body}
</div>
</div>
)}
<div style={{ width: 22, height: 22, borderRadius: "50%", background: mc.bg, border: `2px solid ${mc.border}`, color: mc.text, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 8, fontWeight: 700, boxShadow: "0 2px 8px rgba(0,0,0,0.45)" }}>
{getInitials(c.author_name)}
</div>
<div style={{ width: 1.5, height: 14, background: mc.text, opacity: 0.3, marginTop: 2 }} />
</div>
);
})}
</div>
);
}
// ── Icons ─────────────────────────────────────────────────────────────────────
function IconSkipBack() {
return <svg width="13" height="13" viewBox="0 0 13 13" fill="currentColor"><path d="M6.5 2.5a4 4 0 1 0 3.46 2H8a2.5 2.5 0 1 1-1.5-2.28V4L9.5 2 6.5 0v2.5z" /></svg>;
}
function IconSkipFwd() {
return <svg width="13" height="13" viewBox="0 0 13 13" fill="currentColor" style={{ transform: "scaleX(-1)" }}><path d="M6.5 2.5a4 4 0 1 0 3.46 2H8a2.5 2.5 0 1 1-1.5-2.28V4L9.5 2 6.5 0v2.5z" /></svg>;
}
function IconPlay() {
return <svg width="16" height="16" viewBox="0 0 16 16" fill="white"><path d="M4 2l10 6-10 6V2z" /></svg>;
}
function IconPause() {
return <svg width="16" height="16" viewBox="0 0 16 16" fill="white"><path d="M4 2h3v12H4zm6 0h3v12H10z" /></svg>;
}
// ── PlayerPanel ───────────────────────────────────────────────────────────────
interface PlayerPanelProps {
songId: string;
bandId: string;
/** Called when back button is clicked. If omitted, navigates to /bands/:bandId */
onBack?: () => void;
}
export function PlayerPanel({ songId, bandId, onBack }: PlayerPanelProps) {
const navigate = useNavigate();
const qc = useQueryClient();
const waveformRef = useRef<HTMLDivElement>(null);
const waveformContainerRef = useRef<HTMLDivElement>(null);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
const [commentBody, setCommentBody] = useState("");
const [selectedTag, setSelectedTag] = useState("");
const [composeFocused, setComposeFocused] = useState(false);
const [waveformWidth, setWaveformWidth] = useState(0);
// State resets automatically because BandPage passes key={selectedSongId} to PlayerPanel
// ── Queries ──────────────────────────────────────────────────────────────
const { data: me } = useQuery({ queryKey: ["me"], queryFn: () => api.get<MemberRead>("/auth/me") });
const { data: song } = useQuery({
queryKey: ["song", songId],
queryFn: () => api.get<SongRead>(`/songs/${songId}`),
enabled: !!songId,
});
const { data: versions } = useQuery({
queryKey: ["versions", songId],
queryFn: () => api.get<SongVersion[]>(`/songs/${songId}/versions`),
enabled: !!songId,
});
const { data: session } = useQuery({
queryKey: ["session", song?.session_id],
queryFn: () => api.get<SessionInfo>(`/bands/${bandId}/sessions/${song!.session_id}`),
enabled: !!song?.session_id && !!bandId,
});
const { data: comments } = useQuery<SongComment[]>({
queryKey: ["comments", songId],
queryFn: () => api.get<SongComment[]>(`/songs/${songId}/comments`),
enabled: !!songId,
});
const activeVersion = selectedVersionId ?? versions?.[0]?.id ?? null;
const activeVersionData = versions?.find((v) => v.id === activeVersion) ?? versions?.[0] ?? null;
// ── Waveform ──────────────────────────────────────────────────────────────
const { isPlaying, isReady, currentTime, duration, play, pause, seekTo, error } = useWaveform(waveformRef, {
url: activeVersion ? `/api/v1/versions/${activeVersion}/stream` : null,
peaksUrl: null,
peaks: activeVersionData?.waveform_peaks ?? null,
songId,
bandId,
});
useEffect(() => {
const el = waveformContainerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => setWaveformWidth(entries[0].contentRect.width));
ro.observe(el);
setWaveformWidth(el.offsetWidth);
return () => ro.disconnect();
}, []);
// Space bar shortcut
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === "TEXTAREA" || target.tagName === "INPUT") return;
if (e.code === "Space") { e.preventDefault(); if (isPlaying) pause(); else if (isReady) play(); }
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isPlaying, isReady, play, pause]);
// ── Mutations ─────────────────────────────────────────────────────────────
const addCommentMutation = useMutation({
mutationFn: ({ body, timestamp, tag }: { body: string; timestamp: number; tag: string }) =>
api.post(`/songs/${songId}/comments`, { body, timestamp, tag: tag || null }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["comments", songId] }); setCommentBody(""); setSelectedTag(""); },
});
const deleteCommentMutation = useMutation({
mutationFn: (commentId: string) => api.delete(`/comments/${commentId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", songId] }),
});
const scrollToComment = useCallback((commentId: string) => {
document.getElementById(`comment-${commentId}`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, []);
const handleBack = () => {
if (onBack) onBack();
else navigate(`/bands/${bandId}`);
};
const border = "rgba(255,255,255,0.06)";
// ── Render ────────────────────────────────────────────────────────────────
return (
<div style={{ display: "flex", flexDirection: "column", flex: 1, height: "100%", overflow: "hidden", background: "#080f0d", minWidth: 0 }}>
{/* Breadcrumb / header */}
<div style={{ padding: "11px 20px", borderBottom: `1px solid ${border}`, display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
<button onClick={handleBack}
style={{ background: "none", border: "none", cursor: "pointer", color: "rgba(232,233,240,0.28)", fontSize: 11, padding: 0, fontFamily: "inherit" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.65)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.28)")}
>
Library
</button>
{session && (
<>
<span style={{ color: "rgba(232,233,240,0.2)", fontSize: 11 }}></span>
<button onClick={() => navigate(`/bands/${bandId}/sessions/${session.id}`)}
style={{ background: "none", border: "none", cursor: "pointer", color: "rgba(232,233,240,0.28)", fontSize: 11, padding: 0, fontFamily: "inherit" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.65)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.28)")}
>
{session.date}
</button>
</>
)}
<span style={{ color: "rgba(232,233,240,0.2)", fontSize: 11 }}></span>
<span style={{ fontSize: 12, color: "rgba(232,233,240,0.72)", fontFamily: "monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}>
{song?.title ?? "…"}
</span>
{/* Version selector */}
{versions && versions.length > 1 && (
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
{versions.map((v) => (
<button key={v.id} onClick={() => setSelectedVersionId(v.id)}
style={{ background: v.id === activeVersion ? "rgba(20,184,166,0.12)" : "transparent", border: `1px solid ${v.id === activeVersion ? "rgba(20,184,166,0.3)" : "rgba(255,255,255,0.09)"}`, borderRadius: 6, padding: "4px 10px", color: v.id === activeVersion ? "#2dd4bf" : "rgba(232,233,240,0.38)", cursor: "pointer", fontSize: 11, fontFamily: "monospace", display: "flex", alignItems: "center", gap: 6 }}>
{v.waveform_peaks_mini && (
<MiniWaveform
peaks={v.waveform_peaks_mini}
width={32}
height={16}
color={v.id === activeVersion ? "#2dd4bf" : "rgba(232,233,240,0.25)"}
/>
)}
v{v.version_number}{v.label ? ` · ${v.label}` : ""}
</button>
))}
</div>
)}
<button style={{ background: "transparent", border: `1px solid rgba(255,255,255,0.09)`, borderRadius: 6, color: "rgba(232,233,240,0.38)", cursor: "pointer", fontSize: 12, padding: "5px 12px", fontFamily: "inherit", flexShrink: 0 }}>
Share
</button>
</div>
{/* Body */}
<div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden" }}>
{/* Waveform section */}
<div style={{ padding: "16px 20px", flexShrink: 0 }}>
<div style={{ background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 10, padding: "14px 14px 10px", marginBottom: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 6 }}>
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.45)", fontFamily: "monospace" }}>{song?.title ?? "…"}</span>
<span style={{ fontSize: 10, fontFamily: "monospace", color: "rgba(232,233,240,0.28)" }}>{isReady ? formatTime(duration) : "—"}</span>
</div>
{/* Pin layer + canvas */}
<div ref={waveformContainerRef} style={{ position: "relative" }}>
{isReady && duration > 0 && comments && (
<WaveformPins comments={comments} duration={duration} containerWidth={waveformWidth} onSeek={seekTo} onScrollToComment={scrollToComment} />
)}
{!isReady && <div style={{ height: 44 }} />}
<div ref={waveformRef} />
{error && <div style={{ color: "#f87171", fontSize: 12, padding: "8px 0", textAlign: "center", fontFamily: "monospace" }}>Audio error: {error}</div>}
{!isReady && !error && <div style={{ color: "rgba(232,233,240,0.28)", fontSize: 12, padding: "8px 0", textAlign: "center", fontFamily: "monospace" }}>Loading audio</div>}
</div>
{/* Time bar */}
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, padding: "0 1px" }}>
<span style={{ fontSize: 10, fontFamily: "monospace", color: "#2dd4bf" }}>{formatTime(currentTime)}</span>
<span style={{ fontSize: 10, fontFamily: "monospace", color: "rgba(232,233,240,0.22)" }}>{isReady && duration > 0 ? formatTime(duration / 2) : "—"}</span>
<span style={{ fontSize: 10, fontFamily: "monospace", color: "rgba(232,233,240,0.22)" }}>{isReady ? formatTime(duration) : "—"}</span>
</div>
</div>
{/* Transport */}
<div style={{ display: "flex", justifyContent: "center", gap: 10, padding: "4px 0 4px" }}>
<TransportBtn onClick={() => seekTo(Math.max(0, currentTime - 30))} title="30s"><IconSkipBack /></TransportBtn>
<button
onClick={isPlaying ? pause : play}
disabled={!activeVersion}
style={{ width: 46, height: 46, background: "linear-gradient(135deg, #0d9488, #06b6d4)", borderRadius: "50%", border: "none", display: "flex", alignItems: "center", justifyContent: "center", cursor: activeVersion ? "pointer" : "default", opacity: activeVersion ? 1 : 0.4, flexShrink: 0, transition: "transform 0.12s, box-shadow 0.12s", boxShadow: "0 4px 16px rgba(20,184,166,0.35)" }}
onMouseEnter={(e) => { if (activeVersion) { e.currentTarget.style.boxShadow = "0 6px 24px rgba(20,184,166,0.55)"; e.currentTarget.style.transform = "scale(1.04)"; } }}
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = "0 4px 16px rgba(20,184,166,0.35)"; e.currentTarget.style.transform = "scale(1)"; }}
>
{isPlaying ? <IconPause /> : <IconPlay />}
</button>
<TransportBtn onClick={() => seekTo(currentTime + 30)} title="+30s"><IconSkipFwd /></TransportBtn>
</div>
</div>
{/* Comments section */}
<div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden", borderTop: `1px solid ${border}`, background: "rgba(0,0,0,0.1)" }}>
{/* Header */}
<div style={{ padding: "12px 15px", borderBottom: `1px solid ${border}`, display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: "rgba(232,233,240,0.72)" }}>Comments</span>
{comments && comments.length > 0 && (
<span style={{ fontSize: 11, background: "rgba(20,184,166,0.12)", color: "#2dd4bf", padding: "1px 8px", borderRadius: 10 }}>{comments.length}</span>
)}
</div>
{/* Compose */}
<div style={{ padding: "11px 14px", borderBottom: `1px solid ${border}`, flexShrink: 0 }}>
<div style={{ display: "flex", gap: 9, alignItems: "flex-start" }}>
{me ? (
<Avatar name={me.display_name} avatarUrl={me.avatar_url ?? null} authorId={me.id} size={26} />
) : (
<div style={{ width: 26, height: 26, borderRadius: "50%", background: "rgba(255,255,255,0.06)", flexShrink: 0 }} />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 6 }}>
<div style={{ fontSize: 11, fontFamily: "monospace", background: "rgba(20,184,166,0.1)", color: "#2dd4bf", border: "1px solid rgba(20,184,166,0.22)", padding: "3px 9px", borderRadius: 20, display: "flex", alignItems: "center", gap: 6 }}>
{isPlaying && <div style={{ width: 6, height: 6, borderRadius: "50%", background: "#2dd4bf", animation: "pp-blink 1.1s infinite" }} />}
{formatTime(currentTime)}
</div>
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.2)" }}>· pins to playhead</span>
</div>
<textarea
value={commentBody}
onChange={(e) => setCommentBody(e.target.value)}
onFocus={() => setComposeFocused(true)}
onBlur={() => { if (!commentBody.trim()) setComposeFocused(false); }}
placeholder="What do you hear at this moment…"
style={{ width: "100%", background: "rgba(255,255,255,0.04)", border: composeFocused ? "1px solid rgba(20,184,166,0.35)" : `1px solid rgba(255,255,255,0.07)`, borderRadius: 7, padding: "8px 10px", color: "#e8e9f0", fontSize: 12, resize: "none", outline: "none", fontFamily: "inherit", height: composeFocused ? 68 : 42, transition: "height 0.18s, border-color 0.15s", boxSizing: "border-box" }}
/>
{composeFocused && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 7 }}>
<div style={{ display: "flex", gap: 5 }}>
{(["suggestion", "issue", "keeper"] as const).map((tag) => (
<button key={tag} onClick={() => setSelectedTag((t) => (t === tag ? "" : tag))}
style={{ fontSize: 11, padding: "3px 8px", borderRadius: 4, cursor: "pointer", fontFamily: "inherit", background: selectedTag === tag ? TAG_STYLES[tag].bg : "rgba(255,255,255,0.05)", border: `1px solid ${selectedTag === tag ? TAG_STYLES[tag].color + "44" : "rgba(255,255,255,0.07)"}`, color: selectedTag === tag ? TAG_STYLES[tag].color : "rgba(232,233,240,0.32)", transition: "all 0.12s" }}>
{tag}
</button>
))}
</div>
<button
onClick={() => { if (commentBody.trim()) addCommentMutation.mutate({ body: commentBody.trim(), timestamp: currentTime, tag: selectedTag }); }}
disabled={!commentBody.trim() || addCommentMutation.isPending}
style={{ padding: "5px 14px", borderRadius: 6, background: "linear-gradient(135deg, #0d9488, #06b6d4)", border: "none", color: "white", cursor: commentBody.trim() ? "pointer" : "default", fontSize: 12, fontWeight: 600, fontFamily: "inherit", opacity: commentBody.trim() ? 1 : 0.35, transition: "opacity 0.12s" }}>
Post
</button>
</div>
)}
</div>
</div>
</div>
{/* Comment list */}
<div style={{ flex: 1, overflowY: "auto", padding: "12px 14px" }}>
{comments?.map((c) => {
const tagStyle = c.tag ? TAG_STYLES[c.tag] : null;
const isNearPlayhead = isReady && c.timestamp != null && Math.abs(c.timestamp - currentTime) < 5;
return (
<div key={c.id} id={`comment-${c.id}`}
style={{ marginBottom: 14, paddingBottom: 14, borderBottom: `1px solid rgba(255,255,255,0.04)`, borderRadius: isNearPlayhead ? 6 : undefined, background: isNearPlayhead ? "rgba(20,184,166,0.04)" : undefined, border: isNearPlayhead ? "1px solid rgba(20,184,166,0.12)" : undefined, padding: isNearPlayhead ? 8 : undefined }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 5 }}>
<Avatar name={c.author_name} avatarUrl={c.author_avatar_url} authorId={c.author_id} size={21} />
<span style={{ fontSize: 12, fontWeight: 500, color: "rgba(232,233,240,0.72)" }}>{c.author_name}</span>
{c.timestamp != null && (
<button onClick={() => seekTo(c.timestamp!)}
style={{ marginLeft: "auto", fontSize: 10, fontFamily: "monospace", color: "#2dd4bf", background: "rgba(20,184,166,0.1)", border: "none", borderRadius: 3, padding: "1px 5px", cursor: "pointer" }}
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(20,184,166,0.2)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "rgba(20,184,166,0.1)")}
>
{formatTime(c.timestamp)}
</button>
)}
{tagStyle && <span style={{ fontSize: 10, padding: "1px 5px", borderRadius: 3, background: tagStyle.bg, color: tagStyle.color }}>{c.tag}</span>}
</div>
<p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.45)", lineHeight: 1.55 }}>{c.body}</p>
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.18)", cursor: "pointer" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.5)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.18)")}
> Reply</span>
{me && c.author_id === me.id && (
<button onClick={() => deleteCommentMutation.mutate(c.id)}
style={{ background: "none", border: "none", color: "rgba(232,233,240,0.15)", cursor: "pointer", fontSize: 11, padding: 0, fontFamily: "inherit" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "#f87171")}
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.15)")}
>Delete</button>
)}
</div>
</div>
);
})}
{comments?.length === 0 && <p style={{ color: "rgba(232,233,240,0.22)", fontSize: 12 }}>No comments yet.</p>}
</div>
</div>
</div>
<style>{`
@keyframes pp-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
`}</style>
</div>
);
}

View File

@@ -1,112 +1,87 @@
import { useRef, useState, useEffect } from "react";
import { useState } from "react";
import { useNavigate, useLocation, matchPath } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { listBands } from "../api/bands";
import { api } from "../api/client";
import { logout } from "../api/auth";
import { getInitials } from "../utils";
import type { MemberRead } from "../api/auth";
import { usePlayerStore } from "../stores/playerStore";
import { useBandStore } from "../stores/bandStore";
import { TopBandBar } from "./TopBandBar";
// ── Icons (inline SVG) ──────────────────────────────────────────────────────
function IconWaveform() {
// ── Icons ────────────────────────────────────────────────────────────────────
function IconMenu() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="1" y="1.5" width="12" height="2" rx="1" fill="white" opacity=".9" />
<rect x="1" y="5.5" width="9" height="2" rx="1" fill="white" opacity=".7" />
<rect x="1" y="9.5" width="11" height="2" rx="1" fill="white" opacity=".8" />
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M3 5h12M3 9h12M3 13h8" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function IconLibrary() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<path d="M2 3.5h10v1.5H2zm0 3h10v1.5H2zm0 3h7v1.5H2z" />
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect x="2" y="3.5" width="14" height="2" rx="1" fill="currentColor" />
<rect x="2" y="8" width="14" height="2" rx="1" fill="currentColor" />
<rect x="2" y="12.5" width="14" height="2" rx="1" fill="currentColor" />
</svg>
);
}
function IconPlay() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<path d="M3 2l9 5-9 5V2z" />
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<path d="M5 3l11 6-11 6V3z" />
</svg>
);
}
function IconSettings() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
<circle cx="7" cy="7" r="2" />
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13" />
</svg>
);
}
function IconMembers() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<circle cx="5" cy="4.5" r="2" />
<path d="M1 12c0-2.2 1.8-3.5 4-3.5s4 1.3 4 3.5H1z" />
<circle cx="10.5" cy="4.5" r="1.5" opacity=".6" />
<path d="M10.5 8.5c1.4 0 2.5 1 2.5 2.5H9.5" opacity=".6" />
</svg>
);
}
function IconStorage() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect x="1" y="3" width="12" height="3" rx="1.5" />
<rect x="1" y="8" width="12" height="3" rx="1.5" />
<circle cx="11" cy="4.5" r=".75" fill="#0b0b0e" />
<circle cx="11" cy="9.5" r=".75" fill="#0b0b0e" />
</svg>
);
}
function IconChevron() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5l3 3 3-3" />
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="2.5" stroke="currentColor" strokeWidth="1.4" />
<path d="M9 2v1.5M9 14.5V16M2 9h1.5M14.5 9H16M3.7 3.7l1.06 1.06M13.24 13.24l1.06 1.06M14.3 3.7l-1.06 1.06M4.76 13.24l-1.06 1.06" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
);
}
function IconSignOut() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 2H2.5A1.5 1.5 0 0 0 1 3.5v7A1.5 1.5 0 0 0 2.5 12H5" />
<path d="M9 10l3-3-3-3M12 7H5" />
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M5.5 2.5H3A1.5 1.5 0 0 0 1.5 4v7A1.5 1.5 0 0 0 3 12.5H5.5" />
<path d="M10 10.5l3-3-3-3M13 7.5H6" />
</svg>
);
}
// ── NavItem ─────────────────────────────────────────────────────────────────
// ── NavItem ─────────────────────────────────────────────────────────────────
interface NavItemProps {
icon: React.ReactNode;
label: string;
active: boolean;
onClick: () => void;
disabled?: boolean;
badge?: number;
collapsed: boolean;
}
function NavItem({ icon, label, active, onClick, disabled }: NavItemProps) {
function NavItem({ icon, label, active, onClick, disabled, badge, collapsed }: NavItemProps) {
const [hovered, setHovered] = useState(false);
const color = active
? "#e8a22a"
const fg = active
? "#2dd4bf"
: disabled
? "rgba(255,255,255,0.18)"
? "rgba(255,255,255,0.16)"
: hovered
? "rgba(255,255,255,0.7)"
: "rgba(255,255,255,0.35)";
? "rgba(232,233,240,0.7)"
: "rgba(232,233,240,0.35)";
const bg = active
? "rgba(232,162,42,0.12)"
? "rgba(20,184,166,0.12)"
: hovered && !disabled
? "rgba(255,255,255,0.045)"
? "rgba(255,255,255,0.04)"
: "transparent";
return (
@@ -115,51 +90,69 @@ function NavItem({ icon, label, active, onClick, disabled }: NavItemProps) {
disabled={disabled}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
title={collapsed ? label : undefined}
style={{
display: "flex",
alignItems: "center",
gap: 9,
gap: 10,
width: "100%",
padding: "7px 10px",
borderRadius: 7,
padding: "9px 10px",
borderRadius: 8,
border: "none",
cursor: disabled ? "default" : "pointer",
color,
color: fg,
background: bg,
fontSize: 12,
textAlign: "left",
marginBottom: 1,
transition: "background 0.12s, color 0.12s",
transition: "background 0.15s, color 0.15s",
fontFamily: "inherit",
position: "relative",
overflow: "hidden",
flexShrink: 0,
}}
>
{/* Active indicator */}
{active && (
<div style={{
position: "absolute", left: 0, top: "20%", bottom: "20%",
width: 2, borderRadius: "0 2px 2px 0",
background: "linear-gradient(to bottom, #0d9488, #22d3ee)",
}} />
)}
<span style={{ width: 20, height: 20, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
{icon}
<span>{label}</span>
</span>
{!collapsed && (
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{label}
</span>
)}
{!collapsed && badge != null && badge > 0 && (
<span style={{
fontSize: 9, fontWeight: 700, padding: "2px 6px", borderRadius: 20,
background: "linear-gradient(135deg, #0d9488, #22d3ee)", color: "white",
flexShrink: 0,
}}>
{badge}
</span>
)}
</button>
);
}
// ── Sidebar ────────────────────────────────────────────────────────────────
// ── Sidebar ───────────────────────────────────────────────────────────────────
export function Sidebar({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const location = useLocation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const [collapsed, setCollapsed] = useState(true);
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
const { data: me } = useQuery({
queryKey: ["me"],
queryFn: () => api.get<MemberRead>("/auth/me"),
});
// Derive active band from the current URL
const bandMatch =
matchPath("/bands/:bandId/*", location.pathname) ??
matchPath("/bands/:bandId", location.pathname);
const activeBandId = bandMatch?.params?.bandId ?? null;
const activeBand = bands?.find((b) => b.id === activeBandId) ?? null;
const { activeBandId } = useBandStore();
// Nav active states
const isLibrary = !!(
matchPath({ path: "/bands/:bandId", end: true }, location.pathname) ||
matchPath("/bands/:bandId/sessions/:sessionId", location.pathname) ||
@@ -167,459 +160,121 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
);
const isPlayer = !!matchPath("/bands/:bandId/songs/:songId", location.pathname);
const isSettings = location.pathname.startsWith("/settings");
const isBandSettings = !!matchPath("/bands/:bandId/settings/*", location.pathname);
const bandSettingsPanel = matchPath("/bands/:bandId/settings/:panel", location.pathname)?.params?.panel ?? null;
// Player state
const { currentSongId, currentBandId: playerBandId, isPlaying: isPlayerPlaying } = usePlayerStore();
const hasActiveSong = !!currentSongId && !!playerBandId;
// Close dropdown on outside click
useEffect(() => {
if (!dropdownOpen) return;
function handleClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [dropdownOpen]);
const sidebarWidth = collapsed ? 68 : 230;
const border = "rgba(255,255,255,0.06)";
return (
<div
style={{
display: "flex",
height: "100vh",
overflow: "hidden",
background: "#0f0f12",
color: "#eeeef2",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif",
fontSize: 13,
}}
>
{/* ── Sidebar ──────────────────────────────────────────────────── */}
<aside
style={{
width: 210,
minWidth: 210,
background: "#0b0b0e",
<div style={{ display: "flex", height: "100vh", overflow: "hidden", background: "#080f0d", color: "#e8e9f0", fontFamily: "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif", fontSize: 13 }}>
{/* ── Sidebar ── */}
<aside style={{
width: sidebarWidth,
minWidth: sidebarWidth,
background: "#0c1612",
borderRight: `1px solid ${border}`,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{/* Logo */}
<div
style={{
padding: "17px 14px 14px",
display: "flex",
alignItems: "center",
gap: 10,
borderBottom: `1px solid ${border}`,
transition: "width 0.22s cubic-bezier(0.4,0,0.2,1), min-width 0.22s cubic-bezier(0.4,0,0.2,1)",
flexShrink: 0,
}}
>
<div
zIndex: 20,
}}>
{/* Logo / toggle */}
<div style={{ padding: "18px 14px 14px", display: "flex", alignItems: "center", gap: 10, borderBottom: `1px solid ${border}`, flexShrink: 0 }}>
<button
onClick={() => setCollapsed((c) => !c)}
title={collapsed ? "Expand menu" : "Collapse menu"}
style={{
width: 28,
height: 28,
background: "#e8a22a",
borderRadius: 7,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: 40, height: 40, borderRadius: 12, flexShrink: 0,
background: "linear-gradient(135deg, #0d9488, #06b6d4)",
display: "flex", alignItems: "center", justifyContent: "center",
border: "none", cursor: "pointer",
boxShadow: "0 0 20px rgba(20,184,166,0.3)",
transition: "box-shadow 0.2s",
}}
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = "0 0 30px rgba(20,184,166,0.5)")}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "0 0 20px rgba(20,184,166,0.3)")}
>
<IconWaveform />
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#eeeef2", letterSpacing: -0.2 }}>
<IconMenu />
</button>
{!collapsed && (
<div style={{ overflow: "hidden" }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#e8e9f0", letterSpacing: -0.3, whiteSpace: "nowrap" }}>
RehearsalHub
</div>
{activeBand && (
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.25)", marginTop: 1 }}>
{activeBand.name}
</div>
)}
</div>
</div>
{/* Band switcher */}
<div
ref={dropdownRef}
style={{
padding: "10px 8px",
borderBottom: `1px solid ${border}`,
position: "relative",
flexShrink: 0,
}}
>
<button
onClick={() => setDropdownOpen((o) => !o)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 9px",
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.07)",
borderRadius: 8,
cursor: "pointer",
color: "#eeeef2",
textAlign: "left",
fontFamily: "inherit",
}}
>
<div
style={{
width: 26,
height: 26,
background: "rgba(232,162,42,0.15)",
border: "1px solid rgba(232,162,42,0.3)",
borderRadius: 7,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
fontWeight: 700,
color: "#e8a22a",
flexShrink: 0,
}}
>
{activeBand ? getInitials(activeBand.name) : "?"}
</div>
<span
style={{
flex: 1,
fontSize: 12,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{activeBand?.name ?? "Select a band"}
</span>
<span style={{ opacity: 0.3, flexShrink: 0, display: "flex" }}>
<IconChevron />
</span>
</button>
{dropdownOpen && (
<div
style={{
position: "absolute",
top: "calc(100% - 2px)",
left: 8,
right: 8,
background: "#18181e",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 10,
padding: 6,
zIndex: 100,
boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
}}
>
{bands?.map((band) => (
<button
key={band.id}
onClick={() => {
navigate(`/bands/${band.id}`);
setDropdownOpen(false);
}}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 9px",
marginBottom: 1,
background: band.id === activeBandId ? "rgba(232,162,42,0.08)" : "transparent",
border: "none",
borderRadius: 6,
cursor: "pointer",
color: "#eeeef2",
textAlign: "left",
fontFamily: "inherit",
}}
>
<div
style={{
width: 22,
height: 22,
borderRadius: 5,
background: "rgba(232,162,42,0.15)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 9,
fontWeight: 700,
color: "#e8a22a",
flexShrink: 0,
}}
>
{getInitials(band.name)}
</div>
<span
style={{
flex: 1,
fontSize: 12,
color: "rgba(255,255,255,0.62)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{band.name}
</span>
{band.id === activeBandId && (
<span style={{ fontSize: 10, color: "#e8a22a", flexShrink: 0 }}></span>
)}
</button>
))}
<div
style={{
borderTop: "1px solid rgba(255,255,255,0.06)",
marginTop: 4,
paddingTop: 4,
}}
>
<button
onClick={() => {
navigate("/");
setDropdownOpen(false);
}}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 9px",
background: "transparent",
border: "none",
borderRadius: 6,
cursor: "pointer",
color: "rgba(255,255,255,0.35)",
fontSize: 12,
textAlign: "left",
fontFamily: "inherit",
}}
>
<span style={{ fontSize: 14, opacity: 0.5 }}>+</span>
Create new band
</button>
</div>
</div>
)}
</div>
{/* Navigation */}
<nav style={{ flex: 1, padding: "10px 8px", overflowY: "auto" }}>
{activeBand && (
<nav style={{ flex: 1, padding: "10px 12px", overflowY: "auto", overflowX: "hidden", display: "flex", flexDirection: "column", gap: 2 }}>
{activeBandId && (
<>
<SectionLabel>{activeBand.name}</SectionLabel>
<NavItem
icon={<IconLibrary />}
label="Library"
active={isLibrary}
onClick={() => navigate(`/bands/${activeBand.id}`)}
/>
<NavItem icon={<IconLibrary />} label="Library" active={isLibrary} onClick={() => navigate(`/bands/${activeBandId}`)} collapsed={collapsed} />
<NavItem
icon={<IconPlay />}
label="Player"
label="Now Playing"
active={hasActiveSong && (isPlayer || isPlayerPlaying)}
onClick={() => {
if (hasActiveSong) {
navigate(`/bands/${playerBandId}/songs/${currentSongId}`);
}
}}
onClick={() => { if (hasActiveSong) navigate(`/bands/${playerBandId}/songs/${currentSongId}`); }}
disabled={!hasActiveSong}
collapsed={collapsed}
/>
</>
)}
{activeBand && (
<>
<SectionLabel style={{ paddingTop: 14 }}>Band Settings</SectionLabel>
<NavItem
icon={<IconMembers />}
label="Members"
active={isBandSettings && bandSettingsPanel === "members"}
onClick={() => navigate(`/bands/${activeBand.id}/settings/members`)}
/>
<NavItem
icon={<IconStorage />}
label="Storage"
active={isBandSettings && bandSettingsPanel === "storage"}
onClick={() => navigate(`/bands/${activeBand.id}/settings/storage`)}
/>
<NavItem
icon={<IconSettings />}
label="Band Settings"
active={isBandSettings && bandSettingsPanel === "band"}
onClick={() => navigate(`/bands/${activeBand.id}/settings/band`)}
/>
</>
)}
<SectionLabel style={{ paddingTop: 14 }}>Account</SectionLabel>
<NavItem
icon={<IconSettings />}
label="Settings"
active={isSettings}
onClick={() => navigate("/settings")}
/>
<div style={{ height: 1, background: border, margin: "10px 0", flexShrink: 0 }} />
<NavItem icon={<IconSettings />} label="Settings" active={isSettings} onClick={() => navigate("/settings")} collapsed={collapsed} />
</nav>
{/* User row */}
<div
style={{
padding: "10px",
borderTop: `1px solid ${border}`,
flexShrink: 0,
}}
>
<div style={{ padding: "10px 12px", borderTop: `1px solid ${border}`, flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button
onClick={() => navigate("/settings")}
style={{
flex: 1,
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 8px",
background: "transparent",
border: "none",
borderRadius: 8,
cursor: "pointer",
color: "#eeeef2",
textAlign: "left",
minWidth: 0,
fontFamily: "inherit",
}}
title={collapsed ? (me?.display_name ?? "Account") : undefined}
style={{ flex: 1, display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", background: "transparent", border: "none", borderRadius: 8, cursor: "pointer", color: "#e8e9f0", textAlign: "left", minWidth: 0, fontFamily: "inherit", overflow: "hidden" }}
>
{me?.avatar_url ? (
<img
src={me.avatar_url}
alt=""
style={{
width: 28,
height: 28,
borderRadius: "50%",
objectFit: "cover",
flexShrink: 0,
}}
/>
<img src={me.avatar_url} alt="" style={{ width: 28, height: 28, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} />
) : (
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
background: "rgba(232,162,42,0.18)",
border: "1.5px solid rgba(232,162,42,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
fontWeight: 700,
color: "#e8a22a",
flexShrink: 0,
}}
>
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "rgba(52,211,153,0.15)", border: "1.5px solid rgba(52,211,153,0.3)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 700, color: "#34d399", flexShrink: 0 }}>
{getInitials(me?.display_name ?? "?")}
</div>
)}
<span
style={{
flex: 1,
fontSize: 12,
color: "rgba(255,255,255,0.55)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{!collapsed && (
<span style={{ flex: 1, fontSize: 12, color: "rgba(232,233,240,0.55)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{me?.display_name ?? "…"}
</span>
)}
</button>
{!collapsed && (
<button
onClick={() => logout()}
title="Sign out"
style={{
flexShrink: 0,
width: 30,
height: 30,
borderRadius: 7,
background: "transparent",
border: "1px solid transparent",
cursor: "pointer",
color: "rgba(255,255,255,0.2)",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "border-color 0.12s, color 0.12s",
padding: 0,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "rgba(255,255,255,0.1)";
e.currentTarget.style.color = "rgba(255,255,255,0.5)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "transparent";
e.currentTarget.style.color = "rgba(255,255,255,0.2)";
}}
style={{ flexShrink: 0, width: 30, height: 30, borderRadius: 7, background: "transparent", border: "1px solid transparent", cursor: "pointer", color: "rgba(255,255,255,0.2)", display: "flex", alignItems: "center", justifyContent: "center", transition: "border-color 0.12s, color 0.12s", padding: 0 }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "rgba(255,255,255,0.1)"; e.currentTarget.style.color = "rgba(255,255,255,0.5)"; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "transparent"; e.currentTarget.style.color = "rgba(255,255,255,0.2)"; }}
>
<IconSignOut />
</button>
)}
</div>
</div>
</aside>
{/* ── Main content ──────────────────────────────────────────────── */}
<main
style={{
flex: 1,
overflow: "auto",
display: "flex",
flexDirection: "column",
background: "#0f0f12",
}}
>
{/* ── Main content ── */}
<main style={{ flex: 1, overflow: "hidden", display: "grid", gridTemplateRows: "44px 1fr", background: "#080f0d", minWidth: 0 }}>
<TopBandBar />
<div style={{ overflow: "auto", display: "flex", flexDirection: "column" }}>
{children}
</div>
</main>
</div>
);
}
function SectionLabel({
children,
style,
}: {
children: React.ReactNode;
style?: React.CSSProperties;
}) {
return (
<div
style={{
fontSize: 10,
fontWeight: 500,
color: "rgba(255,255,255,0.2)",
textTransform: "uppercase",
letterSpacing: "0.7px",
padding: "0 6px 5px",
...style,
}}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,297 @@
import { useRef, useState, useEffect } from "react";
import { useNavigate, useLocation, matchPath } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { listBands, createBand } from "../api/bands";
import { getInitials } from "../utils";
import { useBandStore } from "../stores/bandStore";
// ── Shared primitives ──────────────────────────────────────────────────────────
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 11px",
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 7,
color: "#e8e9f0",
fontSize: 13,
fontFamily: "inherit",
outline: "none",
boxSizing: "border-box",
};
const labelStyle: React.CSSProperties = {
display: "block",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.06em",
color: "rgba(232,233,240,0.4)",
marginBottom: 5,
};
// ── Error banner ───────────────────────────────────────────────────────────────
function ErrorBanner({ msg }: { msg: string }) {
return (
<p style={{ margin: "0 0 14px", fontSize: 12, color: "#f87171", background: "rgba(248,113,113,0.08)", border: "1px solid rgba(248,113,113,0.2)", borderRadius: 6, padding: "8px 10px" }}>
{msg}
</p>
);
}
// ── Band creation form ─────────────────────────────────────────────────────────
function BandStep({ onClose }: { onClose: () => void }) {
const navigate = useNavigate();
const qc = useQueryClient();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [error, setError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => { nameRef.current?.focus(); }, []);
const mutation = useMutation({
mutationFn: () => createBand({ name, slug }),
onSuccess: (band) => {
qc.invalidateQueries({ queryKey: ["bands"] });
onClose();
navigate(`/bands/${band.id}`);
},
onError: (err) => setError(err instanceof Error ? err.message : "Failed to create band"),
});
const handleNameChange = (v: string) => {
setName(v);
setSlug(v.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));
};
return (
<>
{error && <ErrorBanner msg={error} />}
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>BAND NAME</label>
<input
ref={nameRef}
value={name}
onChange={(e) => handleNameChange(e.target.value)}
style={inputStyle}
placeholder="e.g. The Midnight Trio"
onKeyDown={(e) => { if (e.key === "Enter" && name && slug) mutation.mutate(); }}
/>
</div>
<div style={{ marginBottom: 24 }}>
<label style={labelStyle}>SLUG</label>
<input
value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))}
style={{ ...inputStyle, fontFamily: "monospace" }}
placeholder="the-midnight-trio"
/>
</div>
<p style={{ margin: "0 0 20px", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
Connect storage after creating the band via Settings Storage.
</p>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
onClick={onClose}
style={{ padding: "8px 16px", background: "transparent", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 7, color: "rgba(232,233,240,0.5)", cursor: "pointer", fontSize: 13, fontFamily: "inherit" }}
>
Cancel
</button>
<button
onClick={() => mutation.mutate()}
disabled={!name || !slug || mutation.isPending}
style={{ padding: "8px 18px", background: !name || !slug ? "rgba(20,184,166,0.3)" : "#14b8a6", border: "none", borderRadius: 7, color: !name || !slug ? "rgba(255,255,255,0.4)" : "#fff", cursor: !name || !slug ? "default" : "pointer", fontSize: 13, fontWeight: 600, fontFamily: "inherit" }}
>
{mutation.isPending ? "Creating…" : "Create Band"}
</button>
</div>
</>
);
}
// ── Create Band Modal ──────────────────────────────────────────────────────────
function CreateBandModal({ onClose }: { onClose: () => void }) {
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
return (
<div
onClick={onClose}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center" }}
>
<div
onClick={(e) => e.stopPropagation()}
style={{ background: "#112018", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 14, padding: 28, width: 420, boxShadow: "0 24px 64px rgba(0,0,0,0.6)" }}
>
<div style={{ marginBottom: 18 }}>
<h3 style={{ margin: "0 0 3px", fontSize: 15, fontWeight: 600, color: "#e8e9f0" }}>New band</h3>
<p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.4)" }}>
Create a workspace for your recordings.
</p>
</div>
<BandStep onClose={onClose} />
</div>
</div>
);
}
// ── TopBandBar ─────────────────────────────────────────────────────────────────
export function TopBandBar() {
const navigate = useNavigate();
const location = useLocation();
const [open, setOpen] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
const { activeBandId, setActiveBandId } = useBandStore();
// Sync store from URL when on a band page
const urlMatch =
matchPath("/bands/:bandId/*", location.pathname) ??
matchPath("/bands/:bandId", location.pathname);
const urlBandId = urlMatch?.params?.bandId ?? null;
useEffect(() => {
if (urlBandId) setActiveBandId(urlBandId);
}, [urlBandId, setActiveBandId]);
const currentBandId = urlBandId ?? activeBandId;
const activeBand = bands?.find((b) => b.id === currentBandId) ?? null;
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const border = "rgba(255,255,255,0.06)";
return (
<>
{showCreate && <CreateBandModal onClose={() => setShowCreate(false)} />}
<div style={{
height: 44,
flexShrink: 0,
display: "flex",
alignItems: "center",
gap: 8,
padding: "0 20px",
borderBottom: `1px solid ${border}`,
background: "#0c1612",
zIndex: 10,
}}>
{/* Band switcher */}
<div ref={ref} style={{ position: "relative" }}>
<button
onClick={() => setOpen((o) => !o)}
style={{
display: "flex", alignItems: "center", gap: 8,
padding: "5px 10px",
background: open ? "rgba(255,255,255,0.06)" : "transparent",
border: `1px solid ${open ? "rgba(255,255,255,0.12)" : "transparent"}`,
borderRadius: 8,
cursor: "pointer", color: "#e8e9f0",
fontFamily: "inherit", fontSize: 13,
transition: "background 0.12s, border-color 0.12s",
}}
onMouseEnter={(e) => { if (!open) e.currentTarget.style.background = "rgba(255,255,255,0.04)"; }}
onMouseLeave={(e) => { if (!open) e.currentTarget.style.background = "transparent"; }}
>
{activeBand ? (
<>
<div style={{
width: 22, height: 22, borderRadius: 6, flexShrink: 0,
background: "rgba(20,184,166,0.15)",
border: "1px solid rgba(20,184,166,0.3)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 9, fontWeight: 800, color: "#2dd4bf",
}}>
{getInitials(activeBand.name)}
</div>
<span style={{ fontWeight: 600, fontSize: 13 }}>{activeBand.name}</span>
</>
) : (
<span style={{ color: "rgba(232,233,240,0.35)", fontSize: 13 }}>Select a band</span>
)}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={{ color: "rgba(232,233,240,0.3)", marginLeft: 2 }}>
<path d="M3 5l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<div style={{
position: "absolute", top: "calc(100% + 6px)", left: 0,
minWidth: 220,
background: "#142420",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 10, padding: 6, zIndex: 100,
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
}}>
{bands?.map((band) => (
<button
key={band.id}
onClick={() => {
setActiveBandId(band.id);
navigate(`/bands/${band.id}`);
setOpen(false);
}}
style={{
width: "100%", display: "flex", alignItems: "center", gap: 8,
padding: "7px 9px", marginBottom: 1,
background: band.id === currentBandId ? "rgba(20,184,166,0.1)" : "transparent",
border: "none", borderRadius: 6,
cursor: "pointer", color: "#e8e9f0",
textAlign: "left", fontFamily: "inherit",
transition: "background 0.12s",
}}
onMouseEnter={(e) => { if (band.id !== currentBandId) e.currentTarget.style.background = "rgba(255,255,255,0.04)"; }}
onMouseLeave={(e) => { if (band.id !== currentBandId) e.currentTarget.style.background = "transparent"; }}
>
<div style={{ width: 22, height: 22, borderRadius: 6, background: "rgba(20,184,166,0.15)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 9, fontWeight: 700, color: "#2dd4bf", flexShrink: 0 }}>
{getInitials(band.name)}
</div>
<span style={{ flex: 1, fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{band.name}
</span>
{band.id === currentBandId && (
<span style={{ fontSize: 10, color: "#2dd4bf", flexShrink: 0 }}></span>
)}
</button>
))}
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", marginTop: 4, paddingTop: 4 }}>
<button
onClick={() => { setOpen(false); setShowCreate(true); }}
style={{ width: "100%", display: "flex", alignItems: "center", gap: 8, padding: "7px 9px", background: "transparent", border: "none", borderRadius: 6, cursor: "pointer", color: "rgba(232,233,240,0.35)", fontSize: 12, textAlign: "left", fontFamily: "inherit" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.7)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "rgba(232,233,240,0.35)")}
>
<span style={{ fontSize: 14, opacity: 0.6 }}>+</span>
New band
</button>
</div>
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { RefObject } from "react";
// ── Hoist mocks so they're available in vi.mock factories ─────────────────────
const { audioServiceMock } = vi.hoisted(() => ({
audioServiceMock: {
initialize: vi.fn().mockResolvedValue(undefined),
play: vi.fn().mockResolvedValue(undefined),
pause: vi.fn(),
seekTo: vi.fn(),
getDuration: vi.fn(() => 0),
isWaveformReady: vi.fn(() => false),
},
}));
vi.mock("../services/audioService", () => ({
audioService: audioServiceMock,
}));
vi.mock("../stores/playerStore", () => ({
usePlayerStore: vi.fn((selector: (s: unknown) => unknown) =>
selector({ isPlaying: false, currentTime: 0, duration: 0 })
),
}));
// ── Import after mocks ─────────────────────────────────────────────────────────
import { useWaveform } from "./useWaveform";
// ── Tests ──────────────────────────────────────────────────────────────────────
describe("useWaveform", () => {
beforeEach(() => {
vi.clearAllMocks();
audioServiceMock.initialize.mockResolvedValue(undefined);
});
it("forwards peaks to audioService.initialize when provided", async () => {
const containerRef: RefObject<HTMLDivElement> = {
current: document.createElement("div"),
};
const peaks = Array.from({ length: 500 }, (_, i) => i / 500);
renderHook(() =>
useWaveform(containerRef, {
url: "http://localhost/song.mp3",
peaksUrl: null,
peaks,
songId: "song-1",
bandId: "band-1",
})
);
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(audioServiceMock.initialize).toHaveBeenCalledOnce();
const [, , passedPeaks] = audioServiceMock.initialize.mock.calls[0];
expect(passedPeaks).toEqual(peaks);
});
it("passes undefined when no peaks provided", async () => {
const containerRef: RefObject<HTMLDivElement> = {
current: document.createElement("div"),
};
renderHook(() =>
useWaveform(containerRef, {
url: "http://localhost/song.mp3",
peaksUrl: null,
songId: "song-1",
bandId: "band-1",
})
);
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(audioServiceMock.initialize).toHaveBeenCalledOnce();
const [, , passedPeaks] = audioServiceMock.initialize.mock.calls[0];
expect(passedPeaks).toBeUndefined();
});
});

View File

@@ -5,6 +5,7 @@ import { usePlayerStore } from "../stores/playerStore";
export interface UseWaveformOptions {
url: string | null;
peaksUrl: string | null;
peaks?: number[] | null;
onReady?: (duration: number) => void;
onTimeUpdate?: (currentTime: number) => void;
songId?: string | null;
@@ -39,7 +40,7 @@ export function useWaveform(
const initializeAudio = async () => {
try {
await audioService.initialize(containerRef.current!, options.url!);
await audioService.initialize(containerRef.current!, options.url!, options.peaks ?? undefined);
// Restore playback if this song was already playing when the page loaded.
// Read as a one-time snapshot — these values must NOT be reactive deps or
@@ -77,7 +78,12 @@ export function useWaveform(
};
initializeAudio();
}, [options.url, options.songId, options.bandId]);
// containerRef is a stable ref object — safe to include.
// options.onReady is intentionally omitted: it's a callback that callers
// may not memoize, and re-running initialization on every render would be
// worse than stale-closing over it for the brief window after mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options.url, options.songId, options.bandId, containerRef]);
const play = () => {
audioService.play(options.songId ?? null, options.bandId ?? null)

View File

@@ -16,23 +16,30 @@ input, textarea, button, select {
/* ── Design system (dark only — no light mode in v1) ─────────────────────── */
:root {
--bg: #0f0f12;
/* v2 dark-teal palette */
--bg: #080f0d;
--bg-card: #0c1612;
--bg-raised: #101c18;
--bg-hover: #142420;
--bg-subtle: rgba(255,255,255,0.025);
--bg-inset: rgba(255,255,255,0.04);
--border: rgba(255,255,255,0.08);
--border-subtle: rgba(255,255,255,0.05);
--text: #eeeef2;
--text-muted: rgba(255,255,255,0.35);
--text-subtle: rgba(255,255,255,0.22);
--accent: #e8a22a;
--accent-hover: #f0b740;
--accent-bg: rgba(232,162,42,0.1);
--accent-border: rgba(232,162,42,0.28);
--accent-fg: #0f0f12;
--teal: #4dba85;
--teal-bg: rgba(61,200,120,0.1);
--danger: #e07070;
--danger-bg: rgba(220,80,80,0.1);
--border: rgba(255,255,255,0.06);
--border-bright: rgba(255,255,255,0.12);
--border-subtle: rgba(255,255,255,0.04);
--text: #e8e9f0;
--text-muted: rgba(232,233,240,0.55);
--text-subtle: rgba(232,233,240,0.28);
/* Teal accent */
--accent: #14b8a6;
--accent-light: #2dd4bf;
--accent-hover: #10a89a;
--accent-bg: rgba(20,184,166,0.12);
--accent-border: rgba(20,184,166,0.3);
--accent-fg: #ffffff;
--teal: #34d399;
--teal-bg: rgba(52,211,153,0.1);
--danger: #f43f5e;
--danger-bg: rgba(244,63,94,0.1);
}
/* ── Responsive Layout ──────────────────────────────────────────────────── */
@@ -46,7 +53,7 @@ input, textarea, button, select {
/* Bottom Navigation Bar */
nav[style*="position: fixed"] {
display: flex;
background: #0b0b0e;
background: #060d0b;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 8px 16px;
}

View File

@@ -1,50 +1,16 @@
import { useState, useMemo } from "react";
import { useParams, Link } from "react-router-dom";
import { useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getBand } from "../api/bands";
import { api } from "../api/client";
import { LibraryPanel } from "../components/LibraryPanel";
import { PlayerPanel } from "../components/PlayerPanel";
interface SongSummary {
id: string;
title: string;
status: string;
tags: string[];
global_key: string | null;
global_bpm: number | null;
version_count: number;
}
interface SessionSummary {
id: string;
date: string;
label: string | null;
recording_count: number;
}
type FilterPill = "all" | "full band" | "guitar" | "vocals" | "drums" | "keys" | "commented";
const PILLS: FilterPill[] = ["all", "full band", "guitar", "vocals", "drums", "keys", "commented"];
function formatDate(iso: string): string {
const d = new Date(iso.slice(0, 10) + "T12:00:00");
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
function formatDateLabel(iso: string): string {
const d = new Date(iso.slice(0, 10) + "T12:00:00");
const today = new Date();
today.setHours(12, 0, 0, 0);
const diffDays = Math.round((today.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return "Today — " + d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
// ── BandPage ──────────────────────────────────────────────────────────────────
export function BandPage() {
const { bandId } = useParams<{ bandId: string }>();
const [librarySearch, setLibrarySearch] = useState("");
const [activePill, setActivePill] = useState<FilterPill>("all");
const [searchParams, setSearchParams] = useSearchParams();
// selectedSongId is kept in URL as ?song=<id> so deep-links and browser back work
const selectedSongId = searchParams.get("song");
const { data: band, isLoading } = useQuery({
queryKey: ["band", bandId],
@@ -52,239 +18,37 @@ export function BandPage() {
enabled: !!bandId,
});
const { data: sessions } = useQuery({
queryKey: ["sessions", bandId],
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
enabled: !!bandId,
});
function selectSong(songId: string) {
setSearchParams({ song: songId }, { replace: false });
}
const { data: unattributedSongs } = useQuery({
queryKey: ["songs-unattributed", bandId],
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?unattributed=true`),
enabled: !!bandId,
});
function clearSong() {
setSearchParams({}, { replace: false });
}
const filteredSessions = useMemo(() => {
return (sessions ?? []).filter((s) => {
if (!librarySearch) return true;
const haystack = [s.label ?? "", s.date, formatDate(s.date)].join(" ").toLowerCase();
return haystack.includes(librarySearch.toLowerCase());
});
}, [sessions, librarySearch]);
const filteredUnattributed = useMemo(() => {
return (unattributedSongs ?? []).filter((song) => {
const matchesSearch =
!librarySearch || song.title.toLowerCase().includes(librarySearch.toLowerCase());
const matchesPill =
activePill === "all" ||
activePill === "commented" ||
song.tags.some((t) => t.toLowerCase() === activePill);
return matchesSearch && matchesPill;
});
}, [unattributedSongs, librarySearch, activePill]);
if (isLoading) return <div style={{ color: "var(--text-muted)", padding: 32 }}>Loading...</div>;
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
const hasResults = filteredSessions.length > 0 || filteredUnattributed.length > 0;
if (isLoading) return <div style={{ color: "rgba(232,233,240,0.35)", padding: 32 }}>Loading</div>;
if (!band || !bandId) return <div style={{ color: "#f87171", padding: 32 }}>Band not found</div>;
if (selectedSongId) {
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", maxWidth: 760, margin: "0 auto" }}>
{/* ── Header ─────────────────────────────────────────────── */}
<div style={{ padding: "18px 26px 0", flexShrink: 0, borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
{/* Title row + search + actions */}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 11 }}>
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: 0, flexShrink: 0 }}>
Library
</h1>
{/* Search input */}
<div style={{ position: "relative", flex: 1, maxWidth: 280 }}>
<svg
style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", opacity: 0.3, pointerEvents: "none", color: "#eeeef2" }}
width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.5"
>
<circle cx="5.5" cy="5.5" r="3.5" />
<path d="M8.5 8.5l3 3" strokeLinecap="round" />
</svg>
<input
value={librarySearch}
onChange={(e) => setLibrarySearch(e.target.value)}
placeholder="Search recordings, comments…"
style={{
width: "100%",
padding: "7px 12px 7px 30px",
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 7,
color: "#e2e2e8",
fontSize: 13,
fontFamily: "inherit",
outline: "none",
boxSizing: "border-box",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "rgba(232,162,42,0.35)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "rgba(255,255,255,0.08)")}
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
<PlayerPanel
key={selectedSongId}
songId={selectedSongId}
bandId={bandId}
onBack={clearSong}
/>
</div>
</div>
{/* Filter pills */}
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", paddingBottom: 14 }}>
{PILLS.map((pill) => {
const active = activePill === pill;
return (
<button
key={pill}
onClick={() => setActivePill(pill)}
style={{
padding: "3px 10px",
borderRadius: 20,
cursor: "pointer",
border: `1px solid ${active ? "rgba(232,162,42,0.28)" : "rgba(255,255,255,0.08)"}`,
background: active ? "rgba(232,162,42,0.1)" : "transparent",
color: active ? "#e8a22a" : "rgba(255,255,255,0.3)",
fontSize: 11,
fontFamily: "inherit",
transition: "all 0.12s",
textTransform: "capitalize",
}}
>
{pill}
</button>
);
})}
</div>
</div>
{/* ── Scrollable content ────────────────────────────────── */}
<div style={{ flex: 1, overflowY: "auto", padding: "4px 26px 26px" }}>
{/* Sessions — one date group per session */}
{filteredSessions.map((s) => (
<div key={s.id} style={{ marginTop: 18 }}>
{/* Date group header */}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span style={{ fontSize: 10, fontWeight: 500, color: "rgba(255,255,255,0.32)", textTransform: "uppercase", letterSpacing: "0.6px", whiteSpace: "nowrap" }}>
{formatDateLabel(s.date)}{s.label ? `${s.label}` : ""}
</span>
<div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
{s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
</span>
</div>
{/* Session row */}
<Link
to={`/bands/${bandId}/sessions/${s.id}`}
style={{
display: "flex",
alignItems: "center",
gap: 11,
padding: "9px 13px",
borderRadius: 8,
background: "rgba(255,255,255,0.02)",
border: "1px solid rgba(255,255,255,0.04)",
textDecoration: "none",
cursor: "pointer",
transition: "background 0.12s, border-color 0.12s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)";
}}
>
{/* Session name */}
<span style={{ flex: 1, fontSize: 13, color: "#c8c8d0", fontFamily: "'SF Mono','Fira Code',monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.label ?? formatDate(s.date)}
</span>
{/* Recording count */}
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
{s.recording_count}
</span>
</Link>
</div>
))}
{/* Unattributed recordings */}
{filteredUnattributed.length > 0 && (
<div style={{ marginTop: filteredSessions.length > 0 ? 28 : 18 }}>
{/* Section header */}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span style={{ fontSize: 10, fontWeight: 500, color: "rgba(255,255,255,0.32)", textTransform: "uppercase", letterSpacing: "0.6px", whiteSpace: "nowrap" }}>
Unattributed
</span>
<div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
{filteredUnattributed.length} track{filteredUnattributed.length !== 1 ? "s" : ""}
</span>
</div>
<div style={{ display: "grid", gap: 3 }}>
{filteredUnattributed.map((song) => (
<Link
key={song.id}
to={`/bands/${bandId}/songs/${song.id}`}
style={{ display: "flex", alignItems: "center", gap: 11, padding: "9px 13px", borderRadius: 8, background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.04)", textDecoration: "none", transition: "background 0.12s, border-color 0.12s" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)";
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "#c8c8d0", fontFamily: "'SF Mono','Fira Code',monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", marginBottom: 3 }}>
{song.title}
</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{song.tags.map((t) => (
<span key={t} style={{ background: "rgba(61,200,120,0.08)", color: "#4dba85", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
{t}
</span>
))}
{song.global_key && (
<span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
{song.global_key}
</span>
)}
{song.global_bpm && (
<span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
{song.global_bpm.toFixed(0)} BPM
</span>
)}
</div>
</div>
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
{song.version_count} ver{song.version_count !== 1 ? "s" : ""}
</span>
</Link>
))}
</div>
</div>
)}
{/* Empty state */}
{!hasResults && (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13, padding: "24px 0 8px" }}>
{librarySearch
? "No results match your search."
: "No sessions yet. Go to Storage settings to scan your Nextcloud folder."}
</p>
)}
</div>
</div>
);
}
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
<LibraryPanel
bandId={bandId}
selectedSongId={selectedSongId}
onSelectSong={selectSong}
/>
</div>
);
}

View File

@@ -1,320 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { screen, fireEvent, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../test/helpers";
import { BandSettingsPage } from "./BandSettingsPage";
// ── Shared fixtures ───────────────────────────────────────────────────────────
const ME = { id: "m-me", email: "s@example.com", display_name: "Steffen", avatar_url: null, created_at: "" };
const BAND = {
id: "band-1",
name: "Loud Hands",
slug: "loud-hands",
genre_tags: ["post-rock", "math-rock"],
nc_folder_path: "bands/loud-hands/",
};
const MEMBERS_ADMIN = [
{ id: "m-me", display_name: "Steffen", email: "s@example.com", role: "admin", joined_at: "" },
{ id: "m-2", display_name: "Alex", email: "a@example.com", role: "member", joined_at: "" },
];
const MEMBERS_NON_ADMIN = [
{ id: "m-me", display_name: "Steffen", email: "s@example.com", role: "member", joined_at: "" },
{ id: "m-2", display_name: "Alex", email: "a@example.com", role: "admin", joined_at: "" },
];
const INVITES_RESPONSE = {
invites: [
{
id: "inv-1",
token: "abcdef1234567890abcd",
role: "member",
expires_at: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
is_used: false,
band_id: "band-1",
created_at: new Date().toISOString(),
used_at: null,
},
],
total: 1,
pending: 1,
};
// ── Mocks ─────────────────────────────────────────────────────────────────────
const {
mockGetBand,
mockApiGet,
mockApiPost,
mockApiPatch,
mockApiDelete,
mockListInvites,
mockRevokeInvite,
} = vi.hoisted(() => ({
mockGetBand: vi.fn(),
mockApiGet: vi.fn(),
mockApiPost: vi.fn(),
mockApiPatch: vi.fn(),
mockApiDelete: vi.fn(),
mockListInvites: vi.fn(),
mockRevokeInvite: vi.fn(),
}));
vi.mock("../api/bands", () => ({ getBand: mockGetBand }));
vi.mock("../api/invites", () => ({
listInvites: mockListInvites,
revokeInvite: mockRevokeInvite,
}));
vi.mock("../api/client", () => ({
api: {
get: mockApiGet,
post: mockApiPost,
patch: mockApiPatch,
delete: mockApiDelete,
},
isLoggedIn: vi.fn().mockReturnValue(true),
}));
// ── Default mock implementations ─────────────────────────────────────────────
afterEach(() => vi.clearAllMocks());
function setupApiGet(members: typeof MEMBERS_ADMIN) {
mockApiGet.mockImplementation((url: string) => {
if (url === "/auth/me") return Promise.resolve(ME);
if (url.includes("/members")) return Promise.resolve(members);
return Promise.resolve([]);
});
}
beforeEach(() => {
mockGetBand.mockResolvedValue(BAND);
setupApiGet(MEMBERS_ADMIN);
mockApiPost.mockResolvedValue({ id: "inv-new", token: "newtoken123", role: "member", expires_at: "" });
mockApiPatch.mockResolvedValue(BAND);
mockApiDelete.mockResolvedValue({});
mockListInvites.mockResolvedValue(INVITES_RESPONSE);
mockRevokeInvite.mockResolvedValue({});
});
// ── Render helpers ────────────────────────────────────────────────────────────
function renderPanel(panel: "members" | "storage" | "band", members = MEMBERS_ADMIN) {
setupApiGet(members);
return renderWithProviders(<BandSettingsPage />, {
path: "/bands/:bandId/settings/:panel",
route: `/bands/band-1/settings/${panel}`,
});
}
// ── Routing ───────────────────────────────────────────────────────────────────
describe("BandSettingsPage — routing (TC-15 to TC-17)", () => {
it("TC-15: renders Storage panel for /settings/storage", async () => {
renderPanel("storage");
const heading = await screen.findByRole("heading", { name: /storage/i });
expect(heading).toBeTruthy();
});
it("TC-16: renders Band Settings panel for /settings/band", async () => {
renderPanel("band");
const heading = await screen.findByRole("heading", { name: /band settings/i });
expect(heading).toBeTruthy();
});
it("TC-17: unknown panel falls back to Members", async () => {
mockApiGet.mockResolvedValue(MEMBERS_ADMIN);
renderWithProviders(<BandSettingsPage />, {
path: "/bands/:bandId/settings/:panel",
route: "/bands/band-1/settings/unknown-panel",
});
const heading = await screen.findByRole("heading", { name: /members/i });
expect(heading).toBeTruthy();
});
});
// ── Members panel — access control ───────────────────────────────────────────
describe("BandSettingsPage — Members panel access control (TC-18 to TC-23)", () => {
it("TC-18: admin sees + Invite button", async () => {
renderPanel("members", MEMBERS_ADMIN);
const btn = await screen.findByText(/\+ invite/i);
expect(btn).toBeTruthy();
});
it("TC-19: non-admin does not see + Invite button", async () => {
renderPanel("members", MEMBERS_NON_ADMIN);
await screen.findByText("Alex"); // wait for members to load
expect(screen.queryByText(/\+ invite/i)).toBeNull();
});
it("TC-20: admin sees Remove button on non-admin members", async () => {
renderPanel("members", MEMBERS_ADMIN);
const removeBtn = await screen.findByText("Remove");
expect(removeBtn).toBeTruthy();
});
it("TC-21: non-admin does not see any Remove button", async () => {
renderPanel("members", MEMBERS_NON_ADMIN);
await screen.findByText("Alex");
expect(screen.queryByText("Remove")).toBeNull();
});
it("TC-22: admin does not see Remove on admin-role members", async () => {
renderPanel("members", MEMBERS_ADMIN);
await screen.findByText("Steffen");
// Only one Remove button — for Alex (member), not Steffen (admin)
const removeBtns = screen.queryAllByText("Remove");
expect(removeBtns).toHaveLength(1);
});
it("TC-23: Pending Invites section hidden from non-admins", async () => {
renderPanel("members", MEMBERS_NON_ADMIN);
await screen.findByText("Alex");
expect(screen.queryByText(/pending invites/i)).toBeNull();
});
});
// ── Members panel — functionality ─────────────────────────────────────────────
describe("BandSettingsPage — Members panel functionality (TC-24 to TC-28)", () => {
it("TC-24: generate invite shows link in UI", async () => {
const token = "tok123abc456def789gh";
mockApiPost.mockResolvedValue({ id: "inv-new", token, role: "member", expires_at: "" });
renderPanel("members", MEMBERS_ADMIN);
const inviteBtn = await screen.findByText(/\+ invite/i);
fireEvent.click(inviteBtn);
const linkEl = await screen.findByText(new RegExp(token));
expect(linkEl).toBeTruthy();
});
it("TC-26: remove member calls DELETE endpoint", async () => {
renderPanel("members", MEMBERS_ADMIN);
const removeBtn = await screen.findByText("Remove");
fireEvent.click(removeBtn);
await waitFor(() => {
expect(mockApiDelete).toHaveBeenCalledWith("/bands/band-1/members/m-2");
});
});
it("TC-27: revoke invite calls revokeInvite and refetches", async () => {
renderPanel("members", MEMBERS_ADMIN);
const revokeBtn = await screen.findByText("Revoke");
fireEvent.click(revokeBtn);
await waitFor(() => {
expect(mockRevokeInvite).toHaveBeenCalledWith("inv-1");
});
});
});
// ── Storage panel — access control ───────────────────────────────────────────
describe("BandSettingsPage — Storage panel access control (TC-29 to TC-33)", () => {
it("TC-29: admin sees Edit button", async () => {
renderPanel("storage", MEMBERS_ADMIN);
const edit = await screen.findByText("Edit");
expect(edit).toBeTruthy();
});
it("TC-30: non-admin does not see Edit button", async () => {
renderPanel("storage", MEMBERS_NON_ADMIN);
await screen.findByText(/scan path/i);
expect(screen.queryByText("Edit")).toBeNull();
});
it("TC-31: saving NC folder path calls PATCH and closes form", async () => {
renderPanel("storage", MEMBERS_ADMIN);
fireEvent.click(await screen.findByText("Edit"));
const input = screen.getByPlaceholderText(/bands\/loud-hands\//i);
fireEvent.change(input, { target: { value: "bands/custom-path/" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockApiPatch).toHaveBeenCalledWith(
"/bands/band-1",
{ nc_folder_path: "bands/custom-path/" }
);
});
});
it("TC-32: cancel edit closes form without calling PATCH", async () => {
renderPanel("storage", MEMBERS_ADMIN);
fireEvent.click(await screen.findByText("Edit"));
fireEvent.click(screen.getByText("Cancel"));
await waitFor(() => {
expect(mockApiPatch).not.toHaveBeenCalled();
});
expect(screen.queryByText("Save")).toBeNull();
});
it("TC-33: shows default path when nc_folder_path is null", async () => {
mockGetBand.mockResolvedValueOnce({ ...BAND, nc_folder_path: null });
renderPanel("storage", MEMBERS_ADMIN);
const path = await screen.findByText("bands/loud-hands/");
expect(path).toBeTruthy();
});
});
// ── Band settings panel — access control ──────────────────────────────────────
describe("BandSettingsPage — Band Settings panel access control (TC-34 to TC-40)", () => {
it("TC-34: admin sees Save changes button", async () => {
renderPanel("band", MEMBERS_ADMIN);
const btn = await screen.findByText(/save changes/i);
expect(btn).toBeTruthy();
});
it("TC-35: non-admin sees info text instead of Save button", async () => {
renderPanel("band", MEMBERS_NON_ADMIN);
// Wait for the band panel heading so we know the page has fully loaded
await screen.findByRole("heading", { name: /band settings/i });
// Once queries settle, the BandPanel-level info text should appear and Save should be absent
await waitFor(() => {
expect(screen.getByText(/only admins can edit band settings/i)).toBeTruthy();
});
expect(screen.queryByText(/save changes/i)).toBeNull();
});
it("TC-36: name field is disabled for non-admins", async () => {
renderPanel("band", MEMBERS_NON_ADMIN);
const input = await screen.findByDisplayValue("Loud Hands");
expect((input as HTMLInputElement).disabled).toBe(true);
});
it("TC-37: saving calls PATCH with name and genre_tags", async () => {
renderPanel("band", MEMBERS_ADMIN);
await screen.findByText(/save changes/i);
fireEvent.click(screen.getByText(/save changes/i));
await waitFor(() => {
expect(mockApiPatch).toHaveBeenCalledWith("/bands/band-1", {
name: "Loud Hands",
genre_tags: ["post-rock", "math-rock"],
});
});
});
it("TC-38: adding a genre tag shows the new pill", async () => {
renderPanel("band", MEMBERS_ADMIN);
const tagInput = await screen.findByPlaceholderText(/add genre tag/i);
fireEvent.change(tagInput, { target: { value: "punk" } });
fireEvent.keyDown(tagInput, { key: "Enter" });
expect(screen.getByText("punk")).toBeTruthy();
});
it("TC-39: removing a genre tag removes its pill", async () => {
renderPanel("band", MEMBERS_ADMIN);
// Find the × button next to "post-rock"
await screen.findByText("post-rock");
// There are two tags; find the × buttons
const removeButtons = screen.getAllByText("×");
fireEvent.click(removeButtons[0]);
expect(screen.queryByText("post-rock")).toBeNull();
});
it("TC-40: Delete band button is disabled for non-admins", async () => {
renderPanel("band", MEMBERS_NON_ADMIN);
const deleteBtn = await screen.findByText(/delete band/i);
expect((deleteBtn as HTMLButtonElement).disabled).toBe(true);
});
});

View File

@@ -1,961 +0,0 @@
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getBand } from "../api/bands";
import { api } from "../api/client";
import { listInvites, revokeInvite } from "../api/invites";
import type { MemberRead } from "../api/auth";
// ── Types ─────────────────────────────────────────────────────────────────────
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 | null;
is_used: boolean;
}
type Panel = "members" | "storage" | "band";
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatExpiry(expiresAt: string | null | undefined): string {
if (!expiresAt) return "No expiry";
const date = new Date(expiresAt);
const diffHours = Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60));
if (diffHours <= 0) return "Expired";
if (diffHours < 24) return `Expires in ${diffHours}h`;
return `Expires in ${Math.floor(diffHours / 24)}d`;
}
function isActive(invite: BandInvite): boolean {
return !invite.is_used && !!invite.expires_at && new Date(invite.expires_at) > new Date();
}
// ── Panel nav item ────────────────────────────────────────────────────────────
function PanelNavItem({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
const [hovered, setHovered] = useState(false);
return (
<button
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
width: "100%",
textAlign: "left",
padding: "7px 10px",
borderRadius: 7,
border: "none",
cursor: "pointer",
fontSize: 12,
fontFamily: "inherit",
marginBottom: 1,
background: active
? "rgba(232,162,42,0.1)"
: hovered
? "rgba(255,255,255,0.04)"
: "transparent",
color: active
? "#e8a22a"
: hovered
? "rgba(255,255,255,0.65)"
: "rgba(255,255,255,0.35)",
transition: "background 0.12s, color 0.12s",
}}
>
{label}
</button>
);
}
// ── Section title ─────────────────────────────────────────────────────────────
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div
style={{
fontSize: 11,
fontWeight: 500,
color: "rgba(255,255,255,0.28)",
textTransform: "uppercase",
letterSpacing: "0.7px",
marginBottom: 12,
}}
>
{children}
</div>
);
}
function Divider() {
return <div style={{ height: 1, background: "rgba(255,255,255,0.05)", margin: "20px 0" }} />;
}
// ── Members panel ─────────────────────────────────────────────────────────────
function MembersPanel({
bandId,
amAdmin,
members,
membersLoading,
}: {
bandId: string;
amAdmin: boolean;
members: BandMember[] | undefined;
membersLoading: boolean;
}) {
const qc = useQueryClient();
const [inviteLink, setInviteLink] = useState<string | null>(null);
const { data: invitesData, isLoading: invitesLoading } = useQuery({
queryKey: ["invites", bandId],
queryFn: () => listInvites(bandId),
enabled: amAdmin,
retry: false,
});
const inviteMutation = useMutation({
mutationFn: () => api.post<BandInvite>(`/bands/${bandId}/invites`, {}),
onSuccess: (invite) => {
const url = `${window.location.origin}/invite/${invite.token}`;
setInviteLink(url);
navigator.clipboard.writeText(url).catch(() => {});
qc.invalidateQueries({ queryKey: ["invites", bandId] });
},
});
const removeMutation = useMutation({
mutationFn: (memberId: string) => api.delete(`/bands/${bandId}/members/${memberId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["members", bandId] }),
});
const revokeMutation = useMutation({
mutationFn: (inviteId: string) => revokeInvite(inviteId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["invites", bandId] }),
});
const activeInvites = invitesData?.invites.filter(isActive) ?? [];
return (
<div>
{/* Member list */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<SectionTitle>Members</SectionTitle>
{amAdmin && (
<button
onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending}
style={{
background: "rgba(232,162,42,0.14)",
border: "1px solid rgba(232,162,42,0.28)",
borderRadius: 6,
color: "#e8a22a",
cursor: "pointer",
padding: "4px 12px",
fontSize: 12,
fontFamily: "inherit",
}}
>
{inviteMutation.isPending ? "Generating…" : "+ Invite"}
</button>
)}
</div>
{inviteLink && (
<div
style={{
background: "rgba(232,162,42,0.06)",
border: "1px solid rgba(232,162,42,0.22)",
borderRadius: 8,
padding: "10px 14px",
marginBottom: 14,
}}
>
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 11, margin: "0 0 5px" }}>
Invite link (copied to clipboard · valid 72h):
</p>
<code style={{ color: "#e8a22a", fontSize: 12, wordBreak: "break-all" }}>{inviteLink}</code>
<button
onClick={() => setInviteLink(null)}
style={{
display: "block",
marginTop: 6,
background: "none",
border: "none",
color: "rgba(255,255,255,0.28)",
cursor: "pointer",
fontSize: 11,
padding: 0,
fontFamily: "inherit",
}}
>
Dismiss
</button>
</div>
)}
{membersLoading ? (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Loading</p>
) : (
<div style={{ display: "grid", gap: 6, marginBottom: 0 }}>
{members?.map((m) => (
<div
key={m.id}
style={{
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
padding: "10px 14px",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 30,
height: 30,
borderRadius: "50%",
background: "rgba(232,162,42,0.15)",
border: "1px solid rgba(232,162,42,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 700,
color: "#e8a22a",
flexShrink: 0,
}}
>
{m.display_name.slice(0, 2).toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "rgba(255,255,255,0.72)" }}>{m.display_name}</div>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", marginTop: 1 }}>{m.email}</div>
</div>
<span
style={{
fontSize: 10,
fontFamily: "monospace",
padding: "2px 7px",
borderRadius: 3,
background: m.role === "admin" ? "rgba(232,162,42,0.1)" : "rgba(255,255,255,0.06)",
color: m.role === "admin" ? "#e8a22a" : "rgba(255,255,255,0.38)",
border: `1px solid ${m.role === "admin" ? "rgba(232,162,42,0.28)" : "rgba(255,255,255,0.08)"}`,
whiteSpace: "nowrap",
}}
>
{m.role}
</span>
{amAdmin && m.role !== "admin" && (
<button
onClick={() => removeMutation.mutate(m.id)}
disabled={removeMutation.isPending}
style={{
background: "rgba(220,80,80,0.08)",
border: "1px solid rgba(220,80,80,0.2)",
borderRadius: 5,
color: "#e07070",
cursor: "pointer",
fontSize: 11,
padding: "3px 8px",
fontFamily: "inherit",
}}
>
Remove
</button>
)}
</div>
))}
</div>
)}
{/* Role info cards */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginTop: 16 }}>
<div
style={{
padding: "10px 12px",
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
}}
>
<div style={{ fontSize: 12, color: "#e8a22a", marginBottom: 4 }}>Admin</div>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", lineHeight: 1.55 }}>
Upload, delete, manage members and storage
</div>
</div>
<div
style={{
padding: "10px 12px",
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
}}
>
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.55)", marginBottom: 4 }}>Member</div>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", lineHeight: 1.55 }}>
Listen, comment, annotate no upload or management
</div>
</div>
</div>
{/* Pending invites — admin only */}
{amAdmin && (
<>
<Divider />
<SectionTitle>Pending Invites</SectionTitle>
{invitesLoading ? (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>Loading invites</p>
) : activeInvites.length === 0 ? (
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13 }}>No pending invites.</p>
) : (
<div style={{ display: "grid", gap: 6 }}>
{activeInvites.map((invite) => (
<div
key={invite.id}
style={{
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 8,
padding: "10px 14px",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<code
style={{
fontSize: 11,
color: "rgba(255,255,255,0.35)",
fontFamily: "monospace",
}}
>
{invite.token.slice(0, 8)}{invite.token.slice(-4)}
</code>
<div style={{ fontSize: 11, color: "rgba(255,255,255,0.25)", marginTop: 2 }}>
{formatExpiry(invite.expires_at)} · {invite.role}
</div>
</div>
<button
onClick={() =>
navigator.clipboard
.writeText(`${window.location.origin}/invite/${invite.token}`)
.catch(() => {})
}
style={{
background: "none",
border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 5,
color: "rgba(255,255,255,0.42)",
cursor: "pointer",
fontSize: 11,
padding: "3px 8px",
fontFamily: "inherit",
}}
>
Copy
</button>
<button
onClick={() => revokeMutation.mutate(invite.id)}
disabled={revokeMutation.isPending}
style={{
background: "rgba(220,80,80,0.08)",
border: "1px solid rgba(220,80,80,0.2)",
borderRadius: 5,
color: "#e07070",
cursor: "pointer",
fontSize: 11,
padding: "3px 8px",
fontFamily: "inherit",
}}
>
Revoke
</button>
</div>
))}
</div>
)}
<p style={{ fontSize: 11, color: "rgba(255,255,255,0.2)", marginTop: 8 }}>
No account needed to accept an invite.
</p>
</>
)}
</div>
);
}
// ── Storage panel ─────────────────────────────────────────────────────────────
function StoragePanel({
bandId,
band,
amAdmin,
}: {
bandId: string;
band: { slug: string; nc_folder_path: string | null };
amAdmin: boolean;
}) {
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
const [folderInput, setFolderInput] = useState("");
const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null);
async function startScan() {
if (scanning) return;
setScanning(true);
setScanMsg(null);
setScanProgress("Starting scan…");
const url = `/api/v1/bands/${bandId}/nc-scan/stream`;
try {
const resp = await fetch(url, { credentials: "include" });
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => resp.statusText);
throw new Error(text || `HTTP ${resp.status}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
let event: Record<string, unknown>;
try { event = JSON.parse(line); } catch { continue; }
if (event.type === "progress") {
setScanProgress(event.message as string);
} else if (event.type === "song" || event.type === "session") {
qc.invalidateQueries({ queryKey: ["sessions", bandId] });
qc.invalidateQueries({ queryKey: ["songs-unattributed", bandId] });
} else if (event.type === "done") {
const s = event.stats as { found: number; imported: number; skipped: number };
if (s.imported > 0) {
setScanMsg(`Imported ${s.imported} new song${s.imported !== 1 ? "s" : ""} (${s.skipped} already registered).`);
} else if (s.found === 0) {
setScanMsg("No audio files found.");
} else {
setScanMsg(`All ${s.found} file${s.found !== 1 ? "s" : ""} already registered.`);
}
setTimeout(() => setScanMsg(null), 6000);
} else if (event.type === "error") {
setScanMsg(`Scan error: ${event.message}`);
}
}
}
} catch (err) {
setScanMsg(err instanceof Error ? err.message : "Scan failed");
} finally {
setScanning(false);
setScanProgress(null);
}
}
const updateMutation = useMutation({
mutationFn: (nc_folder_path: string) => api.patch(`/bands/${bandId}`, { nc_folder_path }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["band", bandId] });
setEditing(false);
},
});
const defaultPath = `bands/${band.slug}/`;
const currentPath = band.nc_folder_path ?? defaultPath;
return (
<div>
<SectionTitle>Nextcloud Scan Folder</SectionTitle>
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.3)", marginBottom: 16, lineHeight: 1.55 }}>
RehearsalHub reads recordings directly from your Nextcloud files are never copied to our
servers.
</p>
<div
style={{
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
borderRadius: 9,
padding: "12px 16px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<div>
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.22)", textTransform: "uppercase", letterSpacing: "0.6px", marginBottom: 4 }}>
Scan path
</div>
<code style={{ fontSize: 13, color: "#4dba85", fontFamily: "monospace" }}>
{currentPath}
</code>
</div>
{amAdmin && !editing && (
<button
onClick={() => { setFolderInput(band.nc_folder_path ?? ""); setEditing(true); }}
style={{
background: "none",
border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 6,
color: "rgba(255,255,255,0.42)",
cursor: "pointer",
padding: "4px 10px",
fontSize: 11,
fontFamily: "inherit",
}}
>
Edit
</button>
)}
</div>
{editing && (
<div style={{ marginTop: 12 }}>
<input
value={folderInput}
onChange={(e) => setFolderInput(e.target.value)}
placeholder={defaultPath}
style={{
width: "100%",
padding: "8px 12px",
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 7,
color: "#eeeef2",
fontSize: 13,
fontFamily: "monospace",
boxSizing: "border-box",
outline: "none",
}}
/>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button
onClick={() => updateMutation.mutate(folderInput)}
disabled={updateMutation.isPending}
style={{
background: "rgba(232,162,42,0.14)",
border: "1px solid rgba(232,162,42,0.28)",
borderRadius: 6,
color: "#e8a22a",
cursor: "pointer",
padding: "6px 14px",
fontSize: 12,
fontWeight: 600,
fontFamily: "inherit",
}}
>
{updateMutation.isPending ? "Saving…" : "Save"}
</button>
<button
onClick={() => setEditing(false)}
style={{
background: "none",
border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 6,
color: "rgba(255,255,255,0.42)",
cursor: "pointer",
padding: "6px 14px",
fontSize: 12,
fontFamily: "inherit",
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
{/* Scan action */}
<div style={{ marginTop: 16 }}>
<button
onClick={startScan}
disabled={scanning}
style={{
background: scanning ? "transparent" : "rgba(61,200,120,0.08)",
border: `1px solid ${scanning ? "rgba(255,255,255,0.07)" : "rgba(61,200,120,0.25)"}`,
borderRadius: 6,
color: scanning ? "rgba(255,255,255,0.28)" : "#4dba85",
cursor: scanning ? "default" : "pointer",
padding: "6px 14px",
fontSize: 12,
fontFamily: "inherit",
transition: "all 0.12s",
}}
>
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"}
</button>
</div>
{scanning && scanProgress && (
<div style={{ marginTop: 10, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.07)", borderRadius: 8, color: "rgba(255,255,255,0.42)", fontSize: 12, padding: "8px 14px", fontFamily: "monospace" }}>
{scanProgress}
</div>
)}
{scanMsg && (
<div style={{ marginTop: 10, background: "rgba(61,200,120,0.06)", border: "1px solid rgba(61,200,120,0.25)", borderRadius: 8, color: "#4dba85", fontSize: 12, padding: "8px 14px" }}>
{scanMsg}
</div>
)}
</div>
);
}
// ── Band settings panel ───────────────────────────────────────────────────────
function BandPanel({
bandId,
band,
amAdmin,
}: {
bandId: string;
band: { name: string; slug: string; genre_tags: string[] };
amAdmin: boolean;
}) {
const qc = useQueryClient();
const [nameInput, setNameInput] = useState(band.name);
const [tagInput, setTagInput] = useState("");
const [tags, setTags] = useState<string[]>(band.genre_tags);
const [saved, setSaved] = useState(false);
const updateMutation = useMutation({
mutationFn: (payload: { name?: string; genre_tags?: string[] }) =>
api.patch(`/bands/${bandId}`, payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["band", bandId] });
qc.invalidateQueries({ queryKey: ["bands"] });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
function addTag() {
const t = tagInput.trim();
if (t && !tags.includes(t)) setTags((prev) => [...prev, t]);
setTagInput("");
}
function removeTag(t: string) {
setTags((prev) => prev.filter((x) => x !== t));
}
return (
<div>
<SectionTitle>Identity</SectionTitle>
<div style={{ marginBottom: 14 }}>
<label style={{ display: "block", fontSize: 12, color: "rgba(255,255,255,0.42)", marginBottom: 5 }}>
Band name
</label>
<input
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
disabled={!amAdmin}
style={{
width: "100%",
padding: "8px 11px",
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 7,
color: "#eeeef2",
fontSize: 13,
fontFamily: "inherit",
boxSizing: "border-box",
outline: "none",
opacity: amAdmin ? 1 : 0.5,
}}
/>
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: "block", fontSize: 12, color: "rgba(255,255,255,0.42)", marginBottom: 5 }}>
Genre tags
</label>
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", marginBottom: 6 }}>
{tags.map((t) => (
<span
key={t}
style={{
background: "rgba(140,90,220,0.1)",
color: "#a878e8",
fontSize: 11,
padding: "2px 8px",
borderRadius: 12,
display: "flex",
alignItems: "center",
gap: 4,
}}
>
{t}
{amAdmin && (
<button
onClick={() => removeTag(t)}
style={{
background: "none",
border: "none",
color: "#a878e8",
cursor: "pointer",
fontSize: 13,
padding: 0,
lineHeight: 1,
fontFamily: "inherit",
}}
>
×
</button>
)}
</span>
))}
</div>
{amAdmin && (
<div style={{ display: "flex", gap: 6 }}>
<input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTag()}
placeholder="Add genre tag…"
style={{
flex: 1,
padding: "6px 10px",
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 7,
color: "#eeeef2",
fontSize: 12,
fontFamily: "inherit",
outline: "none",
}}
/>
<button
onClick={addTag}
style={{
background: "none",
border: "1px solid rgba(255,255,255,0.09)",
borderRadius: 6,
color: "rgba(255,255,255,0.42)",
cursor: "pointer",
padding: "6px 10px",
fontSize: 12,
fontFamily: "inherit",
}}
>
+
</button>
</div>
)}
</div>
{amAdmin && (
<button
onClick={() => updateMutation.mutate({ name: nameInput.trim() || band.name, genre_tags: tags })}
disabled={updateMutation.isPending}
style={{
background: "rgba(232,162,42,0.14)",
border: "1px solid rgba(232,162,42,0.28)",
borderRadius: 6,
color: saved ? "#4dba85" : "#e8a22a",
cursor: "pointer",
padding: "7px 18px",
fontSize: 13,
fontWeight: 600,
fontFamily: "inherit",
transition: "color 0.2s",
}}
>
{updateMutation.isPending ? "Saving…" : saved ? "Saved ✓" : "Save changes"}
</button>
)}
{!amAdmin && (
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.28)" }}>Only admins can edit band settings.</p>
)}
<Divider />
{/* Danger zone */}
<div
style={{
border: "1px solid rgba(220,80,80,0.18)",
borderRadius: 9,
padding: "14px 16px",
background: "rgba(220,80,80,0.04)",
}}
>
<div style={{ fontSize: 13, color: "#e07070", marginBottom: 3 }}>Delete this band</div>
<div style={{ fontSize: 11, color: "rgba(220,80,80,0.45)", marginBottom: 10 }}>
Removes all members and deletes comments. Storage files are NOT deleted.
</div>
<button
disabled={!amAdmin}
style={{
background: "rgba(220,80,80,0.08)",
border: "1px solid rgba(220,80,80,0.2)",
borderRadius: 6,
color: "#e07070",
cursor: amAdmin ? "pointer" : "default",
padding: "5px 12px",
fontSize: 11,
fontFamily: "inherit",
opacity: amAdmin ? 1 : 0.4,
}}
>
Delete band
</button>
</div>
</div>
);
}
// ── BandSettingsPage ──────────────────────────────────────────────────────────
export function BandSettingsPage() {
const { bandId, panel } = useParams<{ bandId: string; panel: string }>();
const navigate = useNavigate();
const activePanel: Panel =
panel === "storage" ? "storage" : panel === "band" ? "band" : "members";
const { data: band, isLoading: bandLoading } = useQuery({
queryKey: ["band", bandId],
queryFn: () => getBand(bandId!),
enabled: !!bandId,
});
const { data: members, isLoading: membersLoading } = useQuery({
queryKey: ["members", bandId],
queryFn: () => api.get<BandMember[]>(`/bands/${bandId}/members`),
enabled: !!bandId,
});
const { data: me } = useQuery({
queryKey: ["me"],
queryFn: () => api.get<MemberRead>("/auth/me"),
});
const amAdmin =
!!me && (members?.some((m) => m.id === me.id && m.role === "admin") ?? false);
const go = (p: Panel) => navigate(`/bands/${bandId}/settings/${p}`);
if (bandLoading) {
return <div style={{ color: "rgba(255,255,255,0.28)", padding: 32 }}>Loading</div>;
}
if (!band) {
return <div style={{ color: "#e07070", padding: 32 }}>Band not found</div>;
}
return (
<div style={{ display: "flex", height: "100%", overflow: "hidden" }}>
{/* ── Left panel nav ─────────────────────────────── */}
<div
style={{
width: 180,
minWidth: 180,
background: "#0b0b0e",
borderRight: "1px solid rgba(255,255,255,0.05)",
padding: "20px 10px",
display: "flex",
flexDirection: "column",
gap: 0,
}}
>
<div
style={{
fontSize: 10,
fontWeight: 500,
color: "rgba(255,255,255,0.2)",
textTransform: "uppercase",
letterSpacing: "0.7px",
padding: "0 6px 8px",
}}
>
Band {band.name}
</div>
<PanelNavItem label="Members" active={activePanel === "members"} onClick={() => go("members")} />
<PanelNavItem label="Storage" active={activePanel === "storage"} onClick={() => go("storage")} />
<PanelNavItem label="Band Settings" active={activePanel === "band"} onClick={() => go("band")} />
</div>
{/* ── Panel content ──────────────────────────────── */}
<div style={{ flex: 1, overflowY: "auto", padding: "28px 32px" }}>
<div style={{ maxWidth: 580 }}>
{activePanel === "members" && (
<>
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: "0 0 4px" }}>
Members
</h1>
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.3)", marginBottom: 24 }}>
Manage who has access to {band.name}'s recordings.
</p>
<MembersPanel
bandId={bandId!}
amAdmin={amAdmin}
members={members}
membersLoading={membersLoading}
/>
</>
)}
{activePanel === "storage" && (
<>
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: "0 0 4px" }}>
Storage
</h1>
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.3)", marginBottom: 24 }}>
Configure where {band.name} stores recordings.
</p>
<StoragePanel bandId={bandId!} band={band} amAdmin={amAdmin} />
</>
)}
{activePanel === "band" && (
<>
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: "0 0 4px" }}>
Band Settings
</h1>
<p style={{ fontSize: 12, color: "rgba(255,255,255,0.3)", marginBottom: 24 }}>
Only admins can edit these settings.
</p>
<BandPanel bandId={bandId!} band={band} amAdmin={amAdmin} />
</>
)}
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { AudioService } from "./audioService";
// ── WaveSurfer mock ────────────────────────────────────────────────────────────
const mockLoad = vi.fn();
const mockOn = vi.fn();
const mockUnAll = vi.fn();
const mockDestroy = vi.fn();
const mockGetDuration = vi.fn(() => 180);
const mockSetOptions = vi.fn();
const mockCreate = vi.fn();
vi.mock("wavesurfer.js", () => ({
default: {
create: (opts: unknown) => {
mockCreate(opts);
return {
load: mockLoad,
on: mockOn,
unAll: mockUnAll,
destroy: mockDestroy,
getDuration: mockGetDuration,
setOptions: mockSetOptions,
isPlaying: vi.fn(() => false),
};
},
},
}));
// ── Zustand store mock ─────────────────────────────────────────────────────────
vi.mock("../stores/playerStore", () => ({
usePlayerStore: {
getState: vi.fn(() => ({
isPlaying: false,
currentTime: 0,
duration: 0,
currentSongId: null,
currentBandId: null,
batchUpdate: vi.fn(),
setDuration: vi.fn(),
setCurrentSong: vi.fn(),
})),
subscribe: vi.fn(),
},
}));
// ── Helpers ────────────────────────────────────────────────────────────────────
function makeContainer(): HTMLDivElement {
return document.createElement("div");
}
function triggerWaveSurferReady() {
// WaveSurfer fires the "ready" event via ws.on("ready", cb)
const readyCb = mockOn.mock.calls.find(([event]) => event === "ready")?.[1];
readyCb?.();
}
// ── Tests ──────────────────────────────────────────────────────────────────────
describe("AudioService.initialize()", () => {
let service: AudioService;
beforeEach(() => {
AudioService.resetInstance();
service = AudioService.getInstance();
vi.clearAllMocks();
mockGetDuration.mockReturnValue(180);
});
afterEach(() => {
AudioService.resetInstance();
});
it("calls ws.load(url) without peaks when no peaks provided", async () => {
const container = makeContainer();
const url = "http://localhost/audio/song.mp3";
const initPromise = service.initialize(container, url);
triggerWaveSurferReady();
await initPromise;
expect(mockLoad).toHaveBeenCalledOnce();
const [calledUrl, calledPeaks] = mockLoad.mock.calls[0];
expect(calledUrl).toBe(url);
expect(calledPeaks).toBeUndefined();
});
it("calls ws.load(url, [Float32Array]) when peaks are provided", async () => {
const container = makeContainer();
const url = "http://localhost/audio/song.mp3";
const peaks = Array.from({ length: 500 }, (_, i) => i / 500);
const initPromise = service.initialize(container, url, peaks);
triggerWaveSurferReady();
await initPromise;
expect(mockLoad).toHaveBeenCalledOnce();
const [calledUrl, calledChannelData] = mockLoad.mock.calls[0];
expect(calledUrl).toBe(url);
expect(calledChannelData).toHaveLength(1);
expect(calledChannelData[0]).toBeInstanceOf(Float32Array);
expect(calledChannelData[0]).toHaveLength(500);
expect(calledChannelData[0][0]).toBeCloseTo(0);
expect(calledChannelData[0][499]).toBeCloseTo(499 / 500);
});
it("does not re-initialize for the same url and container", async () => {
const container = makeContainer();
const url = "http://localhost/audio/song.mp3";
const peaks = [0.5, 0.5, 0.5];
const p1 = service.initialize(container, url, peaks);
triggerWaveSurferReady();
await p1;
vi.clearAllMocks();
// Second call with same URL + container: should no-op
await service.initialize(container, url, peaks);
expect(mockLoad).not.toHaveBeenCalled();
});
});

View File

@@ -26,7 +26,7 @@ class AudioService {
// For use in tests only
public static resetInstance(): void {
this.instance?.cleanup();
this.instance = undefined as any;
this.instance = undefined as unknown as AudioService;
}
private createMediaElement(): HTMLAudioElement {
@@ -41,7 +41,7 @@ class AudioService {
return el;
}
public async initialize(container: HTMLElement, url: string): Promise<void> {
public async initialize(container: HTMLElement, url: string, peaks?: number[] | null): Promise<void> {
if (!container) throw new Error('Container element is required');
if (!url) throw new Error('Valid audio URL is required');
@@ -69,9 +69,9 @@ class AudioService {
// Fresh audio element per song. Lives on document.body so playback
// continues even when the SongPage container is removed from the DOM.
media: this.mediaElement,
waveColor: "rgba(255,255,255,0.09)",
progressColor: "#c8861a",
cursorColor: "#e8a22a",
waveColor: "rgba(20,184,166,0.18)",
progressColor: "#14b8a6",
cursorColor: "#2dd4bf",
barWidth: 2,
barRadius: 2,
height: 104,
@@ -98,7 +98,14 @@ class AudioService {
ws.on('ready', () => { onReady().catch(reject); });
ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
// Pass pre-computed peaks to WaveSurfer so the waveform renders immediately
// without waiting for the full audio to decode (WaveSurfer v7 feature).
if (peaks && peaks.length > 0) {
ws.load(url, [new Float32Array(peaks)]);
} else {
ws.load(url);
}
});
}

View File

@@ -0,0 +1,18 @@
import { create } from "zustand";
interface BandState {
activeBandId: string | null;
setActiveBandId: (id: string | null) => void;
}
function load(): string | null {
try { return localStorage.getItem("rh_active_band_id"); } catch { return null; }
}
export const useBandStore = create<BandState>()((set) => ({
activeBandId: load(),
setActiveBandId: (id) => {
try { if (id) localStorage.setItem("rh_active_band_id", id); } catch { /* ignore */ }
set({ activeBandId: id });
},
}));

View File

@@ -22,9 +22,29 @@ RUN --mount=type=bind,from=essentia-builder,source=/usr/local/lib,target=/essent
RUN pip install uv
FROM base AS development
COPY pyproject.toml .
RUN uv sync --all-extras --no-install-project --frozen || uv sync --all-extras --no-install-project
ENV PYTHONPATH=/app/src
ENV PYTHONUNBUFFERED=1
ENV LOG_LEVEL=DEBUG
CMD ["/bin/sh", "-c", "PYTHONPATH=/app/src exec /app/.venv/bin/watchfiles --ignore-permission-denied '/app/.venv/bin/python -m worker.main' src"]
FROM base AS production
COPY pyproject.toml .
RUN uv sync --no-dev --frozen || uv sync --no-dev
COPY . .
ENV PYTHONPATH=/app/src
# Pre-warm librosa/numba JIT cache and pooch downloads so they happen at build
# time and are baked into the image rather than downloaded on every cold start.
RUN uv run python -c "\
import numpy as np; \
import librosa; \
_dummy = np.zeros(22050, dtype=np.float32); \
librosa.beat.beat_track(y=_dummy, sr=22050); \
librosa.feature.chroma_stft(y=_dummy, sr=22050); \
print('librosa warmup done') \
"
CMD ["uv", "run", "python", "-m", "worker.main"]

View File

@@ -26,6 +26,7 @@ dev = [
"pytest-asyncio>=0.23",
"pytest-cov>=5",
"ruff>=0.4",
"watchfiles>=0.21",
]
[tool.hatch.build.targets.wheel]
@@ -34,3 +35,20 @@ packages = ["src/worker"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
src = ["src"]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["F401", "F841", "SIM102", "SIM211", "UP045", "E501", "UP017"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["F401", "F841", "SIM102", "SIM211", "UP017", "I001", "B017", "SIM117"]
[dependency-groups]
dev = [
"ruff>=0.15.8",
]

View File

@@ -1,4 +1,5 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -9,9 +10,8 @@ class WorkerSettings(BaseSettings):
redis_url: str = "redis://localhost:6379/0"
job_queue_key: str = "rh:jobs"
nextcloud_url: str = "http://nextcloud"
nextcloud_user: str = "ncadmin"
nextcloud_pass: str = ""
api_url: str = "http://api:8000"
internal_secret: str = "dev-change-me-in-production"
audio_tmp_dir: str = "/tmp/audio"
analysis_version: str = "1.0.0"

View File

@@ -6,7 +6,7 @@ import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, func
from sqlalchemy import BigInteger, DateTime, Integer, Numeric, String, Text, func
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -26,6 +26,8 @@ class AudioVersionModel(Base):
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)
waveform_peaks: Mapped[Optional[list]] = mapped_column(JSONB)
waveform_peaks_mini: Mapped[Optional[list]] = mapped_column(JSONB)
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)
@@ -34,6 +36,14 @@ class AudioVersionModel(Base):
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class SongModel(Base):
__tablename__ = "songs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
global_bpm: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
global_key: Mapped[Optional[str]] = mapped_column(String(30))
class RangeAnalysisModel(Base):
__tablename__ = "range_analyses"

View File

@@ -13,7 +13,7 @@ from pathlib import Path
import librosa
import numpy as np
import redis.asyncio as aioredis
from sqlalchemy import select, update
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from worker.config import get_settings
@@ -21,25 +21,32 @@ from worker.db import AudioVersionModel, JobModel
from worker.pipeline.analyse_full import run_full_analysis
from worker.pipeline.analyse_range import run_range_analysis
from worker.pipeline.transcode import get_duration_ms, transcode_to_hls
from worker.pipeline.waveform import generate_waveform_file
from worker.pipeline.waveform import extract_peaks, generate_waveform_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
# Numba floods logs with JIT compilation details at DEBUG level — keep it quiet
logging.getLogger("numba").setLevel(logging.WARNING)
log = logging.getLogger("worker")
async def load_audio(nc_path: str, tmp_dir: str, settings) -> tuple[np.ndarray, int, str]:
"""Download from Nextcloud and load as numpy array. Returns (audio, sr, local_path)."""
async def load_audio(version_id: str, filename: str, tmp_dir: str, settings) -> tuple[np.ndarray, int, str]:
"""Download audio via the internal API and load as numpy array. Returns (audio, sr, local_path)."""
import httpx
local_path = os.path.join(tmp_dir, Path(nc_path).name)
dav_url = f"{settings.nextcloud_url}/remote.php/dav/files/{settings.nextcloud_user}/{nc_path.lstrip('/')}"
local_path = os.path.join(tmp_dir, filename)
url = f"{settings.api_url}/api/v1/internal/audio/{version_id}/stream"
log.info("Fetching audio for version %s from %s", version_id, url)
async with httpx.AsyncClient(
auth=(settings.nextcloud_user, settings.nextcloud_pass), timeout=120.0
headers={"X-Internal-Token": settings.internal_secret}, timeout=120.0
) as client:
resp = await client.get(dav_url)
async with client.stream("GET", url) as resp:
resp.raise_for_status()
with open(local_path, "wb") as f:
f.write(resp.content)
async for chunk in resp.aiter_bytes(65536):
f.write(chunk)
loop = asyncio.get_event_loop()
audio, sr = await loop.run_in_executor(
@@ -53,26 +60,29 @@ async def handle_transcode(payload: dict, session: AsyncSession, settings) -> No
nc_path = payload["nc_file_path"]
with tempfile.TemporaryDirectory(dir=settings.audio_tmp_dir) as tmp:
audio, sr, local_path = await load_audio(nc_path, tmp, settings)
audio, sr, local_path = await load_audio(str(version_id), Path(nc_path).name, tmp, settings)
duration_ms = await get_duration_ms(local_path)
hls_dir = os.path.join(tmp, "hls")
await transcode_to_hls(local_path, hls_dir)
waveform_path = os.path.join(tmp, "waveform.json")
await generate_waveform_file(audio, waveform_path)
# TODO: Upload HLS segments and waveform back to Nextcloud / object storage
# For now, store the local tmp path in the DB (replace with real upload logic)
hls_nc_path = f"hls/{version_id}"
waveform_nc_path = f"waveforms/{version_id}.json"
# Generate waveform peaks at two resolutions:
# - 500-point full peaks passed to WaveSurfer for instant render in player
# - 100-point mini peaks for the library/overview SVG thumbnail
loop = asyncio.get_event_loop()
peaks_500 = await loop.run_in_executor(None, extract_peaks, audio, 500)
peaks_100 = await loop.run_in_executor(None, extract_peaks, audio, 100)
# NOTE: HLS upload to Nextcloud is not yet implemented.
# cdn_hls_base is intentionally left unchanged here — do NOT set it to a
# local tmp path that will be deleted. The stream endpoint falls back to
# nc_file_path (raw file from Nextcloud) when cdn_hls_base is null.
stmt = (
update(AudioVersionModel)
.where(AudioVersionModel.id == version_id)
.values(
cdn_hls_base=hls_nc_path,
waveform_url=waveform_nc_path,
waveform_peaks=peaks_500,
waveform_peaks_mini=peaks_100,
duration_ms=duration_ms,
analysis_status="running",
)
@@ -96,32 +106,74 @@ async def handle_analyse_range(payload: dict, session: AsyncSession, settings) -
raise ValueError(f"AudioVersion {version_id} not found")
with tempfile.TemporaryDirectory(dir=settings.audio_tmp_dir) as tmp:
audio, sr, _ = await load_audio(version.nc_file_path, tmp, settings)
audio, sr, _ = await load_audio(str(version_id), Path(version.nc_file_path).name, tmp, settings)
await run_range_analysis(audio, sr, version_id, annotation_id, start_ms, end_ms, session)
log.info("Range analysis complete for annotation %s", annotation_id)
async def handle_extract_peaks(payload: dict, session: AsyncSession, settings) -> None:
"""Lightweight job: download audio and (re-)compute waveform peaks only.
Used by the reindex endpoint to backfill peaks for versions that were
registered before peak computation was added, or after algorithm changes.
Does NOT transcode, generate HLS, or run full analysis.
"""
version_id = uuid.UUID(payload["version_id"])
nc_path = payload["nc_file_path"]
with tempfile.TemporaryDirectory(dir=settings.audio_tmp_dir) as tmp:
audio, _sr, _local_path = await load_audio(str(version_id), Path(nc_path).name, tmp, settings)
loop = asyncio.get_event_loop()
peaks_500 = await loop.run_in_executor(None, extract_peaks, audio, 500)
peaks_100 = await loop.run_in_executor(None, extract_peaks, audio, 100)
stmt = (
update(AudioVersionModel)
.where(AudioVersionModel.id == version_id)
.values(
waveform_peaks=peaks_500,
waveform_peaks_mini=peaks_100,
)
)
await session.execute(stmt)
await session.commit()
log.info("extract_peaks complete for version %s", version_id)
HANDLERS = {
"transcode": handle_transcode,
"analyse_range": handle_analyse_range,
"extract_peaks": handle_extract_peaks,
}
async def main() -> None:
settings = get_settings()
os.makedirs(settings.audio_tmp_dir, exist_ok=True)
log.info(
"Worker config — redis_url=%s api_url=%s queue=%s",
settings.redis_url, settings.api_url, settings.job_queue_key,
)
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
redis = aioredis.from_url(settings.redis_url, decode_responses=True)
# Drain stale job IDs left in Redis from previous runs whose API transactions
# were never committed (e.g. crashed processes).
stale = await redis.llen(settings.job_queue_key)
if stale:
log.warning("Draining %d stale job IDs from Redis queue before starting", stale)
await redis.delete(settings.job_queue_key)
# Wait for Redis to be reachable before proceeding (startup race condition guard).
for attempt in range(1, 31):
try:
await redis.ping()
log.info("Redis connection established (attempt %d)", attempt)
break
except Exception as exc:
if attempt == 30:
log.error("Redis unreachable after 30 attempts — giving up: %s", exc)
raise
log.warning("Redis not ready (attempt %d/30): %s — retrying in 2s", attempt, exc)
await asyncio.sleep(2)
log.info("Worker started. Listening for jobs on %s", settings.job_queue_key)

View File

@@ -3,15 +3,28 @@
from __future__ import annotations
import asyncio
import logging
import uuid
from concurrent.futures import ThreadPoolExecutor
from typing import Any
import numpy as np
from sqlalchemy.ext.asyncio import AsyncSession
from worker.analyzers.base import AnalysisResult
from worker.analyzers.bpm import BPMAnalyzer
from worker.analyzers.key import KeyAnalyzer
log = logging.getLogger(__name__)
# Dedicated pool so heavy Essentia threads can't starve the default executor.
# max_workers=2 covers BPM + Key running sequentially per job.
_analysis_pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="analysis")
# Per-analyzer timeout in seconds. Essentia multifeature BPM can be slow on
# long recordings; 3 minutes is generous for a single-track analysis pass.
_ANALYZER_TIMEOUT = 180.0
async def run_full_analysis(
audio: np.ndarray,
@@ -19,28 +32,61 @@ async def run_full_analysis(
version_id: uuid.UUID,
session: AsyncSession,
) -> dict[str, Any]:
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
bpm_result = await loop.run_in_executor(None, BPMAnalyzer().analyze, audio, sample_rate)
key_result = await loop.run_in_executor(None, KeyAnalyzer().analyze, audio, sample_rate)
try:
bpm_result = await asyncio.wait_for(
loop.run_in_executor(_analysis_pool, BPMAnalyzer().analyze, audio, sample_rate),
timeout=_ANALYZER_TIMEOUT,
)
except asyncio.TimeoutError:
log.warning("BPM analysis timed out for version %s — storing null", version_id)
bpm_result = AnalysisResult(analyzer_name="bpm", fields={"bpm": None, "bpm_confidence": None})
try:
key_result = await asyncio.wait_for(
loop.run_in_executor(_analysis_pool, KeyAnalyzer().analyze, audio, sample_rate),
timeout=_ANALYZER_TIMEOUT,
)
except asyncio.TimeoutError:
log.warning("Key analysis timed out for version %s — storing null", version_id)
key_result = AnalysisResult(analyzer_name="key", fields={"key": None, "scale": None, "key_confidence": None})
fields: dict[str, Any] = {**bpm_result.fields, **key_result.fields}
from sqlalchemy import update
from worker.db import AudioVersionModel
global_bpm = fields.get("bpm")
global_key = fields.get("key")
from worker.db import SongModel
# Mark version analysis done
stmt = (
update(AudioVersionModel)
.where(AudioVersionModel.id == version_id)
.values(
analysis_status="done",
**({} if global_bpm is None else {"global_bpm": global_bpm}),
)
.values(analysis_status="done")
)
await session.execute(stmt)
# Write BPM/key to the song (global_bpm/global_key live on songs, not audio_versions)
version = await session.get(AudioVersionModel, version_id)
if version is not None:
song_extra: dict[str, Any] = {}
if global_bpm is not None:
song_extra["global_bpm"] = global_bpm
if global_key is not None:
song_extra["global_key"] = global_key
if song_extra:
song_stmt = (
update(SongModel)
.where(SongModel.id == version.song_id)
.values(**song_extra)
)
await session.execute(song_stmt)
await session.commit()
return fields

View File

@@ -5,10 +5,6 @@ from __future__ import annotations
import asyncio
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
async def transcode_to_hls(input_path: str, output_dir: str) -> str:
@@ -45,16 +41,26 @@ async def get_duration_ms(input_path: str) -> int:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, _ = await proc.communicate()
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30.0)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise RuntimeError(f"ffprobe timed out for {input_path}")
info = json.loads(stdout)
duration_s = float(info.get("format", {}).get("duration", 0))
return int(duration_s * 1000)
async def _run_ffmpeg(cmd: list[str]) -> None:
async def _run_ffmpeg(cmd: list[str], timeout: float = 600.0) -> None:
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE
)
_, stderr = await proc.communicate()
try:
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise RuntimeError(f"FFmpeg timed out after {timeout}s")
if proc.returncode != 0:
raise RuntimeError(f"FFmpeg failed: {stderr.decode()[:500]}")

View File

@@ -0,0 +1,71 @@
"""Unit tests for handle_transcode waveform peaks storage."""
from unittest.mock import AsyncMock, MagicMock, patch, call
import uuid
import numpy as np
import pytest
@pytest.fixture
def mock_audio(sine_440hz):
audio, sr = sine_440hz
return audio, sr
@pytest.mark.asyncio
async def test_handle_transcode_stores_both_peak_resolutions(mock_audio):
"""After handle_transcode, waveform_peaks (500) and waveform_peaks_mini (100) are stored in DB."""
audio, sr = mock_audio
version_id = uuid.uuid4()
# Capture the statement passed to session.execute
executed_stmts = []
async def capture_execute(stmt):
executed_stmts.append(stmt)
return MagicMock()
mock_session = AsyncMock()
mock_session.execute = capture_execute
mock_session.commit = AsyncMock()
mock_settings = MagicMock()
mock_settings.nextcloud_url = "http://nc.test"
mock_settings.nextcloud_user = "user"
mock_settings.nextcloud_pass = "pass"
mock_settings.target_sample_rate = 44100
mock_settings.audio_tmp_dir = "/tmp"
payload = {
"version_id": str(version_id),
"nc_file_path": "/bands/test/songs/test/v1.wav",
}
with (
patch("worker.main.load_audio", return_value=(audio, sr, "/tmp/v1.wav")),
patch("worker.main.get_duration_ms", return_value=5000),
patch("worker.main.transcode_to_hls", new_callable=AsyncMock),
patch("worker.main.run_full_analysis", new_callable=AsyncMock),
):
from worker.main import handle_transcode
await handle_transcode(payload, mock_session, mock_settings)
assert len(executed_stmts) == 1, "Expected exactly one UPDATE statement"
stmt = executed_stmts[0]
# Extract the values dict from the SQLAlchemy Update statement
values = stmt._values
value_keys = {col.key for col, _ in values.items()}
assert "waveform_peaks" in value_keys, f"waveform_peaks not in UPDATE values: {value_keys}"
assert "waveform_peaks_mini" in value_keys, f"waveform_peaks_mini not in UPDATE values: {value_keys}"
# Resolve the actual peak lists from the BindParameter objects
peaks_500 = next(val.value for col, val in values.items() if col.key == "waveform_peaks")
peaks_100 = next(val.value for col, val in values.items() if col.key == "waveform_peaks_mini")
assert len(peaks_500) == 500, f"Expected 500 peaks, got {len(peaks_500)}"
assert len(peaks_100) == 100, f"Expected 100 mini peaks, got {len(peaks_100)}"
assert all(0.0 <= p <= 1.0 for p in peaks_500), "Full peaks out of [0, 1] range"
assert all(0.0 <= p <= 1.0 for p in peaks_100), "Mini peaks out of [0, 1] range"

View File

@@ -14,6 +14,12 @@ def test_extract_peaks_returns_correct_length(sine_440hz):
assert len(peaks) == 500
def test_extract_peaks_mini_returns_correct_length(sine_440hz):
audio, sr = sine_440hz
peaks = extract_peaks(audio, num_points=100)
assert len(peaks) == 100
def test_extract_peaks_normalized_between_0_and_1(sine_440hz):
audio, sr = sine_440hz
peaks = extract_peaks(audio, num_points=200)
@@ -21,6 +27,13 @@ def test_extract_peaks_normalized_between_0_and_1(sine_440hz):
assert max(peaks) == pytest.approx(1.0, abs=0.01)
def test_extract_peaks_mini_normalized_between_0_and_1(sine_440hz):
audio, sr = sine_440hz
peaks = extract_peaks(audio, num_points=100)
assert all(0.0 <= p <= 1.0 for p in peaks)
assert max(peaks) == pytest.approx(1.0, abs=0.01)
def test_extract_peaks_empty_audio():
audio = np.array([], dtype=np.float32)
peaks = extract_peaks(audio, num_points=100)
@@ -28,6 +41,14 @@ def test_extract_peaks_empty_audio():
assert all(p == 0.0 for p in peaks)
def test_extract_peaks_custom_num_points(sine_440hz):
audio, _ = sine_440hz
for n in [50, 100, 250, 500]:
peaks = extract_peaks(audio, num_points=n)
assert len(peaks) == n, f"Expected {n} peaks, got {len(peaks)}"
assert all(0.0 <= p <= 1.0 for p in peaks)
def test_peaks_to_json_valid_structure(sine_440hz):
audio, _ = sine_440hz
peaks = extract_peaks(audio)
@@ -46,4 +67,5 @@ async def test_generate_waveform_file_writes_json(tmp_path, sine_440hz):
with open(output) as f:
data = json.load(f)
assert data["version"] == 2
assert len(data["data"]) == 1000
# generate_waveform_file uses the default num_points=500
assert len(data["data"]) == 500

8
worker/uv.lock generated
View File

@@ -1004,6 +1004,11 @@ dev = [
{ name = "ruff" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "asyncpg", specifier = ">=0.29" },
@@ -1023,6 +1028,9 @@ requires-dist = [
]
provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.8" }]
[[package]]
name = "requests"
version = "2.33.1"