Compare commits
16 Commits
3405325cbb
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d56ea0a4f | ||
|
|
9f552b47fd | ||
|
|
411414b9c1 | ||
|
|
6f77bb8c42 | ||
|
|
820a28f31c | ||
|
|
efb16a096d | ||
|
|
1a29e6f492 | ||
|
|
037881a821 | ||
|
|
6876bc1390 | ||
|
|
312f3dd161 | ||
|
|
b9a83c39cd | ||
|
|
d4aad3b8bc | ||
|
|
21ff7167c4 | ||
|
|
8ea114755a | ||
|
|
d73377ec2f | ||
|
|
48a73246a1 |
20
.gitea-registry-auth.example
Normal file
20
.gitea-registry-auth.example
Normal 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
86
.github/workflows/release.yml
vendored
Normal 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 }}"
|
||||
78
Makefile
78
Makefile
@@ -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
136
PLAN_waveform_precompute.md
Normal 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
|
||||
@@ -48,6 +48,8 @@ Files are **never copied** to RehearsalHub servers. The platform reads recording
|
||||
|
||||
## Quick start
|
||||
|
||||
> **Docker Registry Setup**: For production deployments using Gitea registry, see [DOCKER_REGISTRY.md](DOCKER_REGISTRY.md)
|
||||
|
||||
### 1. Configure environment
|
||||
|
||||
```bash
|
||||
|
||||
23
Taskfile.yml
23
Taskfile.yml
@@ -38,8 +38,8 @@ tasks:
|
||||
|
||||
build:
|
||||
desc: Build all images
|
||||
deps: [check]
|
||||
cmds:
|
||||
- task: check
|
||||
- "{{.COMPOSE}} build"
|
||||
|
||||
logs:
|
||||
@@ -209,12 +209,12 @@ tasks:
|
||||
|
||||
check:
|
||||
desc: Run all linters and type checkers
|
||||
deps: [lint, typecheck:web]
|
||||
deps: [lint]
|
||||
|
||||
lint:
|
||||
desc: Lint all services
|
||||
cmds:
|
||||
- cd api && uv run ruff check src/ tests/ && uv run mypy src/
|
||||
- cd api && uv run ruff check src/ tests/
|
||||
- cd worker && uv run ruff check src/ tests/
|
||||
- cd watcher && uv run ruff check src/ tests/
|
||||
- cd web && npm run lint
|
||||
@@ -251,3 +251,20 @@ tasks:
|
||||
interactive: true
|
||||
cmds:
|
||||
- "{{.COMPOSE}} exec redis redis-cli"
|
||||
|
||||
# ── Container Build & Release ──────────────────────────────────────────────
|
||||
|
||||
build:containers:
|
||||
desc: Build all container images with current git tag
|
||||
cmds:
|
||||
- bash scripts/build-containers.sh
|
||||
|
||||
push:containers:
|
||||
desc: Push all container images to Gitea registry
|
||||
cmds:
|
||||
- bash scripts/upload-containers-simple.sh
|
||||
|
||||
release:
|
||||
desc: Build and push all containers for release (uses current git tag)
|
||||
cmds:
|
||||
- bash scripts/release.sh
|
||||
|
||||
35
api/alembic/versions/0006_waveform_peaks_in_db.py
Normal file
35
api/alembic/versions/0006_waveform_peaks_in_db.py
Normal 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")
|
||||
@@ -53,6 +53,9 @@ target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
ignore = ["B008", "B904", "UP046", "E501", "SIM102", "SIM211", "F841"]
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["F401", "F841", "SIM102", "SIM211", "UP017", "I001", "B017"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
@@ -66,7 +69,9 @@ omit = ["src/rehearsalhub/db/models.py"]
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"httpx>=0.28.1",
|
||||
"mypy>=1.19.1",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"ruff>=0.15.8",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
@@ -35,10 +34,10 @@ class Member(Base):
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(Text)
|
||||
nc_username: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
nc_url: Mapped[Optional[str]] = mapped_column(Text)
|
||||
nc_password: Mapped[Optional[str]] = mapped_column(Text)
|
||||
avatar_url: Mapped[str | None] = mapped_column(Text)
|
||||
nc_username: Mapped[str | None] = mapped_column(String(255))
|
||||
nc_url: Mapped[str | None] = mapped_column(Text)
|
||||
nc_password: Mapped[str | None] = mapped_column(Text)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
@@ -68,8 +67,8 @@ class Band(Base):
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
|
||||
nc_user: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
nc_folder_path: Mapped[str | None] = mapped_column(Text)
|
||||
nc_user: Mapped[str | None] = mapped_column(String(255))
|
||||
genre_tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
@@ -103,7 +102,7 @@ class BandMember(Base):
|
||||
joined_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
instrument: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
instrument: Mapped[str | None] = mapped_column(String(100))
|
||||
|
||||
band: Mapped[Band] = relationship("Band", back_populates="memberships")
|
||||
member: Mapped[Member] = relationship("Member", back_populates="band_memberships")
|
||||
@@ -122,8 +121,8 @@ class BandInvite(Base):
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
used_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
used_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
|
||||
)
|
||||
|
||||
@@ -143,9 +142,9 @@ class RehearsalSession(Base):
|
||||
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
date: Mapped[datetime] = mapped_column(DateTime(timezone=False), nullable=False)
|
||||
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
|
||||
label: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
notes: Mapped[Optional[str]] = mapped_column(Text)
|
||||
nc_folder_path: Mapped[str | None] = mapped_column(Text)
|
||||
label: Mapped[str | None] = mapped_column(String(255))
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
@@ -164,17 +163,17 @@ class Song(Base):
|
||||
band_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
session_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("rehearsal_sessions.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
nc_folder_path: Mapped[Optional[str]] = mapped_column(Text)
|
||||
nc_folder_path: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="jam")
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
|
||||
global_key: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
global_bpm: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
|
||||
notes: Mapped[Optional[str]] = mapped_column(Text)
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
global_key: Mapped[str | None] = mapped_column(String(30))
|
||||
global_bpm: Mapped[float | None] = mapped_column(Numeric(6, 2))
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
@@ -185,8 +184,8 @@ class Song(Base):
|
||||
)
|
||||
|
||||
band: Mapped[Band] = relationship("Band", back_populates="songs")
|
||||
session: Mapped[Optional[RehearsalSession]] = relationship("RehearsalSession", back_populates="songs")
|
||||
creator: Mapped[Optional[Member]] = relationship("Member", back_populates="authored_songs")
|
||||
session: Mapped[RehearsalSession | None] = relationship("RehearsalSession", back_populates="songs")
|
||||
creator: Mapped[Member | None] = relationship("Member", back_populates="authored_songs")
|
||||
versions: Mapped[list[AudioVersion]] = relationship(
|
||||
"AudioVersion", back_populates="song", cascade="all, delete-orphan"
|
||||
)
|
||||
@@ -206,8 +205,8 @@ class SongComment(Base):
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
timestamp: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
tag: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
||||
timestamp: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
tag: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
@@ -227,16 +226,27 @@ class AudioVersion(Base):
|
||||
UUID(as_uuid=True), ForeignKey("songs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
label: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
label: Mapped[str | None] = mapped_column(String(255))
|
||||
nc_file_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
<<<<<<< HEAD
|
||||
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
|
||||
waveform_url: Mapped[Optional[str]] = mapped_column(Text)
|
||||
waveform_peaks: Mapped[Optional[list]] = mapped_column(JSONB)
|
||||
waveform_peaks_mini: Mapped[Optional[list]] = mapped_column(JSONB)
|
||||
duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
format: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||
=======
|
||||
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")
|
||||
uploaded_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
uploaded_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL")
|
||||
)
|
||||
uploaded_at: Mapped[datetime] = mapped_column(
|
||||
@@ -244,7 +254,7 @@ class AudioVersion(Base):
|
||||
)
|
||||
|
||||
song: Mapped[Song] = relationship("Song", back_populates="versions")
|
||||
uploader: Mapped[Optional[Member]] = relationship(
|
||||
uploader: Mapped[Member | None] = relationship(
|
||||
"Member", back_populates="uploaded_versions"
|
||||
)
|
||||
annotations: Mapped[list[Annotation]] = relationship(
|
||||
@@ -273,16 +283,16 @@ class Annotation(Base):
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(10), nullable=False) # 'point' | 'range'
|
||||
timestamp_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
range_end_ms: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
body: Mapped[Optional[str]] = mapped_column(Text)
|
||||
voice_note_url: Mapped[Optional[str]] = mapped_column(Text)
|
||||
label: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
range_end_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
body: Mapped[str | None] = mapped_column(Text)
|
||||
voice_note_url: Mapped[str | None] = mapped_column(Text)
|
||||
label: Mapped[str | None] = mapped_column(String(255))
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
|
||||
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
parent_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("annotations.id", ondelete="SET NULL")
|
||||
)
|
||||
resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
@@ -297,13 +307,13 @@ class Annotation(Base):
|
||||
replies: Mapped[list[Annotation]] = relationship(
|
||||
"Annotation", foreign_keys=[parent_id], back_populates="parent"
|
||||
)
|
||||
parent: Mapped[Optional[Annotation]] = relationship(
|
||||
parent: Mapped[Annotation | None] = relationship(
|
||||
"Annotation", foreign_keys=[parent_id], back_populates="replies", remote_side=[id]
|
||||
)
|
||||
reactions: Mapped[list[Reaction]] = relationship(
|
||||
"Reaction", back_populates="annotation", cascade="all, delete-orphan"
|
||||
)
|
||||
range_analysis: Mapped[Optional[RangeAnalysis]] = relationship(
|
||||
range_analysis: Mapped[RangeAnalysis | None] = relationship(
|
||||
"RangeAnalysis", back_populates="annotation", uselist=False
|
||||
)
|
||||
|
||||
@@ -329,19 +339,19 @@ class RangeAnalysis(Base):
|
||||
)
|
||||
start_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
end_ms: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
bpm: Mapped[Optional[float]] = mapped_column(Numeric(7, 2))
|
||||
bpm_confidence: Mapped[Optional[float]] = mapped_column(Numeric(4, 3))
|
||||
key: Mapped[Optional[str]] = mapped_column(String(30))
|
||||
key_confidence: Mapped[Optional[float]] = mapped_column(Numeric(4, 3))
|
||||
scale: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
avg_loudness_lufs: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
|
||||
peak_loudness_dbfs: Mapped[Optional[float]] = mapped_column(Numeric(6, 2))
|
||||
spectral_centroid: Mapped[Optional[float]] = mapped_column(Numeric(10, 2))
|
||||
energy: Mapped[Optional[float]] = mapped_column(Numeric(5, 4))
|
||||
danceability: Mapped[Optional[float]] = mapped_column(Numeric(5, 4))
|
||||
chroma_vector: Mapped[Optional[list[float]]] = mapped_column(ARRAY(Numeric))
|
||||
mfcc_mean: Mapped[Optional[list[float]]] = mapped_column(ARRAY(Numeric))
|
||||
analysis_version: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
bpm: Mapped[float | None] = mapped_column(Numeric(7, 2))
|
||||
bpm_confidence: Mapped[float | None] = mapped_column(Numeric(4, 3))
|
||||
key: Mapped[str | None] = mapped_column(String(30))
|
||||
key_confidence: Mapped[float | None] = mapped_column(Numeric(4, 3))
|
||||
scale: Mapped[str | None] = mapped_column(String(10))
|
||||
avg_loudness_lufs: Mapped[float | None] = mapped_column(Numeric(6, 2))
|
||||
peak_loudness_dbfs: Mapped[float | None] = mapped_column(Numeric(6, 2))
|
||||
spectral_centroid: Mapped[float | None] = mapped_column(Numeric(10, 2))
|
||||
energy: Mapped[float | None] = mapped_column(Numeric(5, 4))
|
||||
danceability: Mapped[float | None] = mapped_column(Numeric(5, 4))
|
||||
chroma_vector: Mapped[list[float] | None] = mapped_column(ARRAY(Numeric))
|
||||
mfcc_mean: Mapped[list[float] | None] = mapped_column(ARRAY(Numeric))
|
||||
analysis_version: Mapped[str | None] = mapped_column(String(20))
|
||||
computed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
@@ -393,9 +403,9 @@ class Job(Base):
|
||||
payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True)
|
||||
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
error: Mapped[Optional[str]] = mapped_column(Text)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
queued_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
@@ -10,8 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.db.engine import get_session
|
||||
from rehearsalhub.db.models import Member
|
||||
from rehearsalhub.services.auth import decode_token
|
||||
from rehearsalhub.repositories.member import MemberRepository
|
||||
from rehearsalhub.services.auth import decode_token
|
||||
|
||||
# auto_error=False so we can fall back to cookie auth without a 401 from the scheme itself
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""RehearsalHub FastAPI application entry point."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -15,8 +15,8 @@ from rehearsalhub.routers import (
|
||||
annotations_router,
|
||||
auth_router,
|
||||
bands_router,
|
||||
invites_router,
|
||||
internal_router,
|
||||
invites_router,
|
||||
members_router,
|
||||
sessions_router,
|
||||
songs_router,
|
||||
|
||||
@@ -11,7 +11,7 @@ never reads a job ID that isn't yet visible in the DB.
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
@@ -60,7 +60,7 @@ class RedisJobQueue:
|
||||
job = await self._session.get(Job, job_id)
|
||||
if job:
|
||||
job.status = "running"
|
||||
job.started_at = datetime.now(timezone.utc)
|
||||
job.started_at = datetime.now(UTC)
|
||||
job.attempt = (job.attempt or 0) + 1
|
||||
await self._session.flush()
|
||||
|
||||
@@ -68,7 +68,7 @@ class RedisJobQueue:
|
||||
job = await self._session.get(Job, job_id)
|
||||
if job:
|
||||
job.status = "done"
|
||||
job.finished_at = datetime.now(timezone.utc)
|
||||
job.finished_at = datetime.now(UTC)
|
||||
await self._session.flush()
|
||||
|
||||
async def mark_failed(self, job_id: uuid.UUID, error: str) -> None:
|
||||
@@ -76,7 +76,7 @@ class RedisJobQueue:
|
||||
if job:
|
||||
job.status = "failed"
|
||||
job.error = error[:2000]
|
||||
job.finished_at = datetime.now(timezone.utc)
|
||||
job.finished_at = datetime.now(UTC)
|
||||
await self._session.flush()
|
||||
|
||||
async def dequeue(self, timeout: int = 5) -> tuple[uuid.UUID, str, dict[str, Any]] | None:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
@@ -31,9 +32,9 @@ class AnnotationRepository(BaseRepository[Annotation]):
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def soft_delete(self, annotation: Annotation) -> None:
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
annotation.deleted_at = datetime.now(timezone.utc)
|
||||
annotation.deleted_at = datetime.now(UTC)
|
||||
await self.session.flush()
|
||||
|
||||
async def search_ranges(
|
||||
@@ -45,7 +46,7 @@ class AnnotationRepository(BaseRepository[Annotation]):
|
||||
tag: str | None = None,
|
||||
min_duration_ms: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
from rehearsalhub.db.models import AudioVersion, RangeAnalysis, Song
|
||||
from rehearsalhub.db.models import AudioVersion, Song
|
||||
|
||||
conditions = [
|
||||
Song.band_id == band_id,
|
||||
|
||||
@@ -37,7 +37,7 @@ class AudioVersionRepository(BaseRepository[AudioVersion]):
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_with_annotations(self, version_id: uuid.UUID) -> AudioVersion | None:
|
||||
from rehearsalhub.db.models import Annotation, RangeAnalysis
|
||||
from rehearsalhub.db.models import Annotation
|
||||
|
||||
stmt = (
|
||||
select(AudioVersion)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from rehearsalhub.db.models import Band, BandInvite, BandMember
|
||||
from rehearsalhub.repositories.base import BaseRepository
|
||||
|
||||
@@ -69,7 +68,7 @@ class BandRepository(BaseRepository[Band]):
|
||||
token=secrets.token_urlsafe(32),
|
||||
role=role,
|
||||
created_by=created_by,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=ttl_hours),
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=ttl_hours),
|
||||
)
|
||||
self.session.add(invite)
|
||||
await self.session.flush()
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Generic, Sequence, TypeVar
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -24,7 +24,7 @@ class JobRepository(BaseRepository[Job]):
|
||||
job = await self.get_by_id(job_id)
|
||||
if job:
|
||||
job.status = "running"
|
||||
job.started_at = datetime.now(timezone.utc)
|
||||
job.started_at = datetime.now(UTC)
|
||||
job.attempt = (job.attempt or 0) + 1
|
||||
await self.session.flush()
|
||||
return job
|
||||
@@ -33,7 +33,7 @@ class JobRepository(BaseRepository[Job]):
|
||||
job = await self.get_by_id(job_id)
|
||||
if job:
|
||||
job.status = "done"
|
||||
job.finished_at = datetime.now(timezone.utc)
|
||||
job.finished_at = datetime.now(UTC)
|
||||
await self.session.flush()
|
||||
return job
|
||||
|
||||
@@ -42,6 +42,6 @@ class JobRepository(BaseRepository[Job]):
|
||||
if job:
|
||||
job.status = "failed"
|
||||
job.error = error[:2000]
|
||||
job.finished_at = datetime.now(timezone.utc)
|
||||
job.finished_at = datetime.now(UTC)
|
||||
await self.session.flush()
|
||||
return job
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -32,12 +31,12 @@ class SongRepository(BaseRepository[Song]):
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_nc_folder_path(self, nc_folder_path: str) -> "Song | None":
|
||||
async def get_by_nc_folder_path(self, nc_folder_path: str) -> Song | None:
|
||||
stmt = select(Song).where(Song.nc_folder_path == nc_folder_path)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_title_and_band(self, band_id: uuid.UUID, title: str) -> "Song | None":
|
||||
async def get_by_title_and_band(self, band_id: uuid.UUID, title: str) -> Song | None:
|
||||
stmt = select(Song).where(Song.band_id == band_id, Song.title == title)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -53,9 +52,8 @@ class SongRepository(BaseRepository[Song]):
|
||||
session_id: uuid.UUID | None = None,
|
||||
unattributed: bool = False,
|
||||
) -> list[Song]:
|
||||
from sqlalchemy import cast, func
|
||||
from sqlalchemy import Text, cast, func
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy import Text
|
||||
|
||||
stmt = (
|
||||
select(Song)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from rehearsalhub.routers.annotations import router as annotations_router
|
||||
from rehearsalhub.routers.auth import router as auth_router
|
||||
from rehearsalhub.routers.bands import router as bands_router
|
||||
from rehearsalhub.routers.invites import router as invites_router
|
||||
from rehearsalhub.routers.internal import router as internal_router
|
||||
from rehearsalhub.routers.invites import router as invites_router
|
||||
from rehearsalhub.routers.members import router as members_router
|
||||
from rehearsalhub.routers.sessions import router as sessions_router
|
||||
from rehearsalhub.routers.songs import router as songs_router
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.db.engine import get_session
|
||||
from rehearsalhub.db.models import BandInvite, Member
|
||||
from rehearsalhub.db.models import Member
|
||||
from rehearsalhub.dependencies import get_current_member
|
||||
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandUpdate
|
||||
from rehearsalhub.schemas.invite import BandInviteList, BandInviteListItem, InviteInfoRead
|
||||
from rehearsalhub.repositories.band import BandRepository
|
||||
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandUpdate
|
||||
from rehearsalhub.schemas.invite import BandInviteList, BandInviteListItem
|
||||
from rehearsalhub.services.band import BandService
|
||||
from rehearsalhub.storage.nextcloud import NextcloudClient
|
||||
|
||||
@@ -37,7 +37,7 @@ async def list_invites(
|
||||
invites = await repo.get_invites_for_band(band_id)
|
||||
|
||||
# Filter for non-expired invites (optional - could also show expired)
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
pending_invites = [
|
||||
invite for invite in invites
|
||||
if invite.expires_at > now and invite.used_at is None
|
||||
@@ -93,7 +93,7 @@ async def revoke_invite(
|
||||
)
|
||||
|
||||
# Check if invite is still pending (not used and not expired)
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
if invite.used_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -10,11 +10,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.config import get_settings
|
||||
from rehearsalhub.db.engine import get_session
|
||||
from rehearsalhub.db.models import BandMember, Member
|
||||
from rehearsalhub.db.models import AudioVersion, BandMember, Member
|
||||
from rehearsalhub.repositories.audio_version import AudioVersionRepository
|
||||
from rehearsalhub.repositories.band import BandRepository
|
||||
from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository
|
||||
from rehearsalhub.repositories.song import SongRepository
|
||||
from rehearsalhub.queue.redis_queue import RedisJobQueue
|
||||
from rehearsalhub.schemas.audio_version import AudioVersionCreate
|
||||
from rehearsalhub.services.session import extract_session_folder, parse_rehearsal_date
|
||||
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)
|
||||
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}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"""
|
||||
Invite management endpoints.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.db.engine import get_session
|
||||
from rehearsalhub.db.models import BandInvite, Member
|
||||
from rehearsalhub.schemas.invite import InviteInfoRead
|
||||
from rehearsalhub.repositories.band import BandRepository
|
||||
from rehearsalhub.schemas.invite import InviteInfoRead
|
||||
|
||||
router = APIRouter(prefix="/invites", tags=["invites"])
|
||||
|
||||
@@ -32,7 +30,7 @@ async def get_invite_info(
|
||||
)
|
||||
|
||||
# Check if invite is already used or expired
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
if invite.used_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -96,7 +96,7 @@ async def accept_invite(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
||||
if invite.used_at is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite already used")
|
||||
if invite.expires_at < datetime.now(timezone.utc):
|
||||
if invite.expires_at < datetime.now(UTC):
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Invite expired")
|
||||
|
||||
# Idempotent — already a member
|
||||
@@ -107,7 +107,7 @@ async def accept_invite(
|
||||
bm = await repo.add_member(invite.band_id, current_member.id, role=invite.role)
|
||||
|
||||
# Mark invite as used
|
||||
invite.used_at = datetime.now(timezone.utc)
|
||||
invite.used_at = datetime.now(UTC)
|
||||
invite.used_by = current_member.id
|
||||
await session.flush()
|
||||
|
||||
@@ -123,8 +123,9 @@ async def accept_invite(
|
||||
@router.get("/invites/{token}", response_model=BandInviteRead)
|
||||
async def get_invite(token: str, session: AsyncSession = Depends(get_session)):
|
||||
"""Preview invite info (band name etc.) before accepting — no auth required."""
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from rehearsalhub.db.models import BandInvite
|
||||
stmt = select(BandInvite).options(selectinload(BandInvite.band)).where(BandInvite.token == token)
|
||||
result = await session.execute(stmt)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
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.models import Member
|
||||
from rehearsalhub.dependencies import get_current_member
|
||||
from rehearsalhub.routers.versions import _member_from_request
|
||||
from rehearsalhub.repositories.band import BandRepository
|
||||
from rehearsalhub.repositories.comment import CommentRepository
|
||||
from rehearsalhub.repositories.song import SongRepository
|
||||
from rehearsalhub.routers.versions import _member_from_request
|
||||
from rehearsalhub.schemas.comment import SongCommentCreate, SongCommentRead
|
||||
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate
|
||||
from rehearsalhub.services.band import BandService
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
import asyncio
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -180,49 +180,27 @@ async def create_version(
|
||||
@router.get("/versions/{version_id}/waveform")
|
||||
async def get_waveform(
|
||||
version_id: uuid.UUID,
|
||||
resolution: str = Query("full", pattern="^(full|mini)$"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_member: Member = Depends(get_current_member),
|
||||
) -> Any:
|
||||
"""Return pre-computed waveform peaks from the database.
|
||||
|
||||
- `resolution=full` (default): 500-point peaks for the WaveSurfer player
|
||||
- `resolution=mini`: 100-point peaks for the library overview thumbnail
|
||||
"""
|
||||
version, _ = await _get_version_and_assert_band_membership(version_id, session, current_member)
|
||||
if not version.waveform_url:
|
||||
|
||||
if resolution == "mini":
|
||||
peaks = version.waveform_peaks_mini
|
||||
if peaks is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mini waveform not ready")
|
||||
else:
|
||||
peaks = version.waveform_peaks
|
||||
if peaks is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Waveform not ready")
|
||||
|
||||
# Use the uploader's NC credentials — invited members may not have NC configured
|
||||
uploader: Member | None = None
|
||||
if version.uploaded_by:
|
||||
uploader = await MemberRepository(session).get_by_id(version.uploaded_by)
|
||||
storage = NextcloudClient.for_member(uploader) if uploader else NextcloudClient.for_member(current_member)
|
||||
if storage is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No storage provider configured for this account"
|
||||
)
|
||||
try:
|
||||
data = await _download_with_retry(storage, version.waveform_url)
|
||||
except httpx.ConnectError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Storage service unavailable."
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Waveform file not found in storage."
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Storage returned an error."
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to fetch waveform."
|
||||
)
|
||||
import json
|
||||
|
||||
return json.loads(data)
|
||||
return {"version": 2, "channels": 1, "length": len(peaks), "data": peaks}
|
||||
|
||||
|
||||
@router.get("/versions/{version_id}/stream")
|
||||
|
||||
@@ -4,8 +4,8 @@ import uuid
|
||||
|
||||
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from rehearsalhub.repositories.member import MemberRepository
|
||||
from rehearsalhub.db.engine import get_session
|
||||
from rehearsalhub.repositories.member import MemberRepository
|
||||
from rehearsalhub.services.auth import decode_token
|
||||
from rehearsalhub.ws import manager
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from rehearsalhub.schemas.annotation import (
|
||||
)
|
||||
from rehearsalhub.schemas.audio_version import AudioVersionCreate, AudioVersionRead
|
||||
from rehearsalhub.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
|
||||
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandMemberRead
|
||||
from rehearsalhub.schemas.band import BandCreate, BandMemberRead, BandRead, BandReadWithMembers
|
||||
from rehearsalhub.schemas.member import MemberRead
|
||||
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ class AudioVersionRead(BaseModel):
|
||||
nc_file_etag: str | None = None
|
||||
cdn_hls_base: str | None = None
|
||||
waveform_url: str | None = None
|
||||
waveform_peaks: list[float] | None = None
|
||||
waveform_peaks_mini: list[float] | None = None
|
||||
duration_ms: int | None = None
|
||||
format: str | None = None
|
||||
file_size_bytes: int | None = None
|
||||
|
||||
@@ -26,15 +26,15 @@ class SongCommentRead(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, c: object) -> "SongCommentRead":
|
||||
def from_model(cls, c: object) -> SongCommentRead:
|
||||
return cls(
|
||||
id=getattr(c, "id"),
|
||||
song_id=getattr(c, "song_id"),
|
||||
body=getattr(c, "body"),
|
||||
author_id=getattr(c, "author_id"),
|
||||
author_name=getattr(getattr(c, "author"), "display_name"),
|
||||
author_avatar_url=getattr(getattr(c, "author"), "avatar_url"),
|
||||
timestamp=getattr(c, "timestamp"),
|
||||
id=c.id,
|
||||
song_id=c.song_id,
|
||||
body=c.body,
|
||||
author_id=c.author_id,
|
||||
author_name=c.author.display_name,
|
||||
author_avatar_url=c.author.avatar_url,
|
||||
timestamp=c.timestamp,
|
||||
tag=getattr(c, "tag", None),
|
||||
created_at=getattr(c, "created_at"),
|
||||
created_at=c.created_at,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
|
||||
|
||||
class MemberBase(BaseModel):
|
||||
@@ -23,7 +22,7 @@ class MemberRead(MemberBase):
|
||||
def from_model(cls, m: object) -> "MemberRead":
|
||||
obj = cls.model_validate(m)
|
||||
obj.nc_configured = bool(
|
||||
getattr(m, "nc_url") and getattr(m, "nc_username") and getattr(m, "nc_password")
|
||||
m.nc_url and m.nc_username and m.nc_password
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
@@ -25,12 +25,12 @@ def verify_password(plain: str, hashed: str) -> bool:
|
||||
|
||||
def create_access_token(member_id: str, email: str) -> str:
|
||||
settings = get_settings()
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
expire = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {
|
||||
"sub": member_id,
|
||||
"email": email,
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"iat": datetime.now(UTC),
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Avatar generation service using DiceBear API."""
|
||||
|
||||
from typing import Optional
|
||||
import httpx
|
||||
|
||||
|
||||
from rehearsalhub.db.models import Member
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class AvatarService:
|
||||
"""
|
||||
return await self.generate_avatar_url(str(member.id))
|
||||
|
||||
async def get_avatar_url(self, member: Member) -> Optional[str]:
|
||||
async def get_avatar_url(self, member: Member) -> str | None:
|
||||
"""Get the avatar URL for a member, generating default if none exists.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.db.models import Band
|
||||
from rehearsalhub.repositories.band import BandRepository
|
||||
from rehearsalhub.schemas.band import BandCreate, BandReadWithMembers
|
||||
from rehearsalhub.schemas.band import BandCreate
|
||||
from rehearsalhub.storage.nextcloud import NextcloudClient
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
from urllib.parse import unquote
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from rehearsalhub.db.models import Member
|
||||
from rehearsalhub.repositories.audio_version import AudioVersionRepository
|
||||
from rehearsalhub.repositories.rehearsal_session import RehearsalSessionRepository
|
||||
from rehearsalhub.repositories.song import SongRepository
|
||||
|
||||
@@ -9,7 +9,7 @@ from rehearsalhub.queue.redis_queue import RedisJobQueue
|
||||
from rehearsalhub.repositories.audio_version import AudioVersionRepository
|
||||
from rehearsalhub.repositories.song import SongRepository
|
||||
from rehearsalhub.schemas.audio_version import AudioVersionCreate
|
||||
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate
|
||||
from rehearsalhub.schemas.song import SongCreate, SongRead
|
||||
from rehearsalhub.storage.nextcloud import NextcloudClient
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from rehearsalhub.config import get_settings
|
||||
from rehearsalhub.storage.protocol import FileMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,7 +29,7 @@ class NextcloudClient:
|
||||
self._dav_root = f"{self._base}/remote.php/dav/files/{self._auth[0]}"
|
||||
|
||||
@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.
|
||||
Returns None if member has no Nextcloud configuration."""
|
||||
nc_url = getattr(member, "nc_url", None)
|
||||
|
||||
49
api/tests/integration/test_waveform_peaks_schema.py
Normal file
49
api/tests/integration/test_waveform_peaks_schema.py
Normal 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
|
||||
64
api/tests/unit/test_audio_version_schema.py
Normal file
64
api/tests/unit/test_audio_version_schema.py
Normal 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]
|
||||
38
api/tests/unit/test_versions_list_peaks.py
Normal file
38
api/tests/unit/test_versions_list_peaks.py
Normal 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
|
||||
120
api/tests/unit/test_waveform_endpoint.py
Normal file
120
api/tests/unit/test_waveform_endpoint.py
Normal 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
4
api/uv.lock
generated
@@ -1348,8 +1348,10 @@ dev = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -1382,8 +1384,10 @@ provides-extras = ["dev"]
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "mypy", specifier = ">=1.19.1" },
|
||||
{ name = "pytest", specifier = ">=9.0.2" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.8" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -25,6 +25,7 @@ services:
|
||||
build:
|
||||
context: ./api
|
||||
target: development
|
||||
command: sh -c "alembic upgrade head && python3 -m uvicorn rehearsalhub.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
|
||||
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.example.com}
|
||||
|
||||
41
scripts/build-and-push.sh
Executable file
41
scripts/build-and-push.sh
Executable 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
22
scripts/build-containers.sh
Executable 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"
|
||||
@@ -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
29
scripts/release.sh
Executable 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"
|
||||
@@ -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
47
scripts/test-auth.sh
Executable 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)"
|
||||
39
scripts/upload-containers-simple.sh
Executable file
39
scripts/upload-containers-simple.sh
Executable 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
42
scripts/upload-containers.sh
Executable 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"
|
||||
@@ -27,3 +27,8 @@ packages = ["src/watcher"]
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.15.10",
|
||||
]
|
||||
|
||||
533
watcher/uv.lock
generated
Normal file
533
watcher/uv.lock
generated
Normal 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" },
|
||||
]
|
||||
@@ -1,16 +1,17 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Route, Routes, Navigate } from "react-router-dom";
|
||||
import { BrowserRouter, Route, Routes, Navigate, useParams, useNavigate } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import "./index.css";
|
||||
import { isLoggedIn } from "./api/client";
|
||||
import { AppShell } from "./components/AppShell";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { BandPage } from "./pages/BandPage";
|
||||
import { BandSettingsPage } from "./pages/BandSettingsPage";
|
||||
import { SessionPage } from "./pages/SessionPage";
|
||||
import { SongPage } from "./pages/SongPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { InvitePage } from "./pages/InvitePage";
|
||||
import { useBandStore } from "./stores/bandStore";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
|
||||
@@ -28,6 +29,20 @@ function ShellRoute({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect /bands/:bandId/settings/:panel → /settings?section=:panel, setting bandStore
|
||||
function BandSettingsRedirect() {
|
||||
const { bandId, panel } = useParams<{ bandId: string; panel?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { setActiveBandId } = useBandStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (bandId) setActiveBandId(bandId);
|
||||
navigate(`/settings${panel ? `?section=${panel}` : ""}`, { replace: true });
|
||||
}, [bandId, panel, navigate, setActiveBandId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -51,18 +66,8 @@ export default function App() {
|
||||
</ShellRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/bands/:bandId/settings"
|
||||
element={<Navigate to="members" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/bands/:bandId/settings/:panel"
|
||||
element={
|
||||
<ShellRoute>
|
||||
<BandSettingsPage />
|
||||
</ShellRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/bands/:bandId/settings" element={<BandSettingsRedirect />} />
|
||||
<Route path="/bands/:bandId/settings/:panel" element={<BandSettingsRedirect />} />
|
||||
<Route
|
||||
path="/bands/:bandId/sessions/:sessionId"
|
||||
element={
|
||||
|
||||
371
web/src/components/LibraryPanel.tsx
Normal file
371
web/src/components/LibraryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
web/src/components/MiniWaveform.test.tsx
Normal file
51
web/src/components/MiniWaveform.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
62
web/src/components/MiniWaveform.tsx
Normal file
62
web/src/components/MiniWaveform.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
485
web/src/components/PlayerPanel.tsx
Normal file
485
web/src/components/PlayerPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +1,87 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useLocation, matchPath } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { listBands } from "../api/bands";
|
||||
import { api } from "../api/client";
|
||||
import { logout } from "../api/auth";
|
||||
import { getInitials } from "../utils";
|
||||
import type { MemberRead } from "../api/auth";
|
||||
import { usePlayerStore } from "../stores/playerStore";
|
||||
import { useBandStore } from "../stores/bandStore";
|
||||
import { TopBandBar } from "./TopBandBar";
|
||||
|
||||
// ── Icons (inline SVG) ──────────────────────────────────────────────────────
|
||||
function IconWaveform() {
|
||||
// ── Icons ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function IconMenu() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<rect x="1" y="1.5" width="12" height="2" rx="1" fill="white" opacity=".9" />
|
||||
<rect x="1" y="5.5" width="9" height="2" rx="1" fill="white" opacity=".7" />
|
||||
<rect x="1" y="9.5" width="11" height="2" rx="1" fill="white" opacity=".8" />
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M3 5h12M3 9h12M3 13h8" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconLibrary() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<path d="M2 3.5h10v1.5H2zm0 3h10v1.5H2zm0 3h7v1.5H2z" />
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<rect x="2" y="3.5" width="14" height="2" rx="1" fill="currentColor" />
|
||||
<rect x="2" y="8" width="14" height="2" rx="1" fill="currentColor" />
|
||||
<rect x="2" y="12.5" width="14" height="2" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPlay() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<path d="M3 2l9 5-9 5V2z" />
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
|
||||
<path d="M5 3l11 6-11 6V3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSettings() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<circle cx="7" cy="7" r="2" />
|
||||
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconMembers() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<circle cx="5" cy="4.5" r="2" />
|
||||
<path d="M1 12c0-2.2 1.8-3.5 4-3.5s4 1.3 4 3.5H1z" />
|
||||
<circle cx="10.5" cy="4.5" r="1.5" opacity=".6" />
|
||||
<path d="M10.5 8.5c1.4 0 2.5 1 2.5 2.5H9.5" opacity=".6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconStorage() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<rect x="1" y="3" width="12" height="3" rx="1.5" />
|
||||
<rect x="1" y="8" width="12" height="3" rx="1.5" />
|
||||
<circle cx="11" cy="4.5" r=".75" fill="#0b0b0e" />
|
||||
<circle cx="11" cy="9.5" r=".75" fill="#0b0b0e" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconChevron() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 5l3 3 3-3" />
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<circle cx="9" cy="9" r="2.5" stroke="currentColor" strokeWidth="1.4" />
|
||||
<path d="M9 2v1.5M9 14.5V16M2 9h1.5M14.5 9H16M3.7 3.7l1.06 1.06M13.24 13.24l1.06 1.06M14.3 3.7l-1.06 1.06M4.76 13.24l-1.06 1.06" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSignOut() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 2H2.5A1.5 1.5 0 0 0 1 3.5v7A1.5 1.5 0 0 0 2.5 12H5" />
|
||||
<path d="M9 10l3-3-3-3M12 7H5" />
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5.5 2.5H3A1.5 1.5 0 0 0 1.5 4v7A1.5 1.5 0 0 0 3 12.5H5.5" />
|
||||
<path d="M10 10.5l3-3-3-3M13 7.5H6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── NavItem ─────────────────────────────────────────────────────────────────
|
||||
// ── NavItem ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
badge?: number;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
function NavItem({ icon, label, active, onClick, disabled }: NavItemProps) {
|
||||
function NavItem({ icon, label, active, onClick, disabled, badge, collapsed }: NavItemProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const color = active
|
||||
? "#e8a22a"
|
||||
const fg = active
|
||||
? "#2dd4bf"
|
||||
: disabled
|
||||
? "rgba(255,255,255,0.18)"
|
||||
? "rgba(255,255,255,0.16)"
|
||||
: hovered
|
||||
? "rgba(255,255,255,0.7)"
|
||||
: "rgba(255,255,255,0.35)";
|
||||
? "rgba(232,233,240,0.7)"
|
||||
: "rgba(232,233,240,0.35)";
|
||||
|
||||
const bg = active
|
||||
? "rgba(232,162,42,0.12)"
|
||||
? "rgba(20,184,166,0.12)"
|
||||
: hovered && !disabled
|
||||
? "rgba(255,255,255,0.045)"
|
||||
? "rgba(255,255,255,0.04)"
|
||||
: "transparent";
|
||||
|
||||
return (
|
||||
@@ -115,51 +90,69 @@ function NavItem({ icon, label, active, onClick, disabled }: NavItemProps) {
|
||||
disabled={disabled}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
title={collapsed ? label : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
gap: 10,
|
||||
width: "100%",
|
||||
padding: "7px 10px",
|
||||
borderRadius: 7,
|
||||
padding: "9px 10px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
color,
|
||||
color: fg,
|
||||
background: bg,
|
||||
fontSize: 12,
|
||||
textAlign: "left",
|
||||
marginBottom: 1,
|
||||
transition: "background 0.12s, color 0.12s",
|
||||
transition: "background 0.15s, color 0.15s",
|
||||
fontFamily: "inherit",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Active indicator */}
|
||||
{active && (
|
||||
<div style={{
|
||||
position: "absolute", left: 0, top: "20%", bottom: "20%",
|
||||
width: 2, borderRadius: "0 2px 2px 0",
|
||||
background: "linear-gradient(to bottom, #0d9488, #22d3ee)",
|
||||
}} />
|
||||
)}
|
||||
<span style={{ width: 20, height: 20, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{!collapsed && badge != null && badge > 0 && (
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, padding: "2px 6px", borderRadius: 20,
|
||||
background: "linear-gradient(135deg, #0d9488, #22d3ee)", color: "white",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sidebar ────────────────────────────────────────────────────────────────
|
||||
// ── Sidebar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
const { data: bands } = useQuery({ queryKey: ["bands"], queryFn: listBands });
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.get<MemberRead>("/auth/me"),
|
||||
});
|
||||
|
||||
// Derive active band from the current URL
|
||||
const bandMatch =
|
||||
matchPath("/bands/:bandId/*", location.pathname) ??
|
||||
matchPath("/bands/:bandId", location.pathname);
|
||||
const activeBandId = bandMatch?.params?.bandId ?? null;
|
||||
const activeBand = bands?.find((b) => b.id === activeBandId) ?? null;
|
||||
const { activeBandId } = useBandStore();
|
||||
|
||||
// Nav active states
|
||||
const isLibrary = !!(
|
||||
matchPath({ path: "/bands/:bandId", end: true }, location.pathname) ||
|
||||
matchPath("/bands/:bandId/sessions/:sessionId", location.pathname) ||
|
||||
@@ -167,459 +160,121 @@ export function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
const isPlayer = !!matchPath("/bands/:bandId/songs/:songId", location.pathname);
|
||||
const isSettings = location.pathname.startsWith("/settings");
|
||||
const isBandSettings = !!matchPath("/bands/:bandId/settings/*", location.pathname);
|
||||
const bandSettingsPanel = matchPath("/bands/:bandId/settings/:panel", location.pathname)?.params?.panel ?? null;
|
||||
|
||||
// Player state
|
||||
const { currentSongId, currentBandId: playerBandId, isPlaying: isPlayerPlaying } = usePlayerStore();
|
||||
const hasActiveSong = !!currentSongId && !!playerBandId;
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!dropdownOpen) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const sidebarWidth = collapsed ? 68 : 230;
|
||||
const border = "rgba(255,255,255,0.06)";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: "100vh",
|
||||
overflow: "hidden",
|
||||
background: "#0f0f12",
|
||||
color: "#eeeef2",
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{/* ── Sidebar ──────────────────────────────────────────────────── */}
|
||||
<aside
|
||||
style={{
|
||||
width: 210,
|
||||
minWidth: 210,
|
||||
background: "#0b0b0e",
|
||||
<div style={{ display: "flex", height: "100vh", overflow: "hidden", background: "#080f0d", color: "#e8e9f0", fontFamily: "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif", fontSize: 13 }}>
|
||||
|
||||
{/* ── Sidebar ── */}
|
||||
<aside style={{
|
||||
width: sidebarWidth,
|
||||
minWidth: sidebarWidth,
|
||||
background: "#0c1612",
|
||||
borderRight: `1px solid ${border}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
style={{
|
||||
padding: "17px 14px 14px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
borderBottom: `1px solid ${border}`,
|
||||
transition: "width 0.22s cubic-bezier(0.4,0,0.2,1), min-width 0.22s cubic-bezier(0.4,0,0.2,1)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
zIndex: 20,
|
||||
}}>
|
||||
|
||||
{/* Logo / toggle */}
|
||||
<div style={{ padding: "18px 14px 14px", display: "flex", alignItems: "center", gap: 10, borderBottom: `1px solid ${border}`, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
title={collapsed ? "Expand menu" : "Collapse menu"}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
background: "#e8a22a",
|
||||
borderRadius: 7,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
width: 40, height: 40, borderRadius: 12, flexShrink: 0,
|
||||
background: "linear-gradient(135deg, #0d9488, #06b6d4)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
border: "none", cursor: "pointer",
|
||||
boxShadow: "0 0 20px rgba(20,184,166,0.3)",
|
||||
transition: "box-shadow 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = "0 0 30px rgba(20,184,166,0.5)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "0 0 20px rgba(20,184,166,0.3)")}
|
||||
>
|
||||
<IconWaveform />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: "#eeeef2", letterSpacing: -0.2 }}>
|
||||
<IconMenu />
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div style={{ overflow: "hidden" }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: "#e8e9f0", letterSpacing: -0.3, whiteSpace: "nowrap" }}>
|
||||
RehearsalHub
|
||||
</div>
|
||||
{activeBand && (
|
||||
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.25)", marginTop: 1 }}>
|
||||
{activeBand.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Band switcher */}
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={{
|
||||
padding: "10px 8px",
|
||||
borderBottom: `1px solid ${border}`,
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setDropdownOpen((o) => !o)}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 9px",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
border: "1px solid rgba(255,255,255,0.07)",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
color: "#eeeef2",
|
||||
textAlign: "left",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
background: "rgba(232,162,42,0.15)",
|
||||
border: "1px solid rgba(232,162,42,0.3)",
|
||||
borderRadius: 7,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: "#e8a22a",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{activeBand ? getInitials(activeBand.name) : "?"}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{activeBand?.name ?? "Select a band"}
|
||||
</span>
|
||||
<span style={{ opacity: 0.3, flexShrink: 0, display: "flex" }}>
|
||||
<IconChevron />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% - 2px)",
|
||||
left: 8,
|
||||
right: 8,
|
||||
background: "#18181e",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: 10,
|
||||
padding: 6,
|
||||
zIndex: 100,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
{bands?.map((band) => (
|
||||
<button
|
||||
key={band.id}
|
||||
onClick={() => {
|
||||
navigate(`/bands/${band.id}`);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 9px",
|
||||
marginBottom: 1,
|
||||
background: band.id === activeBandId ? "rgba(232,162,42,0.08)" : "transparent",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
color: "#eeeef2",
|
||||
textAlign: "left",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 5,
|
||||
background: "rgba(232,162,42,0.15)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: "#e8a22a",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{getInitials(band.name)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: "rgba(255,255,255,0.62)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{band.name}
|
||||
</span>
|
||||
{band.id === activeBandId && (
|
||||
<span style={{ fontSize: 10, color: "#e8a22a", flexShrink: 0 }}>✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid rgba(255,255,255,0.06)",
|
||||
marginTop: 4,
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate("/");
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 9px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
color: "rgba(255,255,255,0.35)",
|
||||
fontSize: 12,
|
||||
textAlign: "left",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14, opacity: 0.5 }}>+</span>
|
||||
Create new band
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav style={{ flex: 1, padding: "10px 8px", overflowY: "auto" }}>
|
||||
{activeBand && (
|
||||
<nav style={{ flex: 1, padding: "10px 12px", overflowY: "auto", overflowX: "hidden", display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{activeBandId && (
|
||||
<>
|
||||
<SectionLabel>{activeBand.name}</SectionLabel>
|
||||
<NavItem
|
||||
icon={<IconLibrary />}
|
||||
label="Library"
|
||||
active={isLibrary}
|
||||
onClick={() => navigate(`/bands/${activeBand.id}`)}
|
||||
/>
|
||||
<NavItem icon={<IconLibrary />} label="Library" active={isLibrary} onClick={() => navigate(`/bands/${activeBandId}`)} collapsed={collapsed} />
|
||||
<NavItem
|
||||
icon={<IconPlay />}
|
||||
label="Player"
|
||||
label="Now Playing"
|
||||
active={hasActiveSong && (isPlayer || isPlayerPlaying)}
|
||||
onClick={() => {
|
||||
if (hasActiveSong) {
|
||||
navigate(`/bands/${playerBandId}/songs/${currentSongId}`);
|
||||
}
|
||||
}}
|
||||
onClick={() => { if (hasActiveSong) navigate(`/bands/${playerBandId}/songs/${currentSongId}`); }}
|
||||
disabled={!hasActiveSong}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeBand && (
|
||||
<>
|
||||
<SectionLabel style={{ paddingTop: 14 }}>Band Settings</SectionLabel>
|
||||
<NavItem
|
||||
icon={<IconMembers />}
|
||||
label="Members"
|
||||
active={isBandSettings && bandSettingsPanel === "members"}
|
||||
onClick={() => navigate(`/bands/${activeBand.id}/settings/members`)}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<IconStorage />}
|
||||
label="Storage"
|
||||
active={isBandSettings && bandSettingsPanel === "storage"}
|
||||
onClick={() => navigate(`/bands/${activeBand.id}/settings/storage`)}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<IconSettings />}
|
||||
label="Band Settings"
|
||||
active={isBandSettings && bandSettingsPanel === "band"}
|
||||
onClick={() => navigate(`/bands/${activeBand.id}/settings/band`)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SectionLabel style={{ paddingTop: 14 }}>Account</SectionLabel>
|
||||
<NavItem
|
||||
icon={<IconSettings />}
|
||||
label="Settings"
|
||||
active={isSettings}
|
||||
onClick={() => navigate("/settings")}
|
||||
/>
|
||||
<div style={{ height: 1, background: border, margin: "10px 0", flexShrink: 0 }} />
|
||||
<NavItem icon={<IconSettings />} label="Settings" active={isSettings} onClick={() => navigate("/settings")} collapsed={collapsed} />
|
||||
</nav>
|
||||
|
||||
{/* User row */}
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
borderTop: `1px solid ${border}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "10px 12px", borderTop: `1px solid ${border}`, flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<button
|
||||
onClick={() => navigate("/settings")}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "6px 8px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
color: "#eeeef2",
|
||||
textAlign: "left",
|
||||
minWidth: 0,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
title={collapsed ? (me?.display_name ?? "Account") : undefined}
|
||||
style={{ flex: 1, display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", background: "transparent", border: "none", borderRadius: 8, cursor: "pointer", color: "#e8e9f0", textAlign: "left", minWidth: 0, fontFamily: "inherit", overflow: "hidden" }}
|
||||
>
|
||||
{me?.avatar_url ? (
|
||||
<img
|
||||
src={me.avatar_url}
|
||||
alt=""
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<img src={me.avatar_url} alt="" style={{ width: 28, height: 28, borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(232,162,42,0.18)",
|
||||
border: "1.5px solid rgba(232,162,42,0.35)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: "#e8a22a",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "rgba(52,211,153,0.15)", border: "1.5px solid rgba(52,211,153,0.3)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 700, color: "#34d399", flexShrink: 0 }}>
|
||||
{getInitials(me?.display_name ?? "?")}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: "rgba(255,255,255,0.55)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{!collapsed && (
|
||||
<span style={{ flex: 1, fontSize: 12, color: "rgba(232,233,240,0.55)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{me?.display_name ?? "…"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
title="Sign out"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 7,
|
||||
background: "transparent",
|
||||
border: "1px solid transparent",
|
||||
cursor: "pointer",
|
||||
color: "rgba(255,255,255,0.2)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "border-color 0.12s, color 0.12s",
|
||||
padding: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "rgba(255,255,255,0.1)";
|
||||
e.currentTarget.style.color = "rgba(255,255,255,0.5)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "transparent";
|
||||
e.currentTarget.style.color = "rgba(255,255,255,0.2)";
|
||||
}}
|
||||
style={{ flexShrink: 0, width: 30, height: 30, borderRadius: 7, background: "transparent", border: "1px solid transparent", cursor: "pointer", color: "rgba(255,255,255,0.2)", display: "flex", alignItems: "center", justifyContent: "center", transition: "border-color 0.12s, color 0.12s", padding: 0 }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = "rgba(255,255,255,0.1)"; e.currentTarget.style.color = "rgba(255,255,255,0.5)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = "transparent"; e.currentTarget.style.color = "rgba(255,255,255,0.2)"; }}
|
||||
>
|
||||
<IconSignOut />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Main content ──────────────────────────────────────────────── */}
|
||||
<main
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "#0f0f12",
|
||||
}}
|
||||
>
|
||||
{/* ── Main content ── */}
|
||||
<main style={{ flex: 1, overflow: "hidden", display: "grid", gridTemplateRows: "44px 1fr", background: "#080f0d", minWidth: 0 }}>
|
||||
<TopBandBar />
|
||||
<div style={{ overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: "rgba(255,255,255,0.2)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.7px",
|
||||
padding: "0 6px 5px",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
467
web/src/components/TopBandBar.tsx
Normal file
467
web/src/components/TopBandBar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
web/src/hooks/useWaveform.test.ts
Normal file
87
web/src/hooks/useWaveform.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { usePlayerStore } from "../stores/playerStore";
|
||||
export interface UseWaveformOptions {
|
||||
url: string | null;
|
||||
peaksUrl: string | null;
|
||||
peaks?: number[] | null;
|
||||
onReady?: (duration: number) => void;
|
||||
onTimeUpdate?: (currentTime: number) => void;
|
||||
songId?: string | null;
|
||||
@@ -39,7 +40,7 @@ export function useWaveform(
|
||||
|
||||
const initializeAudio = async () => {
|
||||
try {
|
||||
await audioService.initialize(containerRef.current!, options.url!);
|
||||
await audioService.initialize(containerRef.current!, options.url!, options.peaks ?? undefined);
|
||||
|
||||
// Restore playback if this song was already playing when the page loaded.
|
||||
// Read as a one-time snapshot — these values must NOT be reactive deps or
|
||||
@@ -77,7 +78,12 @@ export function useWaveform(
|
||||
};
|
||||
|
||||
initializeAudio();
|
||||
}, [options.url, options.songId, options.bandId]);
|
||||
// containerRef is a stable ref object — safe to include.
|
||||
// options.onReady is intentionally omitted: it's a callback that callers
|
||||
// may not memoize, and re-running initialization on every render would be
|
||||
// worse than stale-closing over it for the brief window after mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [options.url, options.songId, options.bandId, containerRef]);
|
||||
|
||||
const play = () => {
|
||||
audioService.play(options.songId ?? null, options.bandId ?? null)
|
||||
|
||||
@@ -16,23 +16,30 @@ input, textarea, button, select {
|
||||
|
||||
/* ── Design system (dark only — no light mode in v1) ─────────────────────── */
|
||||
:root {
|
||||
--bg: #0f0f12;
|
||||
/* v2 dark-teal palette */
|
||||
--bg: #080f0d;
|
||||
--bg-card: #0c1612;
|
||||
--bg-raised: #101c18;
|
||||
--bg-hover: #142420;
|
||||
--bg-subtle: rgba(255,255,255,0.025);
|
||||
--bg-inset: rgba(255,255,255,0.04);
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--border-subtle: rgba(255,255,255,0.05);
|
||||
--text: #eeeef2;
|
||||
--text-muted: rgba(255,255,255,0.35);
|
||||
--text-subtle: rgba(255,255,255,0.22);
|
||||
--accent: #e8a22a;
|
||||
--accent-hover: #f0b740;
|
||||
--accent-bg: rgba(232,162,42,0.1);
|
||||
--accent-border: rgba(232,162,42,0.28);
|
||||
--accent-fg: #0f0f12;
|
||||
--teal: #4dba85;
|
||||
--teal-bg: rgba(61,200,120,0.1);
|
||||
--danger: #e07070;
|
||||
--danger-bg: rgba(220,80,80,0.1);
|
||||
--border: rgba(255,255,255,0.06);
|
||||
--border-bright: rgba(255,255,255,0.12);
|
||||
--border-subtle: rgba(255,255,255,0.04);
|
||||
--text: #e8e9f0;
|
||||
--text-muted: rgba(232,233,240,0.55);
|
||||
--text-subtle: rgba(232,233,240,0.28);
|
||||
/* Teal accent */
|
||||
--accent: #14b8a6;
|
||||
--accent-light: #2dd4bf;
|
||||
--accent-hover: #10a89a;
|
||||
--accent-bg: rgba(20,184,166,0.12);
|
||||
--accent-border: rgba(20,184,166,0.3);
|
||||
--accent-fg: #ffffff;
|
||||
--teal: #34d399;
|
||||
--teal-bg: rgba(52,211,153,0.1);
|
||||
--danger: #f43f5e;
|
||||
--danger-bg: rgba(244,63,94,0.1);
|
||||
}
|
||||
|
||||
/* ── Responsive Layout ──────────────────────────────────────────────────── */
|
||||
@@ -46,7 +53,7 @@ input, textarea, button, select {
|
||||
/* Bottom Navigation Bar */
|
||||
nav[style*="position: fixed"] {
|
||||
display: flex;
|
||||
background: #0b0b0e;
|
||||
background: #060d0b;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
@@ -1,50 +1,16 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getBand } from "../api/bands";
|
||||
import { api } from "../api/client";
|
||||
import { LibraryPanel } from "../components/LibraryPanel";
|
||||
import { PlayerPanel } from "../components/PlayerPanel";
|
||||
|
||||
interface SongSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
tags: string[];
|
||||
global_key: string | null;
|
||||
global_bpm: number | null;
|
||||
version_count: number;
|
||||
}
|
||||
|
||||
interface SessionSummary {
|
||||
id: string;
|
||||
date: string;
|
||||
label: string | null;
|
||||
recording_count: number;
|
||||
}
|
||||
|
||||
type FilterPill = "all" | "full band" | "guitar" | "vocals" | "drums" | "keys" | "commented";
|
||||
|
||||
const PILLS: FilterPill[] = ["all", "full band", "guitar", "vocals", "drums", "keys", "commented"];
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso.slice(0, 10) + "T12:00:00");
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function formatDateLabel(iso: string): string {
|
||||
const d = new Date(iso.slice(0, 10) + "T12:00:00");
|
||||
const today = new Date();
|
||||
today.setHours(12, 0, 0, 0);
|
||||
const diffDays = Math.round((today.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) {
|
||||
return "Today — " + d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
// ── BandPage ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BandPage() {
|
||||
const { bandId } = useParams<{ bandId: string }>();
|
||||
const [librarySearch, setLibrarySearch] = useState("");
|
||||
const [activePill, setActivePill] = useState<FilterPill>("all");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// selectedSongId is kept in URL as ?song=<id> so deep-links and browser back work
|
||||
const selectedSongId = searchParams.get("song");
|
||||
|
||||
const { data: band, isLoading } = useQuery({
|
||||
queryKey: ["band", bandId],
|
||||
@@ -52,239 +18,37 @@ export function BandPage() {
|
||||
enabled: !!bandId,
|
||||
});
|
||||
|
||||
const { data: sessions } = useQuery({
|
||||
queryKey: ["sessions", bandId],
|
||||
queryFn: () => api.get<SessionSummary[]>(`/bands/${bandId}/sessions`),
|
||||
enabled: !!bandId,
|
||||
});
|
||||
function selectSong(songId: string) {
|
||||
setSearchParams({ song: songId }, { replace: false });
|
||||
}
|
||||
|
||||
const { data: unattributedSongs } = useQuery({
|
||||
queryKey: ["songs-unattributed", bandId],
|
||||
queryFn: () => api.get<SongSummary[]>(`/bands/${bandId}/songs/search?unattributed=true`),
|
||||
enabled: !!bandId,
|
||||
});
|
||||
function clearSong() {
|
||||
setSearchParams({}, { replace: false });
|
||||
}
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
return (sessions ?? []).filter((s) => {
|
||||
if (!librarySearch) return true;
|
||||
const haystack = [s.label ?? "", s.date, formatDate(s.date)].join(" ").toLowerCase();
|
||||
return haystack.includes(librarySearch.toLowerCase());
|
||||
});
|
||||
}, [sessions, librarySearch]);
|
||||
|
||||
const filteredUnattributed = useMemo(() => {
|
||||
return (unattributedSongs ?? []).filter((song) => {
|
||||
const matchesSearch =
|
||||
!librarySearch || song.title.toLowerCase().includes(librarySearch.toLowerCase());
|
||||
const matchesPill =
|
||||
activePill === "all" ||
|
||||
activePill === "commented" ||
|
||||
song.tags.some((t) => t.toLowerCase() === activePill);
|
||||
return matchesSearch && matchesPill;
|
||||
});
|
||||
}, [unattributedSongs, librarySearch, activePill]);
|
||||
|
||||
|
||||
if (isLoading) return <div style={{ color: "var(--text-muted)", padding: 32 }}>Loading...</div>;
|
||||
if (!band) return <div style={{ color: "var(--danger)", padding: 32 }}>Band not found</div>;
|
||||
|
||||
const hasResults = filteredSessions.length > 0 || filteredUnattributed.length > 0;
|
||||
if (isLoading) return <div style={{ color: "rgba(232,233,240,0.35)", padding: 32 }}>Loading…</div>;
|
||||
if (!band || !bandId) return <div style={{ color: "#f87171", padding: 32 }}>Band not found</div>;
|
||||
|
||||
if (selectedSongId) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", maxWidth: 760, margin: "0 auto" }}>
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────── */}
|
||||
<div style={{ padding: "18px 26px 0", flexShrink: 0, borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
|
||||
{/* Title row + search + actions */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 11 }}>
|
||||
<h1 style={{ fontSize: 17, fontWeight: 500, color: "#eeeef2", margin: 0, flexShrink: 0 }}>
|
||||
Library
|
||||
</h1>
|
||||
|
||||
{/* Search input */}
|
||||
<div style={{ position: "relative", flex: 1, maxWidth: 280 }}>
|
||||
<svg
|
||||
style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", opacity: 0.3, pointerEvents: "none", color: "#eeeef2" }}
|
||||
width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.5"
|
||||
>
|
||||
<circle cx="5.5" cy="5.5" r="3.5" />
|
||||
<path d="M8.5 8.5l3 3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
value={librarySearch}
|
||||
onChange={(e) => setLibrarySearch(e.target.value)}
|
||||
placeholder="Search recordings, comments…"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "7px 12px 7px 30px",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
borderRadius: 7,
|
||||
color: "#e2e2e8",
|
||||
fontSize: 13,
|
||||
fontFamily: "inherit",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "rgba(232,162,42,0.35)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "rgba(255,255,255,0.08)")}
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<PlayerPanel
|
||||
key={selectedSongId}
|
||||
songId={selectedSongId}
|
||||
bandId={bandId}
|
||||
onBack={clearSong}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div style={{ display: "flex", gap: 5, flexWrap: "wrap", paddingBottom: 14 }}>
|
||||
{PILLS.map((pill) => {
|
||||
const active = activePill === pill;
|
||||
return (
|
||||
<button
|
||||
key={pill}
|
||||
onClick={() => setActivePill(pill)}
|
||||
style={{
|
||||
padding: "3px 10px",
|
||||
borderRadius: 20,
|
||||
cursor: "pointer",
|
||||
border: `1px solid ${active ? "rgba(232,162,42,0.28)" : "rgba(255,255,255,0.08)"}`,
|
||||
background: active ? "rgba(232,162,42,0.1)" : "transparent",
|
||||
color: active ? "#e8a22a" : "rgba(255,255,255,0.3)",
|
||||
fontSize: 11,
|
||||
fontFamily: "inherit",
|
||||
transition: "all 0.12s",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{pill}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{/* ── Scrollable content ────────────────────────────────── */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "4px 26px 26px" }}>
|
||||
|
||||
{/* Sessions — one date group per session */}
|
||||
{filteredSessions.map((s) => (
|
||||
<div key={s.id} style={{ marginTop: 18 }}>
|
||||
{/* Date group header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: "rgba(255,255,255,0.32)", textTransform: "uppercase", letterSpacing: "0.6px", whiteSpace: "nowrap" }}>
|
||||
{formatDateLabel(s.date)}{s.label ? ` — ${s.label}` : ""}
|
||||
</span>
|
||||
<div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
|
||||
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
|
||||
{s.recording_count} recording{s.recording_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Session row */}
|
||||
<Link
|
||||
to={`/bands/${bandId}/sessions/${s.id}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 11,
|
||||
padding: "9px 13px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
border: "1px solid rgba(255,255,255,0.04)",
|
||||
textDecoration: "none",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.12s, border-color 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)";
|
||||
}}
|
||||
>
|
||||
{/* Session name */}
|
||||
<span style={{ flex: 1, fontSize: 13, color: "#c8c8d0", fontFamily: "'SF Mono','Fira Code',monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{s.label ?? formatDate(s.date)}
|
||||
</span>
|
||||
|
||||
{/* Recording count */}
|
||||
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{s.recording_count}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Unattributed recordings */}
|
||||
{filteredUnattributed.length > 0 && (
|
||||
<div style={{ marginTop: filteredSessions.length > 0 ? 28 : 18 }}>
|
||||
{/* Section header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: "rgba(255,255,255,0.32)", textTransform: "uppercase", letterSpacing: "0.6px", whiteSpace: "nowrap" }}>
|
||||
Unattributed
|
||||
</span>
|
||||
<div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.05)" }} />
|
||||
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.18)", whiteSpace: "nowrap" }}>
|
||||
{filteredUnattributed.length} track{filteredUnattributed.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: 3 }}>
|
||||
{filteredUnattributed.map((song) => (
|
||||
<Link
|
||||
key={song.id}
|
||||
to={`/bands/${bandId}/songs/${song.id}`}
|
||||
style={{ display: "flex", alignItems: "center", gap: 11, padding: "9px 13px", borderRadius: 8, background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.04)", textDecoration: "none", transition: "background 0.12s, border-color 0.12s" }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.048)";
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.08)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "rgba(255,255,255,0.02)";
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.04)";
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, color: "#c8c8d0", fontFamily: "'SF Mono','Fira Code',monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", marginBottom: 3 }}>
|
||||
{song.title}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
{song.tags.map((t) => (
|
||||
<span key={t} style={{ background: "rgba(61,200,120,0.08)", color: "#4dba85", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{song.global_key && (
|
||||
<span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
|
||||
{song.global_key}
|
||||
</span>
|
||||
)}
|
||||
{song.global_bpm && (
|
||||
<span style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.28)", fontSize: 9, padding: "1px 6px", borderRadius: 3, fontFamily: "monospace" }}>
|
||||
{song.global_bpm.toFixed(0)} BPM
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.28)", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||
{song.version_count} ver{song.version_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!hasResults && (
|
||||
<p style={{ color: "rgba(255,255,255,0.28)", fontSize: 13, padding: "24px 0 8px" }}>
|
||||
{librarySearch
|
||||
? "No results match your search."
|
||||
: "No sessions yet. Go to Storage settings to scan your Nextcloud folder."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<LibraryPanel
|
||||
bandId={bandId}
|
||||
selectedSongId={selectedSongId}
|
||||
onSelectSong={selectSong}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
125
web/src/services/audioService.test.ts
Normal file
125
web/src/services/audioService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ class AudioService {
|
||||
// For use in tests only
|
||||
public static resetInstance(): void {
|
||||
this.instance?.cleanup();
|
||||
this.instance = undefined as any;
|
||||
this.instance = undefined as unknown as AudioService;
|
||||
}
|
||||
|
||||
private createMediaElement(): HTMLAudioElement {
|
||||
@@ -41,7 +41,7 @@ class AudioService {
|
||||
return el;
|
||||
}
|
||||
|
||||
public async initialize(container: HTMLElement, url: string): Promise<void> {
|
||||
public async initialize(container: HTMLElement, url: string, peaks?: number[] | null): Promise<void> {
|
||||
if (!container) throw new Error('Container element is required');
|
||||
if (!url) throw new Error('Valid audio URL is required');
|
||||
|
||||
@@ -69,9 +69,9 @@ class AudioService {
|
||||
// Fresh audio element per song. Lives on document.body so playback
|
||||
// continues even when the SongPage container is removed from the DOM.
|
||||
media: this.mediaElement,
|
||||
waveColor: "rgba(255,255,255,0.09)",
|
||||
progressColor: "#c8861a",
|
||||
cursorColor: "#e8a22a",
|
||||
waveColor: "rgba(20,184,166,0.18)",
|
||||
progressColor: "#14b8a6",
|
||||
cursorColor: "#2dd4bf",
|
||||
barWidth: 2,
|
||||
barRadius: 2,
|
||||
height: 104,
|
||||
@@ -98,7 +98,14 @@ class AudioService {
|
||||
|
||||
ws.on('ready', () => { onReady().catch(reject); });
|
||||
ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
|
||||
|
||||
// Pass pre-computed peaks to WaveSurfer so the waveform renders immediately
|
||||
// without waiting for the full audio to decode (WaveSurfer v7 feature).
|
||||
if (peaks && peaks.length > 0) {
|
||||
ws.load(url, [new Float32Array(peaks)]);
|
||||
} else {
|
||||
ws.load(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
18
web/src/stores/bandStore.ts
Normal file
18
web/src/stores/bandStore.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
@@ -34,3 +34,20 @@ packages = ["src/worker"]
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
src = ["src"]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
ignore = ["F401", "F841", "SIM102", "SIM211", "UP045", "E501", "UP017"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["F401", "F841", "SIM102", "SIM211", "UP017", "I001", "B017", "SIM117"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.15.8",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, func
|
||||
from sqlalchemy import BigInteger, DateTime, Integer, Numeric, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
@@ -26,6 +26,8 @@ class AudioVersionModel(Base):
|
||||
nc_file_etag: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
cdn_hls_base: Mapped[Optional[str]] = mapped_column(Text)
|
||||
waveform_url: Mapped[Optional[str]] = mapped_column(Text)
|
||||
waveform_peaks: Mapped[Optional[list]] = mapped_column(JSONB)
|
||||
waveform_peaks_mini: Mapped[Optional[list]] = mapped_column(JSONB)
|
||||
duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
format: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
file_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger)
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
import librosa
|
||||
import numpy as np
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from worker.config import get_settings
|
||||
@@ -21,7 +21,7 @@ from worker.db import AudioVersionModel, JobModel
|
||||
from worker.pipeline.analyse_full import run_full_analysis
|
||||
from worker.pipeline.analyse_range import run_range_analysis
|
||||
from worker.pipeline.transcode import get_duration_ms, transcode_to_hls
|
||||
from worker.pipeline.waveform import generate_waveform_file
|
||||
from worker.pipeline.waveform import extract_peaks, generate_waveform_file
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
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")
|
||||
await transcode_to_hls(local_path, hls_dir)
|
||||
|
||||
waveform_path = os.path.join(tmp, "waveform.json")
|
||||
await generate_waveform_file(audio, waveform_path)
|
||||
|
||||
# TODO: Upload HLS segments and waveform back to Nextcloud / object storage
|
||||
# For now, store the local tmp path in the DB (replace with real upload logic)
|
||||
hls_nc_path = f"hls/{version_id}"
|
||||
waveform_nc_path = f"waveforms/{version_id}.json"
|
||||
# Generate waveform peaks at two resolutions:
|
||||
# - 500-point full peaks passed to WaveSurfer for instant render in player
|
||||
# - 100-point mini peaks for the library/overview SVG thumbnail
|
||||
loop = asyncio.get_event_loop()
|
||||
peaks_500 = await loop.run_in_executor(None, extract_peaks, audio, 500)
|
||||
peaks_100 = await loop.run_in_executor(None, extract_peaks, audio, 100)
|
||||
|
||||
# NOTE: HLS upload to Nextcloud is not yet implemented.
|
||||
# cdn_hls_base is intentionally left unchanged here — do NOT set it to a
|
||||
# local tmp path that will be deleted. The stream endpoint falls back to
|
||||
# nc_file_path (raw file from Nextcloud) when cdn_hls_base is null.
|
||||
stmt = (
|
||||
update(AudioVersionModel)
|
||||
.where(AudioVersionModel.id == version_id)
|
||||
.values(
|
||||
cdn_hls_base=hls_nc_path,
|
||||
waveform_url=waveform_nc_path,
|
||||
waveform_peaks=peaks_500,
|
||||
waveform_peaks_mini=peaks_100,
|
||||
duration_ms=duration_ms,
|
||||
analysis_status="running",
|
||||
)
|
||||
@@ -102,9 +105,41 @@ async def handle_analyse_range(payload: dict, session: AsyncSession, settings) -
|
||||
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 = {
|
||||
"transcode": handle_transcode,
|
||||
"analyse_range": handle_analyse_range,
|
||||
"extract_peaks": handle_extract_peaks,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ async def run_full_analysis(
|
||||
fields: dict[str, Any] = {**bpm_result.fields, **key_result.fields}
|
||||
|
||||
from sqlalchemy import update
|
||||
|
||||
from worker.db import AudioVersionModel
|
||||
|
||||
global_bpm = fields.get("bpm")
|
||||
|
||||
@@ -5,10 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
async def transcode_to_hls(input_path: str, output_dir: str) -> str:
|
||||
|
||||
71
worker/tests/test_handle_transcode.py
Normal file
71
worker/tests/test_handle_transcode.py
Normal 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"
|
||||
@@ -14,6 +14,12 @@ def test_extract_peaks_returns_correct_length(sine_440hz):
|
||||
assert len(peaks) == 500
|
||||
|
||||
|
||||
def test_extract_peaks_mini_returns_correct_length(sine_440hz):
|
||||
audio, sr = sine_440hz
|
||||
peaks = extract_peaks(audio, num_points=100)
|
||||
assert len(peaks) == 100
|
||||
|
||||
|
||||
def test_extract_peaks_normalized_between_0_and_1(sine_440hz):
|
||||
audio, sr = sine_440hz
|
||||
peaks = extract_peaks(audio, num_points=200)
|
||||
@@ -21,6 +27,13 @@ def test_extract_peaks_normalized_between_0_and_1(sine_440hz):
|
||||
assert max(peaks) == pytest.approx(1.0, abs=0.01)
|
||||
|
||||
|
||||
def test_extract_peaks_mini_normalized_between_0_and_1(sine_440hz):
|
||||
audio, sr = sine_440hz
|
||||
peaks = extract_peaks(audio, num_points=100)
|
||||
assert all(0.0 <= p <= 1.0 for p in peaks)
|
||||
assert max(peaks) == pytest.approx(1.0, abs=0.01)
|
||||
|
||||
|
||||
def test_extract_peaks_empty_audio():
|
||||
audio = np.array([], dtype=np.float32)
|
||||
peaks = extract_peaks(audio, num_points=100)
|
||||
@@ -28,6 +41,14 @@ def test_extract_peaks_empty_audio():
|
||||
assert all(p == 0.0 for p in peaks)
|
||||
|
||||
|
||||
def test_extract_peaks_custom_num_points(sine_440hz):
|
||||
audio, _ = sine_440hz
|
||||
for n in [50, 100, 250, 500]:
|
||||
peaks = extract_peaks(audio, num_points=n)
|
||||
assert len(peaks) == n, f"Expected {n} peaks, got {len(peaks)}"
|
||||
assert all(0.0 <= p <= 1.0 for p in peaks)
|
||||
|
||||
|
||||
def test_peaks_to_json_valid_structure(sine_440hz):
|
||||
audio, _ = sine_440hz
|
||||
peaks = extract_peaks(audio)
|
||||
@@ -46,4 +67,5 @@ async def test_generate_waveform_file_writes_json(tmp_path, sine_440hz):
|
||||
with open(output) as f:
|
||||
data = json.load(f)
|
||||
assert data["version"] == 2
|
||||
assert len(data["data"]) == 1000
|
||||
# generate_waveform_file uses the default num_points=500
|
||||
assert len(data["data"]) == 500
|
||||
|
||||
8
worker/uv.lock
generated
8
worker/uv.lock
generated
@@ -1004,6 +1004,11 @@ dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "asyncpg", specifier = ">=0.29" },
|
||||
@@ -1023,6 +1028,9 @@ requires-dist = [
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff", specifier = ">=0.15.8" }]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
|
||||
Reference in New Issue
Block a user