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:
143
api/src/rehearsalhub/storage/nextcloud.py
Normal file
143
api/src/rehearsalhub/storage/nextcloud.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user