Refactor storage to provider-agnostic band-scoped model

Replaces per-member Nextcloud credentials with a BandStorage model that
supports multiple providers. Credentials are Fernet-encrypted at rest;
worker receives audio via an internal streaming endpoint instead of
direct storage access.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mistral Vibe
2026-04-10 23:22:36 +02:00
parent ba22853bc7
commit b2d6b4d113
44 changed files with 1725 additions and 675 deletions

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ dependencies = [
"pydantic[email]>=2.7",
"pydantic-settings>=2.3",
"python-jose[cryptography]>=3.3",
"cryptography>=42.0",
"bcrypt>=4.1",
"httpx>=0.27",
"redis[hiredis]>=5.0",

View File

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

View File

@@ -10,12 +10,14 @@ from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -35,9 +37,6 @@ class Member(Base):
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
avatar_url: Mapped[str | None] = mapped_column(Text)
nc_username: Mapped[str | None] = mapped_column(String(255))
nc_url: Mapped[str | None] = mapped_column(Text)
nc_password: Mapped[str | None] = mapped_column(Text)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
@@ -67,8 +66,6 @@ class Band(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
nc_folder_path: Mapped[str | None] = mapped_column(Text)
nc_user: Mapped[str | None] = mapped_column(String(255))
genre_tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
@@ -86,6 +83,59 @@ class Band(Base):
sessions: Mapped[list[RehearsalSession]] = relationship(
"RehearsalSession", back_populates="band", cascade="all, delete-orphan"
)
storage_configs: Mapped[list[BandStorage]] = relationship(
"BandStorage", back_populates="band", cascade="all, delete-orphan"
)
class BandStorage(Base):
"""Storage provider configuration for a band.
Credentials are stored as a Fernet-encrypted JSON blob — never in plaintext.
Only one ``BandStorage`` row per band may be active at a time, enforced by
a partial unique index on ``(band_id) WHERE is_active``.
Supported providers and their credential shapes (all encrypted):
nextcloud: { "url": "...", "username": "...", "app_password": "..." }
googledrive: { "access_token": "...", "refresh_token": "...",
"token_expiry": "ISO-8601", "token_type": "Bearer" }
onedrive: { "access_token": "...", "refresh_token": "...",
"token_expiry": "ISO-8601", "token_type": "Bearer" }
dropbox: { "access_token": "...", "refresh_token": "...",
"token_expiry": "ISO-8601" }
"""
__tablename__ = "band_storage"
__table_args__ = (
# DB-enforced: at most one active storage config per band.
Index(
"uq_band_active_storage",
"band_id",
unique=True,
postgresql_where=text("is_active = true"),
),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
band_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True
)
# 'nextcloud' | 'googledrive' | 'onedrive' | 'dropbox'
provider: Mapped[str] = mapped_column(String(20), nullable=False)
label: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Root path within the provider's storage (e.g. "/bands/cool-band/"). Not sensitive.
root_path: Mapped[str | None] = mapped_column(Text)
# Fernet-encrypted JSON blob — shape depends on provider (see docstring above).
credentials: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
band: Mapped[Band] = relationship("Band", back_populates="storage_configs")
class BandMember(Base):

View File

@@ -20,6 +20,7 @@ from rehearsalhub.routers import (
members_router,
sessions_router,
songs_router,
storage_router,
versions_router,
ws_router,
)
@@ -94,6 +95,7 @@ def create_app() -> FastAPI:
app.include_router(annotations_router, prefix=prefix)
app.include_router(members_router, prefix=prefix)
app.include_router(internal_router, prefix=prefix)
app.include_router(storage_router, prefix=prefix)
app.include_router(ws_router) # WebSocket routes don't use /api/v1 prefix
@app.get("/api/health")

View File

@@ -17,6 +17,11 @@ class AudioVersionRepository(BaseRepository[AudioVersion]):
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_by_nc_file_path(self, nc_file_path: str) -> AudioVersion | None:
stmt = select(AudioVersion).where(AudioVersion.nc_file_path == nc_file_path)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_for_song(self, song_id: uuid.UUID) -> list[AudioVersion]:
stmt = (
select(AudioVersion)

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ from rehearsalhub.routers.invites import router as invites_router
from rehearsalhub.routers.members import router as members_router
from rehearsalhub.routers.sessions import router as sessions_router
from rehearsalhub.routers.songs import router as songs_router
from rehearsalhub.routers.storage import router as storage_router
from rehearsalhub.routers.versions import router as versions_router
from rehearsalhub.routers.ws import router as ws_router
@@ -17,6 +18,7 @@ __all__ = [
"members_router",
"sessions_router",
"songs_router",
"storage_router",
"versions_router",
"annotations_router",
"ws_router",

View File

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

View File

@@ -11,7 +11,6 @@ from rehearsalhub.repositories.band import BandRepository
from rehearsalhub.schemas.band import BandCreate, BandRead, BandReadWithMembers, BandUpdate
from rehearsalhub.schemas.invite import BandInviteList, BandInviteListItem
from rehearsalhub.services.band import BandService
from rehearsalhub.storage.nextcloud import NextcloudClient
router = APIRouter(prefix="/bands", tags=["bands"])
@@ -126,10 +125,9 @@ async def create_band(
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
storage = NextcloudClient.for_member(current_member)
svc = BandService(session, storage)
svc = BandService(session)
try:
band = await svc.create_band(data, current_member.id, creator=current_member)
band = await svc.create_band(data, current_member.id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
except LookupError as e:
@@ -143,8 +141,7 @@ async def get_band(
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
):
storage = NextcloudClient.for_member(current_member)
svc = BandService(session, storage)
svc = BandService(session)
try:
await svc.assert_membership(band_id, current_member.id)
except PermissionError:
@@ -173,9 +170,10 @@ async def update_band(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Band not found")
updates: dict = {}
if data.nc_folder_path is not None:
path = data.nc_folder_path.strip()
updates["nc_folder_path"] = (path.rstrip("/") + "/") if path else None
if data.name is not None:
updates["name"] = data.name.strip()
if data.genre_tags is not None:
updates["genre_tags"] = data.genre_tags
if updates:
band = await repo.update(band, **updates)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ from rehearsalhub.repositories.audio_version import AudioVersionRepository
from rehearsalhub.repositories.song import SongRepository
from rehearsalhub.schemas.audio_version import AudioVersionCreate
from rehearsalhub.schemas.song import SongCreate, SongRead
from rehearsalhub.storage.nextcloud import NextcloudClient
class SongService:
@@ -21,25 +20,31 @@ class SongService:
self,
session: AsyncSession,
job_queue: RedisJobQueue | None = None,
storage: NextcloudClient | None = None,
) -> None:
self._repo = SongRepository(session)
self._version_repo = AudioVersionRepository(session)
self._session = session
self._queue = job_queue or RedisJobQueue(session)
self._storage = storage
async def create_song(
self, band_id: uuid.UUID, data: SongCreate, creator_id: uuid.UUID, band_slug: str,
creator: object | None = None,
self,
band_id: uuid.UUID,
data: SongCreate,
creator_id: uuid.UUID,
band_slug: str,
) -> Song:
from rehearsalhub.storage.nextcloud import NextcloudClient
nc_folder = f"bands/{band_slug}/songs/{data.title.lower().replace(' ', '-')}/"
storage = NextcloudClient.for_member(creator) if creator else self._storage
try:
from rehearsalhub.config import get_settings
from rehearsalhub.storage.factory import StorageFactory
storage = await StorageFactory.create(self._session, band_id, get_settings())
await storage.create_folder(nc_folder)
except LookupError:
log.info("Band %s has no active storage — skipping folder creation for '%s'", band_id, nc_folder)
nc_folder = None # type: ignore[assignment]
except Exception:
nc_folder = None # best-effort
nc_folder = None # best-effort; storage may be temporarily unreachable
song = await self._repo.create(
band_id=band_id,

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