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

@@ -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":