feat(transcode): optimize + on-the-fly quality + HLS streaming (§6.6)

POST /tracks/{id}/optimize enqueues a transcode; GET /stream/{id}?quality=
serves a cached Opus rendition (miss → master + background warm); GET
/stream/{id}/hls/{playlist.m3u8,segment} serves the worker-generated HLS
rendition (AAC-in-TS), with ?token= propagated onto segment URLs for players
that can't set headers. Hexagonal: Transcoder port, FfmpegTranscoder adapter,
TranscodeService + cache-path helpers, transcode_track worker (idempotent),
schemas + deps wiring. ffmpeg already in the image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Цвылев Александр Вадимович
2026-07-28 21:19:36 +03:00
parent 8271de34eb
commit 313af3a070
13 changed files with 384 additions and 11 deletions
+64 -6
View File
@@ -1,30 +1,53 @@
"""Audio streaming endpoint — direct stream with Range support."""
"""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
from fastapi import APIRouter, Header
from fastapi.responses import StreamingResponse
from fastapi import APIRouter, Header, Query, Response
from fastapi.responses import FileResponse, StreamingResponse
from app.api.deps import StreamingServiceDep, StreamUser
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,
) -> StreamingResponse:
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
@@ -37,3 +60,38 @@ async def stream_track(
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 = 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)
+23 -5
View File
@@ -1,7 +1,7 @@
"""Track endpoints."""
import uuid
from typing import Any
from typing import Annotated, Any
from fastapi import APIRouter, Query, Response
from fastapi.responses import StreamingResponse
@@ -30,10 +30,12 @@ from app.api.schemas.track import (
TrackOut,
TrackUpdate,
)
from app.api.schemas.transcode import OptimizeEnqueuedOut
from app.application.transcode_service import bitrate_for_quality
from app.domain.entities.album import Album
from app.domain.entities.track import Artist, Track
from app.domain.errors import NotFoundError
from app.workers.queue import enqueue
from app.domain.errors import NotFoundError, ValidationError
from app.workers.queue import enqueue, enqueue_transcode
router = APIRouter(prefix="/tracks", tags=["tracks"])
@@ -222,8 +224,24 @@ async def delete_track(
async def get_similar_tracks(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
@router.post("/{track_id}/optimize")
async def optimize_track(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
@router.post("/{track_id}/optimize", status_code=202)
async def optimize_track(
track_id: uuid.UUID,
track_repo: TrackRepoDep,
_: CurrentUser,
quality: Annotated[str, Query()] = "high",
) -> OptimizeEnqueuedOut:
"""Enqueue transcoding of a track into a cached Opus rendition + HLS (§6.6).
Heavy ffmpeg work runs in the worker; this only queues it."""
if bitrate_for_quality(quality) is None:
raise ValidationError(
f"Unknown quality '{quality}'; expected one of high, medium, low."
)
track = await track_repo.get_by_id(track_id)
if track is None:
raise NotFoundError(f"Track {track_id} not found.")
job_id = await enqueue_transcode(track_id, quality=quality, hls=True)
return OptimizeEnqueuedOut(status="enqueued", job_id=job_id, quality=quality)
@router.get("/{track_id}/lyrics")