16 Commits

Author SHA1 Message Date
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
80 changed files with 4372 additions and 3698 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 ## Quick start
> **Docker Registry Setup**: For production deployments using Gitea registry, see [DOCKER_REGISTRY.md](DOCKER_REGISTRY.md)
### 1. Configure environment ### 1. Configure environment
```bash ```bash

View File

@@ -38,8 +38,8 @@ tasks:
build: build:
desc: Build all images desc: Build all images
deps: [check]
cmds: cmds:
- task: check
- "{{.COMPOSE}} build" - "{{.COMPOSE}} build"
logs: logs:
@@ -209,12 +209,12 @@ tasks:
check: check:
desc: Run all linters and type checkers desc: Run all linters and type checkers
deps: [lint, typecheck:web] deps: [lint]
lint: lint:
desc: Lint all services desc: Lint all services
cmds: 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 worker && uv run ruff check src/ tests/
- cd watcher && uv run ruff check src/ tests/ - cd watcher && uv run ruff check src/ tests/
- cd web && npm run lint - cd web && npm run lint
@@ -251,3 +251,20 @@ tasks:
interactive: true interactive: true
cmds: cmds:
- "{{.COMPOSE}} exec redis redis-cli" - "{{.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

@@ -53,6 +53,9 @@ target-version = "py312"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"] 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] [tool.mypy]
python_version = "3.12" python_version = "3.12"
@@ -66,7 +69,9 @@ omit = ["src/rehearsalhub/db/models.py"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"httpx>=0.28.1", "httpx>=0.28.1",
"mypy>=1.19.1",
"pytest>=9.0.2", "pytest>=9.0.2",
"pytest-asyncio>=1.3.0", "pytest-asyncio>=1.3.0",
"ruff>=0.15.8",
] ]

View File

@@ -1,4 +1,5 @@
from functools import lru_cache from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Optional
from sqlalchemy import ( from sqlalchemy import (
BigInteger, BigInteger,
@@ -35,10 +34,10 @@ class Member(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 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) email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
display_name: Mapped[str] = mapped_column(String(255), nullable=False) display_name: Mapped[str] = mapped_column(String(255), nullable=False)
avatar_url: Mapped[Optional[str]] = mapped_column(Text) avatar_url: Mapped[str | None] = mapped_column(Text)
nc_username: Mapped[Optional[str]] = mapped_column(String(255)) nc_username: Mapped[str | None] = mapped_column(String(255))
nc_url: Mapped[Optional[str]] = mapped_column(Text) nc_url: Mapped[str | None] = mapped_column(Text)
nc_password: Mapped[Optional[str]] = mapped_column(Text) nc_password: Mapped[str | None] = mapped_column(Text)
password_hash: Mapped[str] = mapped_column(Text, nullable=False) password_hash: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
@@ -68,8 +67,8 @@ class Band(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 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) name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text) nc_folder_path: Mapped[str | None] = mapped_column(Text)
nc_user: Mapped[Optional[str]] = mapped_column(String(255)) nc_user: Mapped[str | None] = mapped_column(String(255))
genre_tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False) genre_tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
@@ -103,7 +102,7 @@ class BandMember(Base):
joined_at: Mapped[datetime] = mapped_column( joined_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False 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") band: Mapped[Band] = relationship("Band", back_populates="memberships")
member: Mapped[Member] = relationship("Member", back_populates="band_memberships") member: Mapped[Member] = relationship("Member", back_populates="band_memberships")
@@ -122,8 +121,8 @@ class BandInvite(Base):
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
) )
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
used_by: Mapped[Optional[uuid.UUID]] = mapped_column( used_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL") UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
) )
@@ -143,9 +142,9 @@ class RehearsalSession(Base):
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
) )
date: Mapped[datetime] = mapped_column(DateTime(timezone=False), nullable=False) date: Mapped[datetime] = mapped_column(DateTime(timezone=False), nullable=False)
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text) nc_folder_path: Mapped[str | None] = mapped_column(Text)
label: Mapped[Optional[str]] = mapped_column(String(255)) label: Mapped[str | None] = mapped_column(String(255))
notes: Mapped[Optional[str]] = mapped_column(Text) notes: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )
@@ -164,17 +163,17 @@ class Song(Base):
band_id: Mapped[uuid.UUID] = mapped_column( band_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True 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 UUID(as_uuid=True), ForeignKey("rehearsal_sessions.id", ondelete="SET NULL"), index=True
) )
title: Mapped[str] = mapped_column(String(500), nullable=False) 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") status: Mapped[str] = mapped_column(String(20), nullable=False, default="jam")
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False) tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
global_key: Mapped[Optional[str]] = mapped_column(String(30)) global_key: Mapped[str | None] = mapped_column(String(30))
global_bpm: Mapped[Optional[float]] = mapped_column(Numeric(6, 2)) global_bpm: Mapped[float | None] = mapped_column(Numeric(6, 2))
notes: Mapped[Optional[str]] = mapped_column(Text) notes: Mapped[str | None] = mapped_column(Text)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column( created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL") UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
) )
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
@@ -185,8 +184,8 @@ class Song(Base):
) )
band: Mapped[Band] = relationship("Band", back_populates="songs") band: Mapped[Band] = relationship("Band", back_populates="songs")
session: Mapped[Optional[RehearsalSession]] = relationship("RehearsalSession", back_populates="songs") session: Mapped[RehearsalSession | None] = relationship("RehearsalSession", back_populates="songs")
creator: Mapped[Optional[Member]] = relationship("Member", back_populates="authored_songs") creator: Mapped[Member | None] = relationship("Member", back_populates="authored_songs")
versions: Mapped[list[AudioVersion]] = relationship( versions: Mapped[list[AudioVersion]] = relationship(
"AudioVersion", back_populates="song", cascade="all, delete-orphan" "AudioVersion", back_populates="song", cascade="all, delete-orphan"
) )
@@ -206,8 +205,8 @@ class SongComment(Base):
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
) )
body: Mapped[str] = mapped_column(Text, nullable=False) body: Mapped[str] = mapped_column(Text, nullable=False)
timestamp: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True) timestamp: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
tag: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) tag: Mapped[str | None] = mapped_column(String(32), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )
@@ -227,16 +226,27 @@ class AudioVersion(Base):
UUID(as_uuid=True), ForeignKey("songs.id", ondelete="CASCADE"), nullable=False, index=True UUID(as_uuid=True), ForeignKey("songs.id", ondelete="CASCADE"), nullable=False, index=True
) )
version_number: Mapped[int] = mapped_column(Integer, nullable=False) 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_path: Mapped[str] = mapped_column(Text, nullable=False)
<<<<<<< HEAD
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255)) nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text) cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
waveform_url: 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) duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
format: Mapped[Optional[str]] = mapped_column(String(10)) format: Mapped[Optional[str]] = mapped_column(String(10))
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger) 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)
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)
>>>>>>> feature/pipeline-fix
analysis_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") 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") UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
) )
uploaded_at: Mapped[datetime] = mapped_column( uploaded_at: Mapped[datetime] = mapped_column(
@@ -244,7 +254,7 @@ class AudioVersion(Base):
) )
song: Mapped[Song] = relationship("Song", back_populates="versions") song: Mapped[Song] = relationship("Song", back_populates="versions")
uploader: Mapped[Optional[Member]] = relationship( uploader: Mapped[Member | None] = relationship(
"Member", back_populates="uploaded_versions" "Member", back_populates="uploaded_versions"
) )
annotations: Mapped[list[Annotation]] = relationship( annotations: Mapped[list[Annotation]] = relationship(
@@ -273,16 +283,16 @@ class Annotation(Base):
) )
type: Mapped[str] = mapped_column(String(10), nullable=False) # 'point' | 'range' type: Mapped[str] = mapped_column(String(10), nullable=False) # 'point' | 'range'
timestamp_ms: Mapped[int] = mapped_column(Integer, nullable=False) timestamp_ms: Mapped[int] = mapped_column(Integer, nullable=False)
range_end_ms: Mapped[Optional[int]] = mapped_column(Integer) range_end_ms: Mapped[int | None] = mapped_column(Integer)
body: Mapped[Optional[str]] = mapped_column(Text) body: Mapped[str | None] = mapped_column(Text)
voice_note_url: Mapped[Optional[str]] = mapped_column(Text) voice_note_url: Mapped[str | None] = mapped_column(Text)
label: Mapped[Optional[str]] = mapped_column(String(255)) label: Mapped[str | None] = mapped_column(String(255))
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False) 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") UUID(as_uuid=True), ForeignKey("annotations.id", ondelete="SET NULL")
) )
resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )
@@ -297,13 +307,13 @@ class Annotation(Base):
replies: Mapped[list[Annotation]] = relationship( replies: Mapped[list[Annotation]] = relationship(
"Annotation", foreign_keys=[parent_id], back_populates="parent" "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] "Annotation", foreign_keys=[parent_id], back_populates="replies", remote_side=[id]
) )
reactions: Mapped[list[Reaction]] = relationship( reactions: Mapped[list[Reaction]] = relationship(
"Reaction", back_populates="annotation", cascade="all, delete-orphan" "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 "RangeAnalysis", back_populates="annotation", uselist=False
) )
@@ -329,19 +339,19 @@ class RangeAnalysis(Base):
) )
start_ms: Mapped[int] = mapped_column(Integer, nullable=False) start_ms: Mapped[int] = mapped_column(Integer, nullable=False)
end_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: Mapped[float | None] = mapped_column(Numeric(7, 2))
bpm_confidence: Mapped[Optional[float]] = mapped_column(Numeric(4, 3)) bpm_confidence: Mapped[float | None] = mapped_column(Numeric(4, 3))
key: Mapped[Optional[str]] = mapped_column(String(30)) key: Mapped[str | None] = mapped_column(String(30))
key_confidence: Mapped[Optional[float]] = mapped_column(Numeric(4, 3)) key_confidence: Mapped[float | None] = mapped_column(Numeric(4, 3))
scale: Mapped[Optional[str]] = mapped_column(String(10)) scale: Mapped[str | None] = mapped_column(String(10))
avg_loudness_lufs: Mapped[Optional[float]] = mapped_column(Numeric(6, 2)) avg_loudness_lufs: Mapped[float | None] = mapped_column(Numeric(6, 2))
peak_loudness_dbfs: Mapped[Optional[float]] = mapped_column(Numeric(6, 2)) peak_loudness_dbfs: Mapped[float | None] = mapped_column(Numeric(6, 2))
spectral_centroid: Mapped[Optional[float]] = mapped_column(Numeric(10, 2)) spectral_centroid: Mapped[float | None] = mapped_column(Numeric(10, 2))
energy: Mapped[Optional[float]] = mapped_column(Numeric(5, 4)) energy: Mapped[float | None] = mapped_column(Numeric(5, 4))
danceability: Mapped[Optional[float]] = mapped_column(Numeric(5, 4)) danceability: Mapped[float | None] = mapped_column(Numeric(5, 4))
chroma_vector: Mapped[Optional[list[float]]] = mapped_column(ARRAY(Numeric)) chroma_vector: Mapped[list[float] | None] = mapped_column(ARRAY(Numeric))
mfcc_mean: Mapped[Optional[list[float]]] = mapped_column(ARRAY(Numeric)) mfcc_mean: Mapped[list[float] | None] = mapped_column(ARRAY(Numeric))
analysis_version: Mapped[Optional[str]] = mapped_column(String(20)) analysis_version: Mapped[str | None] = mapped_column(String(20))
computed_at: Mapped[datetime] = mapped_column( computed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )
@@ -393,9 +403,9 @@ class Job(Base):
payload: Mapped[dict] = mapped_column(JSONB, nullable=False) payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True)
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=0) 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( queued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[Optional[datetime]] = 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.engine import get_session
from rehearsalhub.db.models import Member from rehearsalhub.db.models import Member
from rehearsalhub.services.auth import decode_token
from rehearsalhub.repositories.member import MemberRepository 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 # 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) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)

View File

@@ -1,7 +1,7 @@
"""RehearsalHub FastAPI application entry point.""" """RehearsalHub FastAPI application entry point."""
from contextlib import asynccontextmanager
import os import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -15,8 +15,8 @@ from rehearsalhub.routers import (
annotations_router, annotations_router,
auth_router, auth_router,
bands_router, bands_router,
invites_router,
internal_router, internal_router,
invites_router,
members_router, members_router,
sessions_router, sessions_router,
songs_router, songs_router,

View File

@@ -11,7 +11,7 @@ never reads a job ID that isn't yet visible in the DB.
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone from datetime import UTC, datetime
from typing import Any from typing import Any
import redis.asyncio as aioredis import redis.asyncio as aioredis
@@ -60,7 +60,7 @@ class RedisJobQueue:
job = await self._session.get(Job, job_id) job = await self._session.get(Job, job_id)
if job: if job:
job.status = "running" job.status = "running"
job.started_at = datetime.now(timezone.utc) job.started_at = datetime.now(UTC)
job.attempt = (job.attempt or 0) + 1 job.attempt = (job.attempt or 0) + 1
await self._session.flush() await self._session.flush()
@@ -68,7 +68,7 @@ class RedisJobQueue:
job = await self._session.get(Job, job_id) job = await self._session.get(Job, job_id)
if job: if job:
job.status = "done" job.status = "done"
job.finished_at = datetime.now(timezone.utc) job.finished_at = datetime.now(UTC)
await self._session.flush() await self._session.flush()
async def mark_failed(self, job_id: uuid.UUID, error: str) -> None: async def mark_failed(self, job_id: uuid.UUID, error: str) -> None:
@@ -76,7 +76,7 @@ class RedisJobQueue:
if job: if job:
job.status = "failed" job.status = "failed"
job.error = error[:2000] job.error = error[:2000]
job.finished_at = datetime.now(timezone.utc) job.finished_at = datetime.now(UTC)
await self._session.flush() await self._session.flush()
async def dequeue(self, timeout: int = 5) -> tuple[uuid.UUID, str, dict[str, Any]] | None: 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 from __future__ import annotations
import uuid import uuid
from datetime import UTC
from typing import Any from typing import Any
from sqlalchemy import and_, select from sqlalchemy import and_, select
@@ -31,9 +32,9 @@ class AnnotationRepository(BaseRepository[Annotation]):
return list(result.scalars().all()) return list(result.scalars().all())
async def soft_delete(self, annotation: Annotation) -> None: 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() await self.session.flush()
async def search_ranges( async def search_ranges(
@@ -45,7 +46,7 @@ class AnnotationRepository(BaseRepository[Annotation]):
tag: str | None = None, tag: str | None = None,
min_duration_ms: int | None = None, min_duration_ms: int | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
from rehearsalhub.db.models import AudioVersion, RangeAnalysis, Song from rehearsalhub.db.models import AudioVersion, Song
conditions = [ conditions = [
Song.band_id == band_id, Song.band_id == band_id,

View File

@@ -37,7 +37,7 @@ class AudioVersionRepository(BaseRepository[AudioVersion]):
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_with_annotations(self, version_id: uuid.UUID) -> AudioVersion | 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 = ( stmt = (
select(AudioVersion) select(AudioVersion)

View File

@@ -1,13 +1,12 @@
from __future__ import annotations from __future__ import annotations
import secrets
import uuid import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload 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
from rehearsalhub.repositories.base import BaseRepository from rehearsalhub.repositories.base import BaseRepository
@@ -69,7 +68,7 @@ class BandRepository(BaseRepository[Band]):
token=secrets.token_urlsafe(32), token=secrets.token_urlsafe(32),
role=role, role=role,
created_by=created_by, 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) self.session.add(invite)
await self.session.flush() await self.session.flush()

View File

@@ -3,7 +3,8 @@
from __future__ import annotations from __future__ import annotations
import uuid 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
from rehearsalhub.routers.annotations import router as annotations_router from rehearsalhub.routers.annotations import router as annotations_router
from rehearsalhub.routers.auth import router as auth_router from rehearsalhub.routers.auth import router as auth_router
from rehearsalhub.routers.bands import router as bands_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.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.members import router as members_router
from rehearsalhub.routers.sessions import router as sessions_router from rehearsalhub.routers.sessions import router as sessions_router
from rehearsalhub.routers.songs import router as songs_router from rehearsalhub.routers.songs import router as songs_router

View File

@@ -1,15 +1,15 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session 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.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.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.services.band import BandService
from rehearsalhub.storage.nextcloud import NextcloudClient from rehearsalhub.storage.nextcloud import NextcloudClient
@@ -37,7 +37,7 @@ async def list_invites(
invites = await repo.get_invites_for_band(band_id) invites = await repo.get_invites_for_band(band_id)
# Filter for non-expired invites (optional - could also show expired) # Filter for non-expired invites (optional - could also show expired)
now = datetime.now(timezone.utc) now = datetime.now(UTC)
pending_invites = [ pending_invites = [
invite for invite in invites invite for invite in invites
if invite.expires_at > now and invite.used_at is None if invite.expires_at > now and invite.used_at is None
@@ -93,7 +93,7 @@ async def revoke_invite(
) )
# Check if invite is still pending (not used and not expired) # 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: if invite.used_at is not None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,

View File

@@ -10,11 +10,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.config import get_settings from rehearsalhub.config import get_settings
from rehearsalhub.db.engine import get_session from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import BandMember, Member from rehearsalhub.db.models import AudioVersion, BandMember, Member
from rehearsalhub.repositories.audio_version import AudioVersionRepository from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.band import BandRepository from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository
from rehearsalhub.repositories.song import SongRepository from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.queue.redis_queue import RedisJobQueue
from rehearsalhub.schemas.audio_version import AudioVersionCreate from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.services.session import extract_session_folder, parse_rehearsal_date from rehearsalhub.services.session import extract_session_folder, parse_rehearsal_date
from rehearsalhub.services.song import SongService from rehearsalhub.services.song import SongService
@@ -148,3 +149,37 @@ async def nc_upload(
) )
log.info("nc-upload: registered version %s for song '%s'", version.id, song.title) 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)} return {"status": "ok", "version_id": str(version.id), "song_id": str(song.id)}
@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.
Useful after:
- Fresh DB creation + directory scan (peaks not yet computed)
- Peak algorithm changes (clear waveform_peaks, then call this)
- Worker was down during initial transcode
"""
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. Invite management endpoints.
""" """
import uuid from datetime import UTC, datetime
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session 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.repositories.band import BandRepository
from rehearsalhub.schemas.invite import InviteInfoRead
router = APIRouter(prefix="/invites", tags=["invites"]) router = APIRouter(prefix="/invites", tags=["invites"])
@@ -32,7 +30,7 @@ async def get_invite_info(
) )
# Check if invite is already used or expired # Check if invite is already used or expired
now = datetime.now(timezone.utc) now = datetime.now(UTC)
if invite.used_at is not None: if invite.used_at is not None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,

View File

@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession 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") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
if invite.used_at is not None: if invite.used_at is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite already used") 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") raise HTTPException(status_code=status.HTTP_410_GONE, detail="Invite expired")
# Idempotent — already a member # 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) bm = await repo.add_member(invite.band_id, current_member.id, role=invite.role)
# Mark invite as used # Mark invite as used
invite.used_at = datetime.now(timezone.utc) invite.used_at = datetime.now(UTC)
invite.used_by = current_member.id invite.used_by = current_member.id
await session.flush() await session.flush()
@@ -123,8 +123,9 @@ async def accept_invite(
@router.get("/invites/{token}", response_model=BandInviteRead) @router.get("/invites/{token}", response_model=BandInviteRead)
async def get_invite(token: str, session: AsyncSession = Depends(get_session)): async def get_invite(token: str, session: AsyncSession = Depends(get_session)):
"""Preview invite info (band name etc.) before accepting — no auth required.""" """Preview invite info (band name etc.) before accepting — no auth required."""
from sqlalchemy.orm import selectinload
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from rehearsalhub.db.models import BandInvite from rehearsalhub.db.models import BandInvite
stmt = select(BandInvite).options(selectinload(BandInvite.band)).where(BandInvite.token == token) stmt = select(BandInvite).options(selectinload(BandInvite.band)).where(BandInvite.token == token)
result = await session.execute(stmt) result = await session.execute(stmt)

View File

@@ -1,7 +1,6 @@
import json import json
import logging import logging
import uuid import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -11,10 +10,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session, get_session_factory from rehearsalhub.db.engine import get_session, get_session_factory
from rehearsalhub.db.models import Member from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_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 import BandRepository
from rehearsalhub.repositories.comment import CommentRepository from rehearsalhub.repositories.comment import CommentRepository
from rehearsalhub.repositories.song import SongRepository from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.routers.versions import _member_from_request
from rehearsalhub.schemas.comment import SongCommentCreate, SongCommentRead from rehearsalhub.schemas.comment import SongCommentCreate, SongCommentRead
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate
from rehearsalhub.services.band import BandService from rehearsalhub.services.band import BandService

View File

@@ -1,5 +1,5 @@
import uuid
import asyncio import asyncio
import uuid
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -180,49 +180,27 @@ async def create_version(
@router.get("/versions/{version_id}/waveform") @router.get("/versions/{version_id}/waveform")
async def get_waveform( async def get_waveform(
version_id: uuid.UUID, version_id: uuid.UUID,
resolution: str = Query("full", pattern="^(full|mini)$"),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member), current_member: Member = Depends(get_current_member),
) -> Any: ) -> 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) 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") 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 return {"version": 2, "channels": 1, "length": len(peaks), "data": peaks}
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)
@router.get("/versions/{version_id}/stream") @router.get("/versions/{version_id}/stream")

View File

@@ -4,8 +4,8 @@ import uuid
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from rehearsalhub.repositories.member import MemberRepository
from rehearsalhub.db.engine import get_session from rehearsalhub.db.engine import get_session
from rehearsalhub.repositories.member import MemberRepository
from rehearsalhub.services.auth import decode_token from rehearsalhub.services.auth import decode_token
from rehearsalhub.ws import manager 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.audio_version import AudioVersionCreate, AudioVersionRead
from rehearsalhub.schemas.auth import LoginRequest, RegisterRequest, TokenResponse 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.member import MemberRead
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate

View File

@@ -22,6 +22,8 @@ class AudioVersionRead(BaseModel):
nc_file_etag: str | None = None nc_file_etag: str | None = None
cdn_hls_base: str | None = None cdn_hls_base: str | None = None
waveform_url: 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 duration_ms: int | None = None
format: str | None = None format: str | None = None
file_size_bytes: int | None = None file_size_bytes: int | None = None

View File

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

View File

@@ -1,8 +1,7 @@
import uuid import uuid
from datetime import datetime 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): class MemberBase(BaseModel):
@@ -23,7 +22,7 @@ class MemberRead(MemberBase):
def from_model(cls, m: object) -> "MemberRead": def from_model(cls, m: object) -> "MemberRead":
obj = cls.model_validate(m) obj = cls.model_validate(m)
obj.nc_configured = bool( obj.nc_configured = bool(
getattr(m, "nc_url") and getattr(m, "nc_username") and getattr(m, "nc_password") m.nc_url and m.nc_username and m.nc_password
) )
return obj return obj

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.models import Band from rehearsalhub.db.models import Band
from rehearsalhub.repositories.band import BandRepository from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.schemas.band import BandCreate, BandReadWithMembers from rehearsalhub.schemas.band import BandCreate
from rehearsalhub.storage.nextcloud import NextcloudClient from rehearsalhub.storage.nextcloud import NextcloudClient
log = logging.getLogger(__name__) log = logging.getLogger(__name__)

View File

@@ -3,13 +3,12 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import AsyncGenerator
from pathlib import Path from pathlib import Path
from typing import AsyncGenerator
from urllib.parse import unquote from urllib.parse import unquote
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.models import Member
from rehearsalhub.repositories.audio_version import AudioVersionRepository from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository
from rehearsalhub.repositories.song import SongRepository from rehearsalhub.repositories.song import SongRepository

View File

@@ -9,7 +9,7 @@ from rehearsalhub.queue.redis_queue import RedisJobQueue
from rehearsalhub.repositories.audio_version import AudioVersionRepository from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.song import SongRepository from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate from rehearsalhub.schemas.song import SongCreate, SongRead
from rehearsalhub.storage.nextcloud import NextcloudClient from rehearsalhub.storage.nextcloud import NextcloudClient

View File

@@ -8,7 +8,6 @@ from typing import Any
import httpx import httpx
from rehearsalhub.config import get_settings
from rehearsalhub.storage.protocol import FileMetadata from rehearsalhub.storage.protocol import FileMetadata
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,7 +29,7 @@ class NextcloudClient:
self._dav_root = f"{self._base}/remote.php/dav/files/{self._auth[0]}" self._dav_root = f"{self._base}/remote.php/dav/files/{self._auth[0]}"
@classmethod @classmethod
def for_member(cls, member: object) -> "NextcloudClient | None": def for_member(cls, member: object) -> NextcloudClient | None:
"""Return a client using member's personal NC credentials if configured. """Return a client using member's personal NC credentials if configured.
Returns None if member has no Nextcloud configuration.""" Returns None if member has no Nextcloud configuration."""
nc_url = getattr(member, "nc_url", None) nc_url = getattr(member, "nc_url", None)

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,120 @@
"""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)),
):
with 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)),
):
with 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] [package.dev-dependencies]
dev = [ dev = [
{ name = "httpx" }, { name = "httpx" },
{ name = "mypy" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "ruff" },
] ]
[package.metadata] [package.metadata]
@@ -1382,8 +1384,10 @@ provides-extras = ["dev"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "mypy", specifier = ">=1.19.1" },
{ name = "pytest", specifier = ">=9.0.2" }, { name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "ruff", specifier = ">=0.15.8" },
] ]
[[package]] [[package]]

View File

@@ -25,6 +25,7 @@ services:
build: build:
context: ./api context: ./api
target: development target: development
command: sh -c "alembic upgrade head && python3 -m uvicorn rehearsalhub.main:app --host 0.0.0.0 --port 8000 --reload"
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub} 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_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}

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] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
[dependency-groups]
dev = [
"ruff>=0.15.10",
]

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 { 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 "./index.css";
import { isLoggedIn } from "./api/client"; import { isLoggedIn } from "./api/client";
import { AppShell } from "./components/AppShell"; import { AppShell } from "./components/AppShell";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { HomePage } from "./pages/HomePage"; import { HomePage } from "./pages/HomePage";
import { BandPage } from "./pages/BandPage"; import { BandPage } from "./pages/BandPage";
import { BandSettingsPage } from "./pages/BandSettingsPage";
import { SessionPage } from "./pages/SessionPage"; import { SessionPage } from "./pages/SessionPage";
import { SongPage } from "./pages/SongPage"; import { SongPage } from "./pages/SongPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { InvitePage } from "./pages/InvitePage"; import { InvitePage } from "./pages/InvitePage";
import { useBandStore } from "./stores/bandStore";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } }, 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() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -51,18 +66,8 @@ export default function App() {
</ShellRoute> </ShellRoute>
} }
/> />
<Route <Route path="/bands/:bandId/settings" element={<BandSettingsRedirect />} />
path="/bands/:bandId/settings" <Route path="/bands/:bandId/settings/:panel" element={<BandSettingsRedirect />} />
element={<Navigate to="members" replace />}
/>
<Route
path="/bands/:bandId/settings/:panel"
element={
<ShellRoute>
<BandSettingsPage />
</ShellRoute>
}
/>
<Route <Route
path="/bands/:bandId/sessions/:sessionId" path="/bands/:bandId/sessions/:sessionId"
element={ element={

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 { useNavigate, useLocation, matchPath } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { listBands } from "../api/bands";
import { api } from "../api/client"; import { api } from "../api/client";
import { logout } from "../api/auth"; import { logout } from "../api/auth";
import { getInitials } from "../utils"; import { getInitials } from "../utils";
import type { MemberRead } from "../api/auth"; import type { MemberRead } from "../api/auth";
import { usePlayerStore } from "../stores/playerStore"; import { usePlayerStore } from "../stores/playerStore";
import { useBandStore } from "../stores/bandStore";
import { TopBandBar } from "./TopBandBar";
// ── Icons (inline SVG) ────────────────────────────────────────────────────── // ── Icons ────────────────────────────────────────────────────────────────────
function IconWaveform() {
function IconMenu() {
return ( return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect x="1" y="1.5" width="12" height="2" rx="1" fill="white" opacity=".9" /> <path d="M3 5h12M3 9h12M3 13h8" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
<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> </svg>
); );
} }
function IconLibrary() { function IconLibrary() {
return ( return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M2 3.5h10v1.5H2zm0 3h10v1.5H2zm0 3h7v1.5H2z" /> <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> </svg>
); );
} }
function IconPlay() { function IconPlay() {
return ( return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
<path d="M3 2l9 5-9 5V2z" /> <path d="M5 3l11 6-11 6V3z" />
</svg> </svg>
); );
} }
function IconSettings() { function IconSettings() {
return ( return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="7" cy="7" r="2" /> <circle cx="9" cy="9" r="2.5" stroke="currentColor" strokeWidth="1.4" />
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13" /> <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 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> </svg>
); );
} }
function IconSignOut() { function IconSignOut() {
return ( return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"> <svg width="15" height="15" viewBox="0 0 15 15" 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="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="M9 10l3-3-3-3M12 7H5" /> <path d="M10 10.5l3-3-3-3M13 7.5H6" />
</svg> </svg>
); );
} }
// ── NavItem ───────────────────────────────────────────────────────────────── // ── NavItem ─────────────────────────────────────────────────────────────────
interface NavItemProps { interface NavItemProps {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
active: boolean; active: boolean;
onClick: () => void; onClick: () => void;
disabled?: boolean; 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 [hovered, setHovered] = useState(false);
const color = active const fg = active
? "#e8a22a" ? "#2dd4bf"
: disabled : disabled
? "rgba(255,255,255,0.18)" ? "rgba(255,255,255,0.16)"
: hovered : hovered
? "rgba(255,255,255,0.7)" ? "rgba(232,233,240,0.7)"
: "rgba(255,255,255,0.35)"; : "rgba(232,233,240,0.35)";
const bg = active const bg = active
? "rgba(232,162,42,0.12)" ? "rgba(20,184,166,0.12)"
: hovered && !disabled : hovered && !disabled
? "rgba(255,255,255,0.045)" ? "rgba(255,255,255,0.04)"
: "transparent"; : "transparent";
return ( return (
@@ -115,51 +90,69 @@ function NavItem({ icon, label, active, onClick, disabled }: NavItemProps) {
disabled={disabled} disabled={disabled}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
title={collapsed ? label : undefined}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 9, gap: 10,
width: "100%", width: "100%",
padding: "7px 10px", padding: "9px 10px",
borderRadius: 7, borderRadius: 8,
border: "none", border: "none",
cursor: disabled ? "default" : "pointer", cursor: disabled ? "default" : "pointer",
color, color: fg,
background: bg, background: bg,
fontSize: 12,
textAlign: "left", textAlign: "left",
marginBottom: 1, transition: "background 0.15s, color 0.15s",
transition: "background 0.12s, color 0.12s",
fontFamily: "inherit", 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} {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> </button>
); );
} }
// ── Sidebar ──────────────────────────────────────────────────────────────── // ── Sidebar ───────────────────────────────────────────────────────────────────
export function Sidebar({ children }: { children: React.ReactNode }) { export function Sidebar({ children }: { children: React.ReactNode }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [dropdownOpen, setDropdownOpen] = useState(false); const [collapsed, setCollapsed] = useState(true);
const dropdownRef = useRef<HTMLDivElement>(null);
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
const { data: me } = useQuery({ const { data: me } = useQuery({
queryKey: ["me"], queryKey: ["me"],
queryFn: () => api.get<MemberRead>("/auth/me"), queryFn: () => api.get<MemberRead>("/auth/me"),
}); });
// Derive active band from the current URL const { activeBandId } = useBandStore();
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;
// Nav active states
const isLibrary = !!( const isLibrary = !!(
matchPath({ path: "/bands/:bandId", end: true }, location.pathname) || matchPath({ path: "/bands/:bandId", end: true }, location.pathname) ||
matchPath("/bands/:bandId/sessions/:sessionId", 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 isPlayer = !!matchPath("/bands/:bandId/songs/:songId", location.pathname);
const isSettings = location.pathname.startsWith("/settings"); 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 { currentSongId, currentBandId: playerBandId, isPlaying: isPlayerPlaying } = usePlayerStore();
const hasActiveSong = !!currentSongId && !!playerBandId; const hasActiveSong = !!currentSongId && !!playerBandId;
// Close dropdown on outside click const sidebarWidth = collapsed ? 68 : 230;
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 border = "rgba(255,255,255,0.06)"; const border = "rgba(255,255,255,0.06)";
return ( return (
<div <div style={{ display: "flex", height: "100vh", overflow: "hidden", background: "#080f0d", color: "#e8e9f0", fontFamily: "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif", fontSize: 13 }}>
style={{
display: "flex", {/* ── Sidebar ── */}
height: "100vh", <aside style={{
overflow: "hidden", width: sidebarWidth,
background: "#0f0f12", minWidth: sidebarWidth,
color: "#eeeef2", background: "#0c1612",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif",
fontSize: 13,
}}
>
{/* ── Sidebar ──────────────────────────────────────────────────── */}
<aside
style={{
width: 210,
minWidth: 210,
background: "#0b0b0e",
borderRight: `1px solid ${border}`, borderRight: `1px solid ${border}`,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
overflow: "hidden", overflow: "hidden",
}} transition: "width 0.22s cubic-bezier(0.4,0,0.2,1), min-width 0.22s cubic-bezier(0.4,0,0.2,1)",
>
{/* Logo */}
<div
style={{
padding: "17px 14px 14px",
display: "flex",
alignItems: "center",
gap: 10,
borderBottom: `1px solid ${border}`,
flexShrink: 0, flexShrink: 0,
}} zIndex: 20,
> }}>
<div
{/* 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={{ style={{
width: 28, width: 40, height: 40, borderRadius: 12, flexShrink: 0,
height: 28, background: "linear-gradient(135deg, #0d9488, #06b6d4)",
background: "#e8a22a", display: "flex", alignItems: "center", justifyContent: "center",
borderRadius: 7, border: "none", cursor: "pointer",
display: "flex", boxShadow: "0 0 20px rgba(20,184,166,0.3)",
alignItems: "center", transition: "box-shadow 0.2s",
justifyContent: "center",
flexShrink: 0,
}} }}
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 /> <IconMenu />
</div> </button>
<div> {!collapsed && (
<div style={{ fontSize: 13, fontWeight: 600, color: "#eeeef2", letterSpacing: -0.2 }}> <div style={{ overflow: "hidden" }}>
<div style={{ fontSize: 13, fontWeight: 700, color: "#e8e9f0", letterSpacing: -0.3, whiteSpace: "nowrap" }}>
RehearsalHub RehearsalHub
</div> </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>
)} )}
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav style={{ flex: 1, padding: "10px 8px", overflowY: "auto" }}> <nav style={{ flex: 1, padding: "10px 12px", overflowY: "auto", overflowX: "hidden", display: "flex", flexDirection: "column", gap: 2 }}>
{activeBand && ( {activeBandId && (
<> <>
<SectionLabel>{activeBand.name}</SectionLabel> <NavItem icon={<IconLibrary />} label="Library" active={isLibrary} onClick={() => navigate(`/bands/${activeBandId}`)} collapsed={collapsed} />
<NavItem
icon={<IconLibrary />}
label="Library"
active={isLibrary}
onClick={() => navigate(`/bands/${activeBand.id}`)}
/>
<NavItem <NavItem
icon={<IconPlay />} icon={<IconPlay />}
label="Player" label="Now Playing"
active={hasActiveSong && (isPlayer || isPlayerPlaying)} active={hasActiveSong && (isPlayer || isPlayerPlaying)}
onClick={() => { onClick={() => { if (hasActiveSong) navigate(`/bands/${playerBandId}/songs/${currentSongId}`); }}
if (hasActiveSong) {
navigate(`/bands/${playerBandId}/songs/${currentSongId}`);
}
}}
disabled={!hasActiveSong} disabled={!hasActiveSong}
collapsed={collapsed}
/> />
</> </>
)} )}
{activeBand && ( <div style={{ height: 1, background: border, margin: "10px 0", flexShrink: 0 }} />
<> <NavItem icon={<IconSettings />} label="Settings" active={isSettings} onClick={() => navigate("/settings")} collapsed={collapsed} />
<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")}
/>
</nav> </nav>
{/* User row */} {/* User row */}
<div <div style={{ padding: "10px 12px", borderTop: `1px solid ${border}`, flexShrink: 0 }}>
style={{
padding: "10px",
borderTop: `1px solid ${border}`,
flexShrink: 0,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}> <div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<button <button
onClick={() => navigate("/settings")} onClick={() => navigate("/settings")}
style={{ title={collapsed ? (me?.display_name ?? "Account") : undefined}
flex: 1, 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" }}
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",
}}
> >
{me?.avatar_url ? ( {me?.avatar_url ? (
<img <img src={me.avatar_url} alt="" style={{ width: 28, height: 28, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} />
src={me.avatar_url}
alt=""
style={{
width: 28,
height: 28,
borderRadius: "50%",
objectFit: "cover",
flexShrink: 0,
}}
/>
) : ( ) : (
<div <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 }}>
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,
}}
>
{getInitials(me?.display_name ?? "?")} {getInitials(me?.display_name ?? "?")}
</div> </div>
)} )}
<span {!collapsed && (
style={{ <span style={{ flex: 1, fontSize: 12, color: "rgba(232,233,240,0.55)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
flex: 1,
fontSize: 12,
color: "rgba(255,255,255,0.55)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{me?.display_name ?? "…"} {me?.display_name ?? "…"}
</span> </span>
)}
</button> </button>
{!collapsed && (
<button <button
onClick={() => logout()} onClick={() => logout()}
title="Sign out" title="Sign out"
style={{ 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 }}
flexShrink: 0, onMouseEnter={(e) => { e.currentTarget.style.borderColor = "rgba(255,255,255,0.1)"; e.currentTarget.style.color = "rgba(255,255,255,0.5)"; }}
width: 30, onMouseLeave={(e) => { e.currentTarget.style.borderColor = "transparent"; e.currentTarget.style.color = "rgba(255,255,255,0.2)"; }}
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 /> <IconSignOut />
</button> </button>
)}
</div> </div>
</div> </div>
</aside> </aside>
{/* ── Main content ──────────────────────────────────────────────── */} {/* ── Main content ── */}
<main <main style={{ flex: 1, overflow: "hidden", display: "grid", gridTemplateRows: "44px 1fr", background: "#080f0d", minWidth: 0 }}>
style={{ <TopBandBar />
flex: 1, <div style={{ overflow: "auto", display: "flex", flexDirection: "column" }}>
overflow: "auto",
display: "flex",
flexDirection: "column",
background: "#0f0f12",
}}
>
{children} {children}
</div>
</main> </main>
</div> </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,467 @@
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";
import { api } from "../api/client";
// ── 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,
};
// ── Step indicator ─────────────────────────────────────────────────────────────
function StepDots({ current, total }: { current: number; total: number }) {
return (
<div style={{ display: "flex", gap: 5, alignItems: "center" }}>
{Array.from({ length: total }, (_, i) => (
<div
key={i}
style={{
width: i === current ? 16 : 6,
height: 6,
borderRadius: 3,
background: i === current ? "#14b8a6" : i < current ? "rgba(20,184,166,0.4)" : "rgba(255,255,255,0.12)",
transition: "all 0.2s",
}}
/>
))}
</div>
);
}
// ── 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>
);
}
// ── Step 1: Storage setup ──────────────────────────────────────────────────────
interface Me { nc_configured: boolean; nc_url: string | null; nc_username: string | null; }
function StorageStep({ me, onNext }: { me: Me; onNext: () => void }) {
const qc = useQueryClient();
const [ncUrl, setNcUrl] = useState(me.nc_url ?? "");
const [ncUsername, setNcUsername] = useState(me.nc_username ?? "");
const [ncPassword, setNcPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const urlRef = useRef<HTMLInputElement>(null);
useEffect(() => { urlRef.current?.focus(); }, []);
const saveMutation = useMutation({
mutationFn: () =>
api.patch("/auth/me/settings", {
nc_url: ncUrl.trim() || null,
nc_username: ncUsername.trim() || null,
nc_password: ncPassword || null,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["me"] });
onNext();
},
onError: (err) => setError(err instanceof Error ? err.message : "Failed to save"),
});
const canSave = ncUrl.trim() && ncUsername.trim() && ncPassword;
return (
<>
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>NEXTCLOUD URL</label>
<input
ref={urlRef}
value={ncUrl}
onChange={(e) => setNcUrl(e.target.value)}
style={inputStyle}
placeholder="https://cloud.example.com"
type="url"
/>
</div>
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>USERNAME</label>
<input
value={ncUsername}
onChange={(e) => setNcUsername(e.target.value)}
style={inputStyle}
placeholder="your-nc-username"
autoComplete="username"
/>
</div>
<div style={{ marginBottom: 4 }}>
<label style={labelStyle}>APP PASSWORD</label>
<input
value={ncPassword}
onChange={(e) => setNcPassword(e.target.value)}
style={inputStyle}
type="password"
placeholder="Generate one in Nextcloud → Settings → Security"
autoComplete="current-password"
/>
</div>
<p style={{ margin: "0 0 20px", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
Use an app password, not your account password.
</p>
{error && <ErrorBanner msg={error} />}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button
onClick={onNext}
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" }}
>
Skip for now
</button>
<button
onClick={() => saveMutation.mutate()}
disabled={!canSave || saveMutation.isPending}
style={{ padding: "8px 18px", background: canSave ? "#14b8a6" : "rgba(20,184,166,0.3)", border: "none", borderRadius: 7, color: canSave ? "#fff" : "rgba(255,255,255,0.4)", cursor: canSave ? "pointer" : "default", fontSize: 13, fontWeight: 600, fontFamily: "inherit" }}
>
{saveMutation.isPending ? "Saving…" : "Save & Continue"}
</button>
</div>
</>
);
}
// ── Step 2: Band details ───────────────────────────────────────────────────────
function BandStep({ ncConfigured, onClose }: { ncConfigured: boolean; onClose: () => void }) {
const navigate = useNavigate();
const qc = useQueryClient();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [ncFolder, setNcFolder] = useState("");
const [error, setError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => { nameRef.current?.focus(); }, []);
const mutation = useMutation({
mutationFn: () =>
createBand({
name,
slug,
...(ncFolder.trim() ? { nc_base_path: ncFolder.trim() } : {}),
}),
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 (
<>
{!ncConfigured && (
<div style={{ marginBottom: 18, padding: "9px 12px", background: "rgba(251,191,36,0.07)", border: "1px solid rgba(251,191,36,0.2)", borderRadius: 7, fontSize: 12, color: "rgba(251,191,36,0.8)", lineHeight: 1.5 }}>
Storage not configured recordings won't be scanned. You can set it up later in Settings Storage.
</div>
)}
{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: 20 }}>
<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>
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", paddingTop: 18, marginBottom: 22 }}>
<label style={labelStyle}>
NEXTCLOUD FOLDER{" "}
<span style={{ color: "rgba(232,233,240,0.25)", fontWeight: 400, letterSpacing: 0 }}>(optional)</span>
</label>
<input
value={ncFolder}
onChange={(e) => setNcFolder(e.target.value)}
style={{ ...inputStyle, fontFamily: "monospace" }}
placeholder={slug ? `bands/${slug}/` : "bands/my-band/"}
disabled={!ncConfigured}
/>
<p style={{ margin: "7px 0 0", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
{ncConfigured
? <>Leave blank to auto-create <code style={{ color: "rgba(232,233,240,0.45)", fontFamily: "monospace" }}>bands/{slug || "slug"}/</code>.</>
: "Connect storage first to set a folder."}
</p>
</div>
<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 (orchestrates steps) ─────────────────────────────────────
function CreateBandModal({ onClose }: { onClose: () => void }) {
const { data: me, isLoading } = useQuery<Me>({
queryKey: ["me"],
queryFn: () => api.get("/auth/me"),
});
// Start on step 0 (storage) if NC not configured, otherwise jump straight to step 1 (band)
const [step, setStep] = useState<0 | 1 | null>(null);
useEffect(() => {
if (me && step === null) setStep(me.nc_configured ? 1 : 0);
}, [me, step]);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
const totalSteps = me?.nc_configured === false ? 2 : 1;
const currentDot = step === 0 ? 0 : totalSteps - 1;
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)" }}
>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 18 }}>
<div>
<h3 style={{ margin: "0 0 3px", fontSize: 15, fontWeight: 600, color: "#e8e9f0" }}>
{step === 0 ? "Connect storage" : "New band"}
</h3>
<p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.4)" }}>
{step === 0 ? "Needed to scan and index your recordings." : "Create a workspace for your recordings."}
</p>
</div>
{totalSteps > 1 && step !== null && (
<StepDots current={currentDot} total={totalSteps} />
)}
</div>
{isLoading || step === null ? (
<p style={{ color: "rgba(232,233,240,0.3)", fontSize: 13 }}>Loading</p>
) : step === 0 ? (
<StorageStep me={me!} onNext={() => setStep(1)} />
) : (
<BandStep ncConfigured={me?.nc_configured ?? false} 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 { export interface UseWaveformOptions {
url: string | null; url: string | null;
peaksUrl: string | null; peaksUrl: string | null;
peaks?: number[] | null;
onReady?: (duration: number) => void; onReady?: (duration: number) => void;
onTimeUpdate?: (currentTime: number) => void; onTimeUpdate?: (currentTime: number) => void;
songId?: string | null; songId?: string | null;
@@ -39,7 +40,7 @@ export function useWaveform(
const initializeAudio = async () => { const initializeAudio = async () => {
try { 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. // 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 // Read as a one-time snapshot — these values must NOT be reactive deps or
@@ -77,7 +78,12 @@ export function useWaveform(
}; };
initializeAudio(); 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 = () => { const play = () => {
audioService.play(options.songId ?? null, options.bandId ?? null) 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) ─────────────────────── */ /* ── Design system (dark only — no light mode in v1) ─────────────────────── */
:root { :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-subtle: rgba(255,255,255,0.025);
--bg-inset: rgba(255,255,255,0.04); --bg-inset: rgba(255,255,255,0.04);
--border: rgba(255,255,255,0.08); --border: rgba(255,255,255,0.06);
--border-subtle: rgba(255,255,255,0.05); --border-bright: rgba(255,255,255,0.12);
--text: #eeeef2; --border-subtle: rgba(255,255,255,0.04);
--text-muted: rgba(255,255,255,0.35); --text: #e8e9f0;
--text-subtle: rgba(255,255,255,0.22); --text-muted: rgba(232,233,240,0.55);
--accent: #e8a22a; --text-subtle: rgba(232,233,240,0.28);
--accent-hover: #f0b740; /* Teal accent */
--accent-bg: rgba(232,162,42,0.1); --accent: #14b8a6;
--accent-border: rgba(232,162,42,0.28); --accent-light: #2dd4bf;
--accent-fg: #0f0f12; --accent-hover: #10a89a;
--teal: #4dba85; --accent-bg: rgba(20,184,166,0.12);
--teal-bg: rgba(61,200,120,0.1); --accent-border: rgba(20,184,166,0.3);
--danger: #e07070; --accent-fg: #ffffff;
--danger-bg: rgba(220,80,80,0.1); --teal: #34d399;
--teal-bg: rgba(52,211,153,0.1);
--danger: #f43f5e;
--danger-bg: rgba(244,63,94,0.1);
} }
/* ── Responsive Layout ──────────────────────────────────────────────────── */ /* ── Responsive Layout ──────────────────────────────────────────────────── */
@@ -46,7 +53,7 @@ input, textarea, button, select {
/* Bottom Navigation Bar */ /* Bottom Navigation Bar */
nav[style*="position: fixed"] { nav[style*="position: fixed"] {
display: flex; display: flex;
background: #0b0b0e; background: #060d0b;
border-top: 1px solid rgba(255, 255, 255, 0.06); border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 8px 16px; padding: 8px 16px;
} }

View File

@@ -1,50 +1,16 @@
import { useState, useMemo } from "react"; import { useParams, useSearchParams } from "react-router-dom";
import { useParams, Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getBand } from "../api/bands"; import { getBand } from "../api/bands";
import { api } from "../api/client"; import { LibraryPanel } from "../components/LibraryPanel";
import { PlayerPanel } from "../components/PlayerPanel";
interface SongSummary { // ── BandPage ──────────────────────────────────────────────────────────────────
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" });
}
export function BandPage() { export function BandPage() {
const { bandId } = useParams<{ bandId: string }>(); const { bandId } = useParams<{ bandId: string }>();
const [librarySearch, setLibrarySearch] = useState(""); const [searchParams, setSearchParams] = useSearchParams();
const [activePill, setActivePill] = useState<FilterPill>("all"); // 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({ const { data: band, isLoading } = useQuery({
queryKey: ["band", bandId], queryKey: ["band", bandId],
@@ -52,239 +18,37 @@ export function BandPage() {
enabled: !!bandId, enabled: !!bandId,
}); });
const { data: sessions } = useQuery({ function selectSong(songId: string) {
queryKey: ["sessions", bandId], setSearchParams({ song: songId }, { replace: false });
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`), }
enabled: !!bandId,
});
const { data: unattributedSongs } = useQuery({ function clearSong() {
queryKey: ["songs-unattributed", bandId], setSearchParams({}, { replace: false });
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?unattributed=true`), }
enabled: !!bandId,
});
const filteredSessions = useMemo(() => { if (isLoading) return <div style={{ color: "rgba(232,233,240,0.35)", padding: 32 }}>Loading</div>;
return (sessions ?? []).filter((s) => { if (!band || !bandId) return <div style={{ color: "#f87171", padding: 32 }}>Band not found</div>;
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 (selectedSongId) {
return ( return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", maxWidth: 760, margin: "0 auto" }}> <div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
<PlayerPanel
{/* ── Header ─────────────────────────────────────────────── */} key={selectedSongId}
<div style={{ padding: "18px 26px 0", flexShrink: 0, borderBottom: "1px solid rgba(255,255,255,0.06)" }}> songId={selectedSongId}
{/* Title row + search + actions */} bandId={bandId}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 11 }}> onBack={clearSong}
<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> </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 // For use in tests only
public static resetInstance(): void { public static resetInstance(): void {
this.instance?.cleanup(); this.instance?.cleanup();
this.instance = undefined as any; this.instance = undefined as unknown as AudioService;
} }
private createMediaElement(): HTMLAudioElement { private createMediaElement(): HTMLAudioElement {
@@ -41,7 +41,7 @@ class AudioService {
return el; 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 (!container) throw new Error('Container element is required');
if (!url) throw new Error('Valid audio URL 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 // Fresh audio element per song. Lives on document.body so playback
// continues even when the SongPage container is removed from the DOM. // continues even when the SongPage container is removed from the DOM.
media: this.mediaElement, media: this.mediaElement,
waveColor: "rgba(255,255,255,0.09)", waveColor: "rgba(20,184,166,0.18)",
progressColor: "#c8861a", progressColor: "#14b8a6",
cursorColor: "#e8a22a", cursorColor: "#2dd4bf",
barWidth: 2, barWidth: 2,
barRadius: 2, barRadius: 2,
height: 104, height: 104,
@@ -98,7 +98,14 @@ class AudioService {
ws.on('ready', () => { onReady().catch(reject); }); ws.on('ready', () => { onReady().catch(reject); });
ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err)))); 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); 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

@@ -34,3 +34,20 @@ packages = ["src/worker"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] 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 functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict

View File

@@ -6,7 +6,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Optional 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.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 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)) nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text) cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
waveform_url: 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) duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
format: Mapped[Optional[str]] = mapped_column(String(10)) format: Mapped[Optional[str]] = mapped_column(String(10))
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger) file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)

View File

@@ -13,7 +13,7 @@ from pathlib import Path
import librosa import librosa
import numpy as np import numpy as np
import redis.asyncio as aioredis 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 sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from worker.config import get_settings from worker.config import get_settings
@@ -21,7 +21,7 @@ from worker.db import AudioVersionModel, JobModel
from worker.pipeline.analyse_full import run_full_analysis from worker.pipeline.analyse_full import run_full_analysis
from worker.pipeline.analyse_range import run_range_analysis from worker.pipeline.analyse_range import run_range_analysis
from worker.pipeline.transcode import get_duration_ms, transcode_to_hls 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=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("worker") log = logging.getLogger("worker")
@@ -59,20 +59,23 @@ async def handle_transcode(payload: dict, session: AsyncSession, settings) -> No
hls_dir = os.path.join(tmp, "hls") hls_dir = os.path.join(tmp, "hls")
await transcode_to_hls(local_path, hls_dir) await transcode_to_hls(local_path, hls_dir)
waveform_path = os.path.join(tmp, "waveform.json") # Generate waveform peaks at two resolutions:
await generate_waveform_file(audio, waveform_path) # - 500-point full peaks passed to WaveSurfer for instant render in player
# - 100-point mini peaks for the library/overview SVG thumbnail
# TODO: Upload HLS segments and waveform back to Nextcloud / object storage loop = asyncio.get_event_loop()
# For now, store the local tmp path in the DB (replace with real upload logic) peaks_500 = await loop.run_in_executor(None, extract_peaks, audio, 500)
hls_nc_path = f"hls/{version_id}" peaks_100 = await loop.run_in_executor(None, extract_peaks, audio, 100)
waveform_nc_path = f"waveforms/{version_id}.json"
# 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 = ( stmt = (
update(AudioVersionModel) update(AudioVersionModel)
.where(AudioVersionModel.id == version_id) .where(AudioVersionModel.id == version_id)
.values( .values(
cdn_hls_base=hls_nc_path, waveform_peaks=peaks_500,
waveform_url=waveform_nc_path, waveform_peaks_mini=peaks_100,
duration_ms=duration_ms, duration_ms=duration_ms,
analysis_status="running", analysis_status="running",
) )
@@ -102,9 +105,41 @@ async def handle_analyse_range(payload: dict, session: AsyncSession, settings) -
log.info("Range analysis complete for annotation %s", annotation_id) 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(nc_path, 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 = { HANDLERS = {
"transcode": handle_transcode, "transcode": handle_transcode,
"analyse_range": handle_analyse_range, "analyse_range": handle_analyse_range,
"extract_peaks": handle_extract_peaks,
} }

View File

@@ -27,6 +27,7 @@ async def run_full_analysis(
fields: dict[str, Any] = {**bpm_result.fields, **key_result.fields} fields: dict[str, Any] = {**bpm_result.fields, **key_result.fields}
from sqlalchemy import update from sqlalchemy import update
from worker.db import AudioVersionModel from worker.db import AudioVersionModel
global_bpm = fields.get("bpm") global_bpm = fields.get("bpm")

View File

@@ -5,10 +5,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os import os
import shutil
import subprocess
import tempfile
from pathlib import Path
async def transcode_to_hls(input_path: str, output_dir: str) -> str: async def transcode_to_hls(input_path: str, output_dir: str) -> str:

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 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): def test_extract_peaks_normalized_between_0_and_1(sine_440hz):
audio, sr = sine_440hz audio, sr = sine_440hz
peaks = extract_peaks(audio, num_points=200) 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) 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(): def test_extract_peaks_empty_audio():
audio = np.array([], dtype=np.float32) audio = np.array([], dtype=np.float32)
peaks = extract_peaks(audio, num_points=100) 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) 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): def test_peaks_to_json_valid_structure(sine_440hz):
audio, _ = sine_440hz audio, _ = sine_440hz
peaks = extract_peaks(audio) 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: with open(output) as f:
data = json.load(f) data = json.load(f)
assert data["version"] == 2 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" }, { name = "ruff" },
] ]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "asyncpg", specifier = ">=0.29" }, { name = "asyncpg", specifier = ">=0.29" },
@@ -1023,6 +1028,9 @@ requires-dist = [
] ]
provides-extras = ["dev"] provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.8" }]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.33.1" version = "2.33.1"