Merge pull request 'feature/file-import' (#2) from feature/file-import into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-04-10 21:37:53 +00:00
70 changed files with 2959 additions and 917 deletions

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,8 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member from rehearsalhub.db.models import Member
from rehearsalhub.services.auth import decode_token
from rehearsalhub.repositories.member import MemberRepository from rehearsalhub.repositories.member import MemberRepository
from rehearsalhub.services.auth import decode_token
# auto_error=False so we can fall back to cookie auth without a 401 from the scheme itself # auto_error=False so we can fall back to cookie auth without a 401 from the scheme itself
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,11 @@ class AudioVersionRepository(BaseRepository[AudioVersion]):
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_by_nc_file_path(self, nc_file_path: str) -> AudioVersion | None:
stmt = select(AudioVersion).where(AudioVersion.nc_file_path == nc_file_path)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_for_song(self, song_id: uuid.UUID) -> list[AudioVersion]: async def list_for_song(self, song_id: uuid.UUID) -> list[AudioVersion]:
stmt = ( stmt = (
select(AudioVersion) select(AudioVersion)
@@ -37,7 +42,7 @@ class AudioVersionRepository(BaseRepository[AudioVersion]):
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_with_annotations(self, version_id: uuid.UUID) -> AudioVersion | None: async def get_with_annotations(self, version_id: uuid.UUID) -> AudioVersion | None:
from rehearsalhub.db.models import Annotation, RangeAnalysis from rehearsalhub.db.models import Annotation
stmt = ( stmt = (
select(AudioVersion) select(AudioVersion)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
from rehearsalhub.routers.annotations import router as annotations_router from rehearsalhub.routers.annotations import router as annotations_router
from rehearsalhub.routers.auth import router as auth_router from rehearsalhub.routers.auth import router as auth_router
from rehearsalhub.routers.bands import router as bands_router from rehearsalhub.routers.bands import router as bands_router
from rehearsalhub.routers.invites import router as invites_router
from rehearsalhub.routers.internal import router as internal_router from rehearsalhub.routers.internal import router as internal_router
from rehearsalhub.routers.invites import router as invites_router
from rehearsalhub.routers.members import router as members_router from rehearsalhub.routers.members import router as members_router
from rehearsalhub.routers.sessions import router as sessions_router from rehearsalhub.routers.sessions import router as sessions_router
from rehearsalhub.routers.songs import router as songs_router from rehearsalhub.routers.songs import router as songs_router
from rehearsalhub.routers.storage import router as storage_router
from rehearsalhub.routers.versions import router as versions_router from rehearsalhub.routers.versions import router as versions_router
from rehearsalhub.routers.ws import router as ws_router from rehearsalhub.routers.ws import router as ws_router
@@ -17,6 +18,7 @@ __all__ = [
"members_router", "members_router",
"sessions_router", "sessions_router",
"songs_router", "songs_router",
"storage_router",
"versions_router", "versions_router",
"annotations_router", "annotations_router",
"ws_router", "ws_router",

View File

@@ -34,7 +34,7 @@ async def register(request: Request, req: RegisterRequest, session: AsyncSession
member = await svc.register(req) member = await svc.register(req)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
return MemberRead.from_model(member) return MemberRead.model_validate(member)
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
@@ -87,7 +87,7 @@ async def logout(response: Response):
@router.get("/me", response_model=MemberRead) @router.get("/me", response_model=MemberRead)
async def get_me(current_member: Member = Depends(get_current_member)): async def get_me(current_member: Member = Depends(get_current_member)):
return MemberRead.from_model(current_member) return MemberRead.model_validate(current_member)
@router.patch("/me/settings", response_model=MemberRead) @router.patch("/me/settings", response_model=MemberRead)
@@ -100,12 +100,6 @@ async def update_settings(
updates: dict = {} updates: dict = {}
if data.display_name is not None: if data.display_name is not None:
updates["display_name"] = data.display_name updates["display_name"] = data.display_name
if data.nc_url is not None:
updates["nc_url"] = data.nc_url.rstrip("/") if data.nc_url else None
if data.nc_username is not None:
updates["nc_username"] = data.nc_username or None
if data.nc_password is not None:
updates["nc_password"] = data.nc_password or None
if data.avatar_url is not None: if data.avatar_url is not None:
updates["avatar_url"] = data.avatar_url or None updates["avatar_url"] = data.avatar_url or None
@@ -113,7 +107,7 @@ async def update_settings(
member = await repo.update(current_member, **updates) member = await repo.update(current_member, **updates)
else: else:
member = current_member member = current_member
return MemberRead.from_model(member) return MemberRead.model_validate(member)
@router.post("/me/avatar", response_model=MemberRead) @router.post("/me/avatar", response_model=MemberRead)
@@ -187,4 +181,4 @@ async def upload_avatar(
repo = MemberRepository(session) repo = MemberRepository(session)
avatar_url = f"/api/static/avatars/{filename}" avatar_url = f"/api/static/avatars/{filename}"
member = await repo.update(current_member, avatar_url=avatar_url) member = await repo.update(current_member, avatar_url=avatar_url)
return MemberRead.from_model(member) return MemberRead.model_validate(member)

View File

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

View File

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

View File

@@ -1,16 +1,14 @@
""" """
Invite management endpoints. Invite management endpoints.
""" """
import uuid from datetime import UTC, datetime
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import BandInvite, Member
from rehearsalhub.schemas.invite import InviteInfoRead
from rehearsalhub.repositories.band import BandRepository from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.schemas.invite import InviteInfoRead
router = APIRouter(prefix="/invites", tags=["invites"]) router = APIRouter(prefix="/invites", tags=["invites"])
@@ -32,7 +30,7 @@ async def get_invite_info(
) )
# Check if invite is already used or expired # Check if invite is already used or expired
now = datetime.now(timezone.utc) now = datetime.now(UTC)
if invite.used_at is not None: if invite.used_at is not None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import uuid
import asyncio import asyncio
import uuid
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -17,9 +17,11 @@ from rehearsalhub.repositories.member import MemberRepository
from rehearsalhub.repositories.song import SongRepository from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate, AudioVersionRead from rehearsalhub.schemas.audio_version import AudioVersionCreate, AudioVersionRead
from rehearsalhub.services.auth import decode_token from rehearsalhub.services.auth import decode_token
from rehearsalhub.config import get_settings
from rehearsalhub.services.band import BandService from rehearsalhub.services.band import BandService
from rehearsalhub.services.song import SongService from rehearsalhub.services.song import SongService
from rehearsalhub.storage.nextcloud import NextcloudClient from rehearsalhub.storage.factory import StorageFactory
from rehearsalhub.storage.protocol import StorageClient
router = APIRouter(tags=["versions"]) router = APIRouter(tags=["versions"])
@@ -35,7 +37,7 @@ _AUDIO_CONTENT_TYPES: dict[str, str] = {
} }
async def _download_with_retry(storage: NextcloudClient, file_path: str, max_retries: int = 3) -> bytes: async def _download_with_retry(storage: StorageClient, file_path: str, max_retries: int = 3) -> bytes:
"""Download file from Nextcloud with retry logic for transient errors.""" """Download file from Nextcloud with retry logic for transient errors."""
last_error = None last_error = None
@@ -171,8 +173,7 @@ async def create_version(
except PermissionError: except PermissionError:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
storage = NextcloudClient.for_member(current_member) song_svc = SongService(session)
song_svc = SongService(session, storage=storage)
version = await song_svc.register_version(song_id, data, current_member.id) version = await song_svc.register_version(song_id, data, current_member.id)
return AudioVersionRead.model_validate(version) return AudioVersionRead.model_validate(version)
@@ -219,15 +220,12 @@ async def stream_version(
else: else:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No audio file") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No audio file")
# Use the uploader's NC credentials — invited members may not have NC configured try:
uploader: Member | None = None storage = await StorageFactory.create(session, song.band_id, get_settings())
if version.uploaded_by: except LookupError:
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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_502_BAD_GATEWAY,
detail="No storage provider configured for this account" detail="Band has no active storage configured",
) )
try: try:
data = await _download_with_retry(storage, file_path) data = await _download_with_retry(storage, file_path)

View File

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

View File

@@ -8,7 +8,7 @@ from rehearsalhub.schemas.annotation import (
) )
from rehearsalhub.schemas.audio_version import AudioVersionCreate, AudioVersionRead from rehearsalhub.schemas.audio_version import AudioVersionCreate, AudioVersionRead
from rehearsalhub.schemas.auth import LoginRequest, RegisterRequest, TokenResponse from rehearsalhub.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandMemberRead from rehearsalhub.schemas.band import BandCreate, BandMemberRead, BandRead, BandReadWithMembers
from rehearsalhub.schemas.member import MemberRead from rehearsalhub.schemas.member import MemberRead
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate

View File

@@ -18,11 +18,11 @@ class BandCreate(BaseModel):
name: str name: str
slug: str slug: str
genre_tags: list[str] = [] genre_tags: list[str] = []
nc_base_path: str | None = None # e.g. "Bands/MyBand/" — defaults to "bands/{slug}/"
class BandUpdate(BaseModel): class BandUpdate(BaseModel):
nc_folder_path: str | None = None # update the Nextcloud base folder for scans name: str | None = None
genre_tags: list[str] | None = None
class BandRead(BaseModel): class BandRead(BaseModel):
@@ -31,7 +31,6 @@ class BandRead(BaseModel):
name: str name: str
slug: str slug: str
genre_tags: list[str] genre_tags: list[str]
nc_folder_path: str | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

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

View File

@@ -1,8 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, EmailStr, model_validator from pydantic import BaseModel, ConfigDict, EmailStr
class MemberBase(BaseModel): class MemberBase(BaseModel):
@@ -14,23 +13,9 @@ class MemberRead(MemberBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID
avatar_url: str | None = None avatar_url: str | None = None
nc_username: str | None = None
nc_url: str | None = None
nc_configured: bool = False # True if nc_url + nc_username + nc_password are all set
created_at: datetime created_at: datetime
@classmethod
def from_model(cls, m: object) -> "MemberRead":
obj = cls.model_validate(m)
obj.nc_configured = bool(
getattr(m, "nc_url") and getattr(m, "nc_username") and getattr(m, "nc_password")
)
return obj
class MemberSettingsUpdate(BaseModel): class MemberSettingsUpdate(BaseModel):
display_name: str | None = None display_name: str | None = None
nc_url: str | None = None avatar_url: str | None = None
nc_username: str | None = None
nc_password: str | None = None # send null to clear, omit to leave unchanged
avatar_url: str | None = None # URL to user's avatar image

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,18 @@
from __future__ import annotations from __future__ import annotations
import logging
import uuid import uuid
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
from rehearsalhub.db.models import AudioVersion, Song from rehearsalhub.db.models import AudioVersion, Song
from rehearsalhub.queue.redis_queue import RedisJobQueue from rehearsalhub.queue.redis_queue import RedisJobQueue
from rehearsalhub.repositories.audio_version import AudioVersionRepository from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.song import SongRepository from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.schemas.song import SongCreate, SongRead, SongUpdate from rehearsalhub.schemas.song import SongCreate, SongRead
from rehearsalhub.storage.nextcloud import NextcloudClient
class SongService: class SongService:
@@ -18,25 +20,31 @@ class SongService:
self, self,
session: AsyncSession, session: AsyncSession,
job_queue: RedisJobQueue | None = None, job_queue: RedisJobQueue | None = None,
storage: NextcloudClient | None = None,
) -> None: ) -> None:
self._repo = SongRepository(session) self._repo = SongRepository(session)
self._version_repo = AudioVersionRepository(session) self._version_repo = AudioVersionRepository(session)
self._session = session self._session = session
self._queue = job_queue or RedisJobQueue(session) self._queue = job_queue or RedisJobQueue(session)
self._storage = storage
async def create_song( async def create_song(
self, band_id: uuid.UUID, data: SongCreate, creator_id: uuid.UUID, band_slug: str, self,
creator: object | None = None, band_id: uuid.UUID,
data: SongCreate,
creator_id: uuid.UUID,
band_slug: str,
) -> Song: ) -> Song:
from rehearsalhub.storage.nextcloud import NextcloudClient
nc_folder = f"bands/{band_slug}/songs/{data.title.lower().replace(' ', '-')}/" nc_folder = f"bands/{band_slug}/songs/{data.title.lower().replace(' ', '-')}/"
storage = NextcloudClient.for_member(creator) if creator else self._storage
try: try:
from rehearsalhub.config import get_settings
from rehearsalhub.storage.factory import StorageFactory
storage = await StorageFactory.create(self._session, band_id, get_settings())
await storage.create_folder(nc_folder) await storage.create_folder(nc_folder)
except LookupError:
log.info("Band %s has no active storage — skipping folder creation for '%s'", band_id, nc_folder)
nc_folder = None # type: ignore[assignment]
except Exception: except Exception:
nc_folder = None # best-effort nc_folder = None # best-effort; storage may be temporarily unreachable
song = await self._repo.create( song = await self._repo.create(
band_id=band_id, band_id=band_id,
@@ -67,11 +75,6 @@ class SongService:
data: AudioVersionCreate, data: AudioVersionCreate,
uploader_id: uuid.UUID, uploader_id: uuid.UUID,
) -> AudioVersion: ) -> AudioVersion:
if data.nc_file_etag:
existing = await self._version_repo.get_by_etag(data.nc_file_etag)
if existing:
return existing
version_number = await self._repo.next_version_number(song_id) version_number = await self._repo.next_version_number(song_id)
version = await self._version_repo.create( version = await self._version_repo.create(
song_id=song_id, song_id=song_id,
@@ -85,8 +88,15 @@ class SongService:
uploaded_by=uploader_id, uploaded_by=uploader_id,
) )
try:
await self._queue.enqueue( await self._queue.enqueue(
"transcode", "transcode",
{"version_id": str(version.id), "nc_file_path": data.nc_file_path}, {"version_id": str(version.id), "nc_file_path": data.nc_file_path},
) )
except Exception as exc:
log.error(
"Failed to enqueue transcode job for version %s ('%s'): %s",
version.id, data.nc_file_path, exc, exc_info=True,
)
return version return version

View File

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

View File

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

View File

@@ -92,9 +92,8 @@ async def test_waveform_404_when_no_peaks_in_db(mock_session):
with ( with (
patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership", patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(version, song)), return_value=(version, song)),pytest.raises(HTTPException) as exc_info
): ):
with pytest.raises(HTTPException) as exc_info:
await get_waveform(version_id=version.id, session=mock_session, current_member=member) await get_waveform(version_id=version.id, session=mock_session, current_member=member)
assert exc_info.value.status_code == 404 assert exc_info.value.status_code == 404
@@ -113,8 +112,8 @@ async def test_waveform_mini_404_when_no_mini_peaks(mock_session):
with ( with (
patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership", patch("rehearsalhub.routers.versions._get_version_and_assert_band_membership",
return_value=(version, song)), return_value=(version, song)),
pytest.raises(HTTPException) as exc_info,
): ):
with pytest.raises(HTTPException) as exc_info:
await get_waveform(version_id=version.id, session=mock_session, current_member=member, resolution="mini") await get_waveform(version_id=version.id, session=mock_session, current_member=member, resolution="mini")
assert exc_info.value.status_code == 404 assert exc_info.value.status_code == 404

4
api/uv.lock generated
View File

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

View File

@@ -7,6 +7,8 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-default_secure_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-default_secure_password}
volumes: volumes:
- pg_data_dev:/var/lib/postgresql/data - pg_data_dev:/var/lib/postgresql/data
ports:
- "5432:5432"
networks: networks:
- rh_net - rh_net
healthcheck: healthcheck:
@@ -20,6 +22,11 @@ services:
image: redis:7-alpine image: redis:7-alpine
networks: networks:
- rh_net - rh_net
healthcheck:
test: ["CMD-SHELL", "redis-cli ping || exit 1"]
interval: 5s
timeout: 3s
retries: 10
api: api:
build: build:
@@ -34,6 +41,7 @@ services:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY:-replace_me_with_32_byte_hex_default} SECRET_KEY: ${SECRET_KEY:-replace_me_with_32_byte_hex_default}
INTERNAL_SECRET: ${INTERNAL_SECRET:-replace_me_with_32_byte_hex_default} INTERNAL_SECRET: ${INTERNAL_SECRET:-replace_me_with_32_byte_hex_default}
STORAGE_ENCRYPTION_KEY: ${STORAGE_ENCRYPTION_KEY:-5vaaZQs4J7CFYZ7fqee37HgIt4xNxKHHX6OWd29Yh5E=}
DOMAIN: localhost DOMAIN: localhost
ports: ports:
- "8000:8000" - "8000:8000"
@@ -43,6 +51,29 @@ services:
db: db:
condition: service_healthy condition: service_healthy
audio-worker:
build:
context: ./worker
target: development
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-rh_user}:${POSTGRES_PASSWORD:-default_secure_password}@db:5432/${POSTGRES_DB:-rehearsalhub}
REDIS_URL: redis://redis:6379/0
API_URL: http://api:8000
INTERNAL_SECRET: ${INTERNAL_SECRET:-replace_me_with_32_byte_hex_default}
ANALYSIS_VERSION: "1.0.0"
LOG_LEVEL: DEBUG
PYTHONUNBUFFERED: "1"
volumes:
- ./worker/src:/app/src:z
- audio_tmp:/tmp/audio
networks:
- rh_net
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
web: web:
build: build:
context: ./web context: ./web
@@ -62,3 +93,4 @@ networks:
volumes: volumes:
pg_data_dev: pg_data_dev:
audio_tmp:

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

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

View File

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

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

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

29
scripts/release.sh Executable file
View File

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

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

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

View File

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

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

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

View File

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

View File

@@ -5,11 +5,10 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class WatcherSettings(BaseSettings): class WatcherSettings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
nextcloud_url: str = "http://nextcloud"
nextcloud_user: str = "ncadmin"
nextcloud_pass: str = ""
api_url: str = "http://api:8000" api_url: str = "http://api:8000"
# Shared secret for calling internal API endpoints
internal_secret: str = "dev-change-me-in-production"
redis_url: str = "redis://localhost:6379/0" redis_url: str = "redis://localhost:6379/0"
job_queue_key: str = "rh:jobs" job_queue_key: str = "rh:jobs"
@@ -18,6 +17,10 @@ class WatcherSettings(BaseSettings):
# File extensions to watch # File extensions to watch
audio_extensions: list[str] = [".wav", ".mp3", ".flac", ".aac", ".ogg", ".m4a", ".opus"] audio_extensions: list[str] = [".wav", ".mp3", ".flac", ".aac", ".ogg", ".m4a", ".opus"]
# How often (in poll cycles) to refresh the list of bands from the API.
# 0 = only on startup, N = every N poll cycles.
config_refresh_interval: int = 10
@lru_cache @lru_cache
def get_settings() -> WatcherSettings: def get_settings() -> WatcherSettings:

View File

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

View File

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

View File

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

View File

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

533
watcher/uv.lock generated Normal file
View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { listBands, createBand } from "../api/bands"; import { listBands, createBand } from "../api/bands";
import { getInitials } from "../utils"; import { getInitials } from "../utils";
import { useBandStore } from "../stores/bandStore"; import { useBandStore } from "../stores/bandStore";
import { api } from "../api/client";
// ── Shared primitives ────────────────────────────────────────────────────────── // ── Shared primitives ──────────────────────────────────────────────────────────
@@ -30,27 +29,6 @@ const labelStyle: React.CSSProperties = {
marginBottom: 5, 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 ─────────────────────────────────────────────────────────────── // ── Error banner ───────────────────────────────────────────────────────────────
function ErrorBanner({ msg }: { msg: string }) { function ErrorBanner({ msg }: { msg: string }) {
@@ -61,117 +39,20 @@ function ErrorBanner({ msg }: { msg: string }) {
); );
} }
// ── Step 1: Storage setup ────────────────────────────────────────────────────── // ── Band creation form ─────────────────────────────────────────────────────────
interface Me { nc_configured: boolean; nc_url: string | null; nc_username: string | null; } function BandStep({ onClose }: { onClose: () => void }) {
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 navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [slug, setSlug] = useState(""); const [slug, setSlug] = useState("");
const [ncFolder, setNcFolder] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null); const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => { nameRef.current?.focus(); }, []); useEffect(() => { nameRef.current?.focus(); }, []);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () => createBand({ name, slug }),
createBand({
name,
slug,
...(ncFolder.trim() ? { nc_base_path: ncFolder.trim() } : {}),
}),
onSuccess: (band) => { onSuccess: (band) => {
qc.invalidateQueries({ queryKey: ["bands"] }); qc.invalidateQueries({ queryKey: ["bands"] });
onClose(); onClose();
@@ -187,12 +68,6 @@ function BandStep({ ncConfigured, onClose }: { ncConfigured: boolean; onClose: (
return ( 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} />} {error && <ErrorBanner msg={error} />}
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
@@ -207,7 +82,7 @@ function BandStep({ ncConfigured, onClose }: { ncConfigured: boolean; onClose: (
/> />
</div> </div>
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 24 }}>
<label style={labelStyle}>SLUG</label> <label style={labelStyle}>SLUG</label>
<input <input
value={slug} value={slug}
@@ -217,24 +92,9 @@ function BandStep({ ncConfigured, onClose }: { ncConfigured: boolean; onClose: (
/> />
</div> </div>
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", paddingTop: 18, marginBottom: 22 }}> <p style={{ margin: "0 0 20px", fontSize: 11, color: "rgba(232,233,240,0.3)", lineHeight: 1.5 }}>
<label style={labelStyle}> Connect storage after creating the band via Settings Storage.
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> </p>
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button <button
@@ -255,21 +115,9 @@ function BandStep({ ncConfigured, onClose }: { ncConfigured: boolean; onClose: (
); );
} }
// ── Create Band Modal (orchestrates steps) ───────────────────────────────────── // ── Create Band Modal ──────────────────────────────────────────────────────────
function CreateBandModal({ onClose }: { onClose: () => void }) { 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 // Close on Escape
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
@@ -277,9 +125,6 @@ function CreateBandModal({ onClose }: { onClose: () => void }) {
return () => document.removeEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler);
}, [onClose]); }, [onClose]);
const totalSteps = me?.nc_configured === false ? 2 : 1;
const currentDot = step === 0 ? 0 : totalSteps - 1;
return ( return (
<div <div
onClick={onClose} onClick={onClose}
@@ -289,28 +134,13 @@ function CreateBandModal({ onClose }: { onClose: () => void }) {
onClick={(e) => e.stopPropagation()} 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)" }} 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={{ marginBottom: 18 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 18 }}> <h3 style={{ margin: "0 0 3px", fontSize: 15, fontWeight: 600, color: "#e8e9f0" }}>New band</h3>
<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)" }}> <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."} Create a workspace for your recordings.
</p> </p>
</div> </div>
{totalSteps > 1 && step !== null && ( <BandStep onClose={onClose} />
<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>
</div> </div>
); );

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client"; import { api } from "../api/client";
import { listBands } from "../api/bands"; import { listBands, listStorage, connectNextcloud, disconnectStorage } from "../api/bands";
import { listInvites, revokeInvite } from "../api/invites"; import { listInvites, revokeInvite } from "../api/invites";
import { useBandStore } from "../stores/bandStore"; import { useBandStore } from "../stores/bandStore";
import { getInitials } from "../utils"; import { getInitials } from "../utils";
@@ -14,9 +14,6 @@ interface MemberRead {
display_name: string; display_name: string;
email: string; email: string;
avatar_url: string | null; avatar_url: string | null;
nc_username: string | null;
nc_url: string | null;
nc_configured: boolean;
} }
interface BandMember { interface BandMember {
@@ -40,7 +37,6 @@ interface Band {
name: string; name: string;
slug: string; slug: string;
genre_tags: string[]; genre_tags: string[];
nc_folder_path: string | null;
} }
type Section = "profile" | "members" | "storage" | "band"; type Section = "profile" | "members" | "storage" | "band";
@@ -267,42 +263,48 @@ function ProfileSection({ me }: { me: MemberRead }) {
); );
} }
// ── Storage section (NC credentials + scan folder) ──────────────────────────── // ── Storage section ────────────────────────────────────────────────────────────
function StorageSection({ bandId, band, amAdmin, me }: { bandId: string; band: Band; amAdmin: boolean; me: MemberRead }) { function StorageSection({ bandId, band, amAdmin }: { bandId: string; band: Band; amAdmin: boolean }) {
const qc = useQueryClient(); const qc = useQueryClient();
// NC credentials state const [showConnect, setShowConnect] = useState(false);
const [ncUrl, setNcUrl] = useState(me.nc_url ?? ""); const [ncUrl, setNcUrl] = useState("");
const [ncUsername, setNcUsername] = useState(me.nc_username ?? ""); const [ncUsername, setNcUsername] = useState("");
const [ncPassword, setNcPassword] = useState(""); const [ncPassword, setNcPassword] = useState("");
const [ncSaved, setNcSaved] = useState(false); const [ncRootPath, setNcRootPath] = useState("");
const [ncError, setNcError] = useState<string | null>(null); const [connectError, setConnectError] = useState<string | null>(null);
// Scan folder state
const [editingPath, setEditingPath] = useState(false);
const [folderInput, setFolderInput] = useState("");
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState<string | null>(null); const [scanProgress, setScanProgress] = useState<string | null>(null);
const [scanMsg, setScanMsg] = useState<string | null>(null); const [scanMsg, setScanMsg] = useState<string | null>(null);
const ncMutation = useMutation({ const { data: storageConfigs, isLoading: storageLoading } = useQuery({
mutationFn: () => api.patch<MemberRead>("/auth/me/settings", { queryKey: ["storage", bandId],
nc_url: ncUrl || undefined, queryFn: () => listStorage(bandId),
nc_username: ncUsername || undefined,
nc_password: ncPassword || undefined,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["me"] });
setNcSaved(true); setNcError(null); setNcPassword("");
setTimeout(() => setNcSaved(false), 2500);
},
onError: (err) => setNcError(err instanceof Error ? err.message : "Save failed"),
}); });
const pathMutation = useMutation({ const activeStorage = storageConfigs?.find((s) => s.is_active) ?? null;
mutationFn: (nc_folder_path: string) => api.patch(`/bands/${bandId}`, { nc_folder_path }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["band", bandId] }); setEditingPath(false); }, const connectMutation = useMutation({
mutationFn: () => connectNextcloud(bandId, {
url: ncUrl.trim(),
username: ncUsername.trim(),
app_password: ncPassword,
root_path: ncRootPath.trim() || undefined,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["storage", bandId] });
setShowConnect(false);
setNcUrl(""); setNcUsername(""); setNcPassword(""); setNcRootPath("");
setConnectError(null);
},
onError: (err) => setConnectError(err instanceof Error ? err.message : "Connection failed"),
});
const disconnectMutation = useMutation({
mutationFn: () => disconnectStorage(bandId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["storage", bandId] }),
}); });
async function startScan() { async function startScan() {
@@ -340,33 +342,57 @@ function StorageSection({ bandId, band, amAdmin, me }: { bandId: string; band: B
finally { setScanning(false); setScanProgress(null); } finally { setScanning(false); setScanProgress(null); }
} }
const defaultPath = `bands/${band.slug}/`; const canConnect = ncUrl.trim() && ncUsername.trim() && ncPassword;
const currentPath = band.nc_folder_path ?? defaultPath;
return ( return (
<div> <div>
<SectionHeading title="Storage" subtitle="Configure Nextcloud credentials and your band's recording folder." /> <SectionHeading title="Storage" subtitle="Connect a storage provider to scan and index your band's recordings." />
{/* NC Connection */}
<div style={{ marginBottom: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 16 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: me.nc_configured ? "#34d399" : "rgba(232,233,240,0.25)", flexShrink: 0, boxShadow: me.nc_configured ? "0 0 6px rgba(52,211,153,0.5)" : "none" }} />
<span style={{ fontSize: 12, color: me.nc_configured ? "#34d399" : "rgba(232,233,240,0.4)" }}>
{me.nc_configured ? "Nextcloud connected" : "Nextcloud not configured"}
</span>
</div>
{/* Status card */}
<div style={{ padding: "14px 16px", background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 10, marginBottom: 16 }}> <div style={{ padding: "14px 16px", background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 10, marginBottom: 16 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8, marginBottom: 12 }}> {storageLoading ? (
<div> <p style={{ margin: 0, fontSize: 12, color: "rgba(232,233,240,0.3)" }}>Loading</p>
<div style={{ fontSize: 13, fontWeight: 600, color: "#e8e9f0", marginBottom: 2 }}>Nextcloud Connection</div> ) : activeStorage ? (
<div style={{ fontSize: 11, color: "rgba(232,233,240,0.35)" }}> <>
Your personal credentials will move to per-band config in a future update. <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: "#34d399", flexShrink: 0, boxShadow: "0 0 6px rgba(52,211,153,0.5)" }} />
<span style={{ fontSize: 13, fontWeight: 600, color: "#34d399" }}>
{activeStorage.label ?? activeStorage.provider}
</span>
<span style={{ fontSize: 11, color: "rgba(232,233,240,0.3)", textTransform: "capitalize" }}>({activeStorage.provider})</span>
</div> </div>
{activeStorage.root_path && (
<div style={{ marginBottom: 10 }}>
<Label>Scan path</Label>
<code style={{ fontSize: 12, color: "#34d399", fontFamily: "monospace" }}>{activeStorage.root_path}</code>
</div> </div>
)}
{amAdmin && (
<button
onClick={() => disconnectMutation.mutate()}
disabled={disconnectMutation.isPending}
style={{ padding: "5px 12px", background: "rgba(244,63,94,0.08)", border: "1px solid rgba(244,63,94,0.2)", borderRadius: 6, color: "#f87171", cursor: "pointer", fontSize: 11, fontFamily: "inherit" }}
>
{disconnectMutation.isPending ? "Disconnecting…" : "Disconnect"}
</button>
)}
</>
) : (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: "rgba(232,233,240,0.2)", flexShrink: 0 }} />
<span style={{ fontSize: 12, color: "rgba(232,233,240,0.35)" }}>No storage connected</span>
</div>
)}
</div> </div>
<div style={{ display: "grid", gap: 12 }}> {/* Connect form — admin only, shown when no active storage or toggled */}
{amAdmin && (!activeStorage || showConnect) && (
<>
{activeStorage && <Divider />}
<div style={{ fontSize: 13, fontWeight: 600, color: "#e8e9f0", marginBottom: 12 }}>
{activeStorage ? "Replace connection" : "Connect Nextcloud"}
</div>
<div style={{ display: "grid", gap: 12, marginBottom: 14 }}>
<div> <div>
<Label>Nextcloud URL</Label> <Label>Nextcloud URL</Label>
<Input value={ncUrl} onChange={(e) => setNcUrl(e.target.value)} placeholder="https://cloud.example.com" /> <Input value={ncUrl} onChange={(e) => setNcUrl(e.target.value)} placeholder="https://cloud.example.com" />
@@ -376,69 +402,58 @@ function StorageSection({ bandId, band, amAdmin, me }: { bandId: string; band: B
<Input value={ncUsername} onChange={(e) => setNcUsername(e.target.value)} /> <Input value={ncUsername} onChange={(e) => setNcUsername(e.target.value)} />
</div> </div>
<div> <div>
<Label>Password / App Password</Label> <Label>App Password</Label>
<Input type="password" value={ncPassword} onChange={(e) => setNcPassword(e.target.value)} placeholder={me.nc_configured ? "•••••••• (leave blank to keep)" : ""} /> <Input type="password" value={ncPassword} onChange={(e) => setNcPassword(e.target.value)} placeholder="Generate in Nextcloud → Settings → Security" />
<div style={{ fontSize: 11, color: "rgba(232,233,240,0.28)", marginTop: 4 }}>
Use an app password from Nextcloud Settings Security.
</div> </div>
</div>
</div>
{ncError && <p style={{ color: "#f87171", fontSize: 12, margin: "12px 0 0" }}>{ncError}</p>}
<div style={{ marginTop: 14 }}>
<SaveBtn pending={ncMutation.isPending} saved={ncSaved} onClick={() => ncMutation.mutate()} />
</div>
</div>
</div>
{/* Scan folder — admin only */}
{amAdmin && (
<>
<Divider />
<div style={{ fontSize: 13, fontWeight: 600, color: "#e8e9f0", marginBottom: 4 }}>Scan Folder</div>
<div style={{ fontSize: 12, color: "rgba(232,233,240,0.35)", marginBottom: 16, lineHeight: 1.55 }}>
RehearsalHub reads recordings from your Nextcloud files are never copied to our servers.
</div>
<div style={{ background: "rgba(255,255,255,0.02)", border: `1px solid ${border}`, borderRadius: 10, padding: "12px 16px", marginBottom: 14 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<div> <div>
<Label>Scan path</Label> <Label>Root path <span style={{ color: "rgba(232,233,240,0.25)", fontWeight: 400 }}>(optional)</span></Label>
<code style={{ fontSize: 13, color: "#34d399", fontFamily: "monospace" }}>{currentPath}</code> <Input value={ncRootPath} onChange={(e) => setNcRootPath(e.target.value)} placeholder={`bands/${band.slug}/`} style={{ fontFamily: "monospace" }} />
<div style={{ fontSize: 11, color: "rgba(232,233,240,0.28)", marginTop: 4 }}>
Leave blank to auto-create <code style={{ fontFamily: "monospace" }}>bands/{band.slug}/</code>
</div> </div>
{!editingPath && ( </div>
</div>
{connectError && <p style={{ color: "#f87171", fontSize: 12, marginBottom: 12 }}>{connectError}</p>}
<div style={{ display: "flex", gap: 8 }}>
<button <button
onClick={() => { setFolderInput(band.nc_folder_path ?? ""); setEditingPath(true); }} onClick={() => connectMutation.mutate()}
style={{ padding: "4px 10px", background: "transparent", border: `1px solid ${border}`, borderRadius: 6, color: "rgba(232,233,240,0.42)", cursor: "pointer", fontSize: 11, fontFamily: "inherit" }} disabled={!canConnect || connectMutation.isPending}
style={{ padding: "8px 18px", background: canConnect ? "linear-gradient(135deg, #0d9488, #06b6d4)" : "rgba(20,184,166,0.2)", border: "none", borderRadius: 8, color: canConnect ? "white" : "rgba(255,255,255,0.35)", cursor: canConnect ? "pointer" : "default", fontSize: 13, fontWeight: 600, fontFamily: "inherit" }}
> >
Edit {connectMutation.isPending ? "Connecting…" : "Connect"}
</button> </button>
)} {activeStorage && (
</div> <button onClick={() => setShowConnect(false)}
style={{ padding: "8px 14px", background: "transparent", border: `1px solid ${border}`, borderRadius: 8, color: "rgba(232,233,240,0.42)", cursor: "pointer", fontSize: 13, fontFamily: "inherit" }}>
{editingPath && (
<div style={{ marginTop: 12 }}>
<Input value={folderInput} onChange={(e) => setFolderInput(e.target.value)} placeholder={defaultPath} style={{ fontFamily: "monospace" }} />
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button onClick={() => pathMutation.mutate(folderInput)} disabled={pathMutation.isPending}
style={{ padding: "6px 14px", background: "rgba(20,184,166,0.12)", border: "1px solid rgba(20,184,166,0.3)", borderRadius: 6, color: "#2dd4bf", cursor: "pointer", fontSize: 12, fontWeight: 600, fontFamily: "inherit" }}>
{pathMutation.isPending ? "Saving…" : "Save"}
</button>
<button onClick={() => setEditingPath(false)}
style={{ padding: "6px 14px", background: "transparent", border: `1px solid ${border}`, borderRadius: 6, color: "rgba(232,233,240,0.42)", cursor: "pointer", fontSize: 12, fontFamily: "inherit" }}>
Cancel Cancel
</button> </button>
</div>
</div>
)} )}
</div> </div>
</>
)}
{amAdmin && activeStorage && !showConnect && (
<button
onClick={() => setShowConnect(true)}
style={{ padding: "7px 14px", background: "transparent", border: `1px solid ${border}`, borderRadius: 8, color: "rgba(232,233,240,0.42)", cursor: "pointer", fontSize: 12, fontFamily: "inherit", marginBottom: 16 }}
>
Replace connection
</button>
)}
{/* Scan — admin only, only if active storage */}
{amAdmin && activeStorage && (
<>
<Divider />
<div style={{ fontSize: 13, fontWeight: 600, color: "#e8e9f0", marginBottom: 4 }}>Scan Recordings</div>
<div style={{ fontSize: 12, color: "rgba(232,233,240,0.35)", marginBottom: 12, lineHeight: 1.55 }}>
RehearsalHub reads recordings from storage files are never copied to our servers.
</div>
<button <button
onClick={startScan} disabled={scanning} onClick={startScan} disabled={scanning}
style={{ padding: "7px 16px", background: scanning ? "transparent" : "rgba(52,211,153,0.08)", border: `1px solid ${scanning ? border : "rgba(52,211,153,0.25)"}`, borderRadius: 8, color: scanning ? "rgba(232,233,240,0.28)" : "#34d399", cursor: scanning ? "default" : "pointer", fontSize: 12, fontFamily: "inherit", transition: "all 0.12s" }}> style={{ padding: "7px 16px", background: scanning ? "transparent" : "rgba(52,211,153,0.08)", border: `1px solid ${scanning ? border : "rgba(52,211,153,0.25)"}`, borderRadius: 8, color: scanning ? "rgba(232,233,240,0.28)" : "#34d399", cursor: scanning ? "default" : "pointer", fontSize: 12, fontFamily: "inherit", transition: "all 0.12s" }}>
{scanning ? "Scanning…" : "⟳ Scan Nextcloud"} {scanning ? "Scanning…" : "⟳ Scan Storage"}
</button> </button>
{scanning && scanProgress && ( {scanning && scanProgress && (
<div style={{ marginTop: 10, background: "rgba(255,255,255,0.03)", border: `1px solid ${border}`, borderRadius: 8, color: "rgba(232,233,240,0.42)", fontSize: 12, padding: "8px 14px", fontFamily: "monospace" }}> <div style={{ marginTop: 10, background: "rgba(255,255,255,0.03)", border: `1px solid ${border}`, borderRadius: 8, color: "rgba(232,233,240,0.42)", fontSize: 12, padding: "8px 14px", fontFamily: "monospace" }}>
{scanProgress} {scanProgress}
@@ -750,7 +765,7 @@ export function SettingsPage() {
<div style={{ padding: "0 16px 24px" }}> <div style={{ padding: "0 16px 24px" }}>
{section === "profile" && <ProfileSection me={me} />} {section === "profile" && <ProfileSection me={me} />}
{section === "members" && activeBandId && band && <MembersSection bandId={activeBandId} band={band} amAdmin={amAdmin} members={members} membersLoading={membersLoading} />} {section === "members" && activeBandId && band && <MembersSection bandId={activeBandId} band={band} amAdmin={amAdmin} members={members} membersLoading={membersLoading} />}
{section === "storage" && activeBandId && band && <StorageSection bandId={activeBandId} band={band} amAdmin={amAdmin} me={me} />} {section === "storage" && activeBandId && band && <StorageSection bandId={activeBandId} band={band} amAdmin={amAdmin} />}
{section === "band" && activeBandId && band && amAdmin && <BandSection bandId={activeBandId} band={band} />} {section === "band" && activeBandId && band && amAdmin && <BandSection bandId={activeBandId} band={band} />}
</div> </div>
</div> </div>
@@ -832,7 +847,7 @@ export function SettingsPage() {
<MembersSection bandId={activeBandId} band={band} amAdmin={amAdmin} members={members} membersLoading={membersLoading} /> <MembersSection bandId={activeBandId} band={band} amAdmin={amAdmin} members={members} membersLoading={membersLoading} />
)} )}
{section === "storage" && activeBandId && band && ( {section === "storage" && activeBandId && band && (
<StorageSection bandId={activeBandId} band={band} amAdmin={amAdmin} me={me} /> <StorageSection bandId={activeBandId} band={band} amAdmin={amAdmin} />
)} )}
{section === "band" && activeBandId && band && amAdmin && ( {section === "band" && activeBandId && band && amAdmin && (
<BandSection bandId={activeBandId} band={band} /> <BandSection bandId={activeBandId} band={band} />

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ from pathlib import Path
import librosa import librosa
import numpy as np import numpy as np
import redis.asyncio as aioredis import redis.asyncio as aioredis
from sqlalchemy import select, update from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from worker.config import get_settings from worker.config import get_settings
@@ -23,23 +23,30 @@ from worker.pipeline.analyse_range import run_range_analysis
from worker.pipeline.transcode import get_duration_ms, transcode_to_hls from worker.pipeline.transcode import get_duration_ms, transcode_to_hls
from worker.pipeline.waveform import extract_peaks, generate_waveform_file from worker.pipeline.waveform import extract_peaks, generate_waveform_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
# Numba floods logs with JIT compilation details at DEBUG level — keep it quiet
logging.getLogger("numba").setLevel(logging.WARNING)
log = logging.getLogger("worker") log = logging.getLogger("worker")
async def load_audio(nc_path: str, tmp_dir: str, settings) -> tuple[np.ndarray, int, str]: async def load_audio(version_id: str, filename: str, tmp_dir: str, settings) -> tuple[np.ndarray, int, str]:
"""Download from Nextcloud and load as numpy array. Returns (audio, sr, local_path).""" """Download audio via the internal API and load as numpy array. Returns (audio, sr, local_path)."""
import httpx import httpx
local_path = os.path.join(tmp_dir, Path(nc_path).name) local_path = os.path.join(tmp_dir, filename)
dav_url = f"{settings.nextcloud_url}/remote.php/dav/files/{settings.nextcloud_user}/{nc_path.lstrip('/')}" url = f"{settings.api_url}/api/v1/internal/audio/{version_id}/stream"
log.info("Fetching audio for version %s from %s", version_id, url)
async with httpx.AsyncClient( async with httpx.AsyncClient(
auth=(settings.nextcloud_user, settings.nextcloud_pass), timeout=120.0 headers={"X-Internal-Token": settings.internal_secret}, timeout=120.0
) as client: ) as client:
resp = await client.get(dav_url) async with client.stream("GET", url) as resp:
resp.raise_for_status() resp.raise_for_status()
with open(local_path, "wb") as f: with open(local_path, "wb") as f:
f.write(resp.content) async for chunk in resp.aiter_bytes(65536):
f.write(chunk)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
audio, sr = await loop.run_in_executor( audio, sr = await loop.run_in_executor(
@@ -53,7 +60,7 @@ async def handle_transcode(payload: dict, session: AsyncSession, settings) -> No
nc_path = payload["nc_file_path"] nc_path = payload["nc_file_path"]
with tempfile.TemporaryDirectory(dir=settings.audio_tmp_dir) as tmp: with tempfile.TemporaryDirectory(dir=settings.audio_tmp_dir) as tmp:
audio, sr, local_path = await load_audio(nc_path, tmp, settings) audio, sr, local_path = await load_audio(str(version_id), Path(nc_path).name, tmp, settings)
duration_ms = await get_duration_ms(local_path) duration_ms = await get_duration_ms(local_path)
hls_dir = os.path.join(tmp, "hls") hls_dir = os.path.join(tmp, "hls")
@@ -99,7 +106,7 @@ async def handle_analyse_range(payload: dict, session: AsyncSession, settings) -
raise ValueError(f"AudioVersion {version_id} not found") raise ValueError(f"AudioVersion {version_id} not found")
with tempfile.TemporaryDirectory(dir=settings.audio_tmp_dir) as tmp: with tempfile.TemporaryDirectory(dir=settings.audio_tmp_dir) as tmp:
audio, sr, _ = await load_audio(version.nc_file_path, tmp, settings) audio, sr, _ = await load_audio(str(version_id), Path(version.nc_file_path).name, tmp, settings)
await run_range_analysis(audio, sr, version_id, annotation_id, start_ms, end_ms, session) await run_range_analysis(audio, sr, version_id, annotation_id, start_ms, end_ms, session)
log.info("Range analysis complete for annotation %s", annotation_id) log.info("Range analysis complete for annotation %s", annotation_id)
@@ -116,7 +123,7 @@ async def handle_extract_peaks(payload: dict, session: AsyncSession, settings) -
nc_path = payload["nc_file_path"] nc_path = payload["nc_file_path"]
with tempfile.TemporaryDirectory(dir=settings.audio_tmp_dir) as tmp: with tempfile.TemporaryDirectory(dir=settings.audio_tmp_dir) as tmp:
audio, _sr, _local_path = await load_audio(nc_path, tmp, settings) audio, _sr, _local_path = await load_audio(str(version_id), Path(nc_path).name, tmp, settings)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
peaks_500 = await loop.run_in_executor(None, extract_peaks, audio, 500) peaks_500 = await loop.run_in_executor(None, extract_peaks, audio, 500)
@@ -146,17 +153,27 @@ HANDLERS = {
async def main() -> None: async def main() -> None:
settings = get_settings() settings = get_settings()
os.makedirs(settings.audio_tmp_dir, exist_ok=True) os.makedirs(settings.audio_tmp_dir, exist_ok=True)
log.info(
"Worker config — redis_url=%s api_url=%s queue=%s",
settings.redis_url, settings.api_url, settings.job_queue_key,
)
engine = create_async_engine(settings.database_url, pool_pre_ping=True) engine = create_async_engine(settings.database_url, pool_pre_ping=True)
session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
redis = aioredis.from_url(settings.redis_url, decode_responses=True) redis = aioredis.from_url(settings.redis_url, decode_responses=True)
# Drain stale job IDs left in Redis from previous runs whose API transactions # Wait for Redis to be reachable before proceeding (startup race condition guard).
# were never committed (e.g. crashed processes). for attempt in range(1, 31):
stale = await redis.llen(settings.job_queue_key) try:
if stale: await redis.ping()
log.warning("Draining %d stale job IDs from Redis queue before starting", stale) log.info("Redis connection established (attempt %d)", attempt)
await redis.delete(settings.job_queue_key) break
except Exception as exc:
if attempt == 30:
log.error("Redis unreachable after 30 attempts — giving up: %s", exc)
raise
log.warning("Redis not ready (attempt %d/30): %s — retrying in 2s", attempt, exc)
await asyncio.sleep(2)
log.info("Worker started. Listening for jobs on %s", settings.job_queue_key) log.info("Worker started. Listening for jobs on %s", settings.job_queue_key)

View File

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

View File

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

8
worker/uv.lock generated
View File

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