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:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user