Initial commit: RehearsalHub POC

Full-stack self-hosted band rehearsal platform:

Backend (FastAPI + SQLAlchemy 2.0 async):
- Auth with JWT (register, login, /me, settings)
- Band management with Nextcloud folder integration
- Song management with audio version tracking
- Nextcloud scan to auto-import audio files
- Band membership with link-based invite system
- Song comments
- Audio analysis worker (BPM, key, loudness, waveform)
- Nextcloud activity watcher for auto-import
- WebSocket support for real-time annotation updates
- Alembic migrations (0001–0003)
- Repository pattern, Ruff + mypy configured

Frontend (React 18 + Vite + TypeScript strict):
- Login/register page with post-login redirect
- Home page with band list and creation form
- Band page with member panel, invite link, song list, NC scan
- Song page with waveform player, annotations, comment thread
- Settings page for per-user Nextcloud credentials
- Invite acceptance page (/invite/:token)
- ESLint v9 flat config + TypeScript strict mode

Infrastructure:
- Docker Compose: PostgreSQL, Redis, API, worker, watcher, nginx
- nginx reverse proxy for static files + /api/ proxy
- make check runs all linters before docker compose build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Steffen Schuhmann
2026-03-28 21:53:03 +01:00
commit f7be1b994d
139 changed files with 12743 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
"""Nextcloud WebDAV + OCS storage client."""
from __future__ import annotations
import xml.etree.ElementTree as ET
from typing import Any
import httpx
from rehearsalhub.config import get_settings
from rehearsalhub.storage.protocol import FileMetadata
_DAV_NS = "{DAV:}"
class NextcloudClient:
def __init__(
self,
base_url: str | None = None,
username: str | None = None,
password: str | None = None,
) -> None:
s = get_settings()
self._base = (base_url or s.nextcloud_url).rstrip("/")
self._auth = (username or s.nextcloud_user, password or s.nextcloud_pass)
self._dav_root = f"{self._base}/remote.php/dav/files/{self._auth[0]}"
@classmethod
def for_member(cls, member: object) -> "NextcloudClient":
"""Return a client using member's personal NC credentials if configured,
falling back to the global env-var credentials."""
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 cls()
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(auth=self._auth, timeout=30.0)
def _dav_url(self, path: str) -> str:
return f"{self._dav_root}/{path.lstrip('/')}"
async def create_folder(self, path: str) -> None:
async with self._client() as c:
resp = await c.request("MKCOL", self._dav_url(path))
if resp.status_code not in (201, 405): # 405 = already exists
resp.raise_for_status()
async def get_file_metadata(self, path: str) -> FileMetadata:
body = (
'<?xml version="1.0"?>'
'<d:propfind xmlns:d="DAV:">'
" <d:prop><d:getcontentlength/><d:getetag/><d:getcontenttype/></d:prop>"
"</d:propfind>"
)
async with self._client() as c:
resp = await c.request(
"PROPFIND",
self._dav_url(path),
headers={"Depth": "0", "Content-Type": "application/xml"},
content=body,
)
resp.raise_for_status()
return _parse_propfind_single(resp.text, path)
async def list_folder(self, path: str) -> list[FileMetadata]:
body = (
'<?xml version="1.0"?>'
'<d:propfind xmlns:d="DAV:">'
" <d:prop><d:getcontentlength/><d:getetag/><d:getcontenttype/></d:prop>"
"</d:propfind>"
)
async with self._client() as c:
resp = await c.request(
"PROPFIND",
self._dav_url(path),
headers={"Depth": "1", "Content-Type": "application/xml"},
content=body,
)
resp.raise_for_status()
return _parse_propfind_multi(resp.text)
async def download(self, path: str) -> bytes:
async with self._client() as c:
resp = await c.get(self._dav_url(path))
resp.raise_for_status()
return resp.content
async def get_direct_url(self, path: str) -> str:
return self._dav_url(path)
async def delete(self, path: str) -> None:
async with self._client() as c:
resp = await c.request("DELETE", self._dav_url(path))
resp.raise_for_status()
async def get_activities(self, since_id: int = 0, limit: int = 50) -> list[dict[str, Any]]:
"""Fetch recent file activity from Nextcloud OCS API."""
url = f"{self._base}/ocs/v2.php/apps/activity/api/v2/activity/files"
params: dict[str, Any] = {"since": since_id, "limit": limit, "format": "json"}
async with self._client() as c:
resp = await c.get(url, params=params, headers={"OCS-APIRequest": "true"})
resp.raise_for_status()
data = resp.json()
return data.get("ocs", {}).get("data", [])
def _parse_propfind_single(xml_text: str, path: str) -> FileMetadata:
root = ET.fromstring(xml_text)
response = root.find(f"{_DAV_NS}response")
return _response_to_metadata(response, path) # type: ignore[arg-type]
def _parse_propfind_multi(xml_text: str) -> list[FileMetadata]:
root = ET.fromstring(xml_text)
results = []
for i, response in enumerate(root.findall(f"{_DAV_NS}response")):
if i == 0:
continue # skip the folder itself
href = response.findtext(f"{_DAV_NS}href") or ""
results.append(_response_to_metadata(response, href))
return results
def _response_to_metadata(response: ET.Element, fallback_path: str) -> FileMetadata:
propstat = response.find(f"{_DAV_NS}propstat")
prop = propstat.find(f"{_DAV_NS}prop") if propstat is not None else None # type: ignore[union-attr]
href = response.findtext(f"{_DAV_NS}href") or fallback_path
etag = (prop.findtext(f"{_DAV_NS}getetag") or "").strip('"') if prop is not None else ""
size_text = prop.findtext(f"{_DAV_NS}getcontentlength") if prop is not None else "0"
ctype = (
prop.findtext(f"{_DAV_NS}getcontenttype") or "application/octet-stream"
if prop is not None
else "application/octet-stream"
)
return FileMetadata(
path=href,
etag=etag,
size=int(size_text or 0),
content_type=ctype,
)