9a78cf5261
- run blocking Path.exists/read_text off the event loop via anyio.to_thread - publish HLS renditions by building into a temp dir then atomic rename, so a reader never sees a playlist referencing half-written segments - per-writer temp name for opus so two concurrent jobs can't interleave - drop a track's cached renditions on delete so they don't dangle Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
99 lines
3.5 KiB
Python
99 lines
3.5 KiB
Python
"""Audio streaming — direct byte-range stream, transcoded quality, and HLS.
|
|
|
|
``GET /stream/{id}`` streams the master with Range support, or a cached Opus
|
|
rendition when ``?quality=`` is set (a cache miss falls back to the master and
|
|
warms the cache in the background — playback never waits on ffmpeg). ``/hls/*``
|
|
serves the cached HLS rendition (generated by the ``transcode_track`` worker).
|
|
"""
|
|
|
|
import re
|
|
import uuid
|
|
from typing import Annotated
|
|
|
|
import anyio
|
|
from fastapi import APIRouter, Header, Query, Response
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
|
|
from app.api.deps import StreamingServiceDep, StreamUser, TranscodeServiceDep
|
|
from app.domain.errors import NotFoundError
|
|
from app.workers.queue import enqueue_transcode_quiet
|
|
|
|
router = APIRouter(prefix="/stream", tags=["streaming"])
|
|
|
|
_HLS_PLAYLIST_TYPE = "application/vnd.apple.mpegurl"
|
|
_HLS_SEGMENT_TYPE = "video/mp2t"
|
|
_OPUS_TYPE = "audio/ogg"
|
|
_SEGMENT_LINE_RE = re.compile(r"^(seg_\d+\.ts)$", re.MULTILINE)
|
|
|
|
|
|
@router.get("/{track_id}")
|
|
async def stream_track(
|
|
track_id: uuid.UUID,
|
|
service: StreamingServiceDep,
|
|
transcode: TranscodeServiceDep,
|
|
_user: StreamUser,
|
|
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
|
quality: Annotated[str | None, Query()] = None,
|
|
) -> Response:
|
|
# A quality rendition, if one is cached; otherwise fall back to the master
|
|
# and enqueue generation so the next play gets it (graceful degradation).
|
|
if quality and quality != "original":
|
|
cached = await transcode.resolve_quality_file(track_id, quality)
|
|
if cached is not None:
|
|
return FileResponse(cached, media_type=_OPUS_TYPE)
|
|
await enqueue_transcode_quiet(track_id, quality=quality, hls=False)
|
|
|
|
result = await service.open_stream(track_id, range_header)
|
|
|
|
headers = {
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": str(result.content_length),
|
|
}
|
|
if result.is_partial:
|
|
headers["Content-Range"] = f"bytes {result.start}-{result.end}/{result.total_size}"
|
|
status_code = 206
|
|
else:
|
|
status_code = 200
|
|
|
|
return StreamingResponse(
|
|
result.stream,
|
|
status_code=status_code,
|
|
headers=headers,
|
|
media_type=result.content_type,
|
|
)
|
|
|
|
|
|
@router.get("/{track_id}/hls/playlist.m3u8")
|
|
async def stream_hls_playlist(
|
|
track_id: uuid.UUID,
|
|
transcode: TranscodeServiceDep,
|
|
_user: StreamUser,
|
|
token: Annotated[str | None, Query()] = None,
|
|
) -> Response:
|
|
"""Serve the cached HLS playlist. On a miss, kick off generation and 404 so
|
|
the client retries. Segment URLs are relative; when the request carried a
|
|
``?token=`` (players can't set an Authorization header), it's appended to
|
|
each segment line so the segment requests authenticate the same way."""
|
|
path = await transcode.hls_playlist(track_id)
|
|
if path is None:
|
|
await enqueue_transcode_quiet(track_id, hls=True)
|
|
raise NotFoundError("HLS rendition is being prepared; retry shortly.")
|
|
|
|
body = await anyio.to_thread.run_sync(path.read_text)
|
|
if token:
|
|
body = _SEGMENT_LINE_RE.sub(rf"\1?token={token}", body)
|
|
return Response(body, media_type=_HLS_PLAYLIST_TYPE)
|
|
|
|
|
|
@router.get("/{track_id}/hls/{segment}")
|
|
async def stream_hls_segment(
|
|
track_id: uuid.UUID,
|
|
segment: str,
|
|
transcode: TranscodeServiceDep,
|
|
_user: StreamUser,
|
|
) -> FileResponse:
|
|
path = transcode.hls_segment(track_id, segment)
|
|
if path is None:
|
|
raise NotFoundError("Segment not found.")
|
|
return FileResponse(path, media_type=_HLS_SEGMENT_TYPE)
|