fix: proxy audio stream through API with query-param token auth

WaveSurfer makes plain browser fetches without Authorization headers,
causing 401s on the stream endpoint. The stream endpoint now accepts
a ?token= query param in addition to the Authorization header, and
proxies audio bytes directly through FastAPI instead of redirecting to
raw WebDAV (which would require a second Nextcloud auth challenge).
Falls back to nc_file_path if HLS transcoding hasn't run yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Steffen Schuhmann
2026-03-29 13:23:51 +02:00
parent 47bc802775
commit 53da085f28
2 changed files with 57 additions and 8 deletions

View File

@@ -1,22 +1,61 @@
import uuid
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import Response
from fastapi.security.utils import get_authorization_scheme_param
from sqlalchemy.ext.asyncio import AsyncSession
from rehearsalhub.db.engine import get_session
from rehearsalhub.db.models import Member
from rehearsalhub.dependencies import get_current_member
from rehearsalhub.repositories.audio_version import AudioVersionRepository
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.services.band import BandService
from rehearsalhub.services.song import SongService
from rehearsalhub.storage.nextcloud import NextcloudClient
router = APIRouter(tags=["versions"])
_AUDIO_CONTENT_TYPES: dict[str, str] = {
".mp3": "audio/mpeg",
".wav": "audio/wav",
".flac": "audio/flac",
".ogg": "audio/ogg",
".m4a": "audio/mp4",
".aac": "audio/aac",
".opus": "audio/ogg",
".m3u8": "application/vnd.apple.mpegurl",
}
async def _member_from_request(
request: Request,
token: str | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> Member:
"""Resolve member from Authorization header or ?token= query param."""
auth_header = request.headers.get("Authorization")
if auth_header:
scheme, param = get_authorization_scheme_param(auth_header)
if scheme.lower() == "bearer":
token = param
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token required")
try:
payload = decode_token(token)
member_id = uuid.UUID(payload["sub"])
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
member = await MemberRepository(session).get_by_id(member_id)
if member is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Member not found")
return member
async def _get_version_and_assert_band_membership(
version_id: uuid.UUID,
@@ -110,11 +149,19 @@ async def get_waveform(
async def stream_version(
version_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_member: Member = Depends(get_current_member),
current_member: Member = Depends(_member_from_request),
):
version, _ = await _get_version_and_assert_band_membership(version_id, session, current_member)
if not version.cdn_hls_base:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stream not ready")
# Prefer HLS playlist if transcoding finished, otherwise serve the raw file
if version.cdn_hls_base:
file_path = f"{version.cdn_hls_base}/playlist.m3u8"
elif version.nc_file_path:
file_path = version.nc_file_path
else:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No audio file")
storage = NextcloudClient()
url = await storage.get_direct_url(f"{version.cdn_hls_base}/playlist.m3u8")
return RedirectResponse(url=url, status_code=302)
data = await storage.download(file_path)
content_type = _AUDIO_CONTENT_TYPES.get(Path(file_path).suffix.lower(), "application/octet-stream")
return Response(content=data, media_type=content_type)