Files
mcma-backend/app/application/streaming_service.py
T
2026-06-07 15:34:06 +03:00

98 lines
2.7 KiB
Python

"""StreamingService — resolves a track and opens a byte-range stream."""
import re
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
from app.domain.errors import NotFoundError, RangeNotSatisfiableError
from app.domain.ports import FileStorage, TrackRepository
_FORMAT_CONTENT_TYPE: dict[str, str] = {
"mp3": "audio/mpeg",
"flac": "audio/flac",
"m4a": "audio/mp4",
"aac": "audio/aac",
"ogg": "audio/ogg",
"opus": "audio/ogg",
"wav": "audio/wav",
"wma": "audio/x-ms-wma",
"aiff": "audio/aiff",
"aif": "audio/aiff",
}
_RANGE_RE = re.compile(r"bytes=(\d+)-(\d*)")
@dataclass
class StreamResult:
stream: AsyncIterator[bytes]
total_size: int
content_length: int
content_type: str
start: int
end: int
is_partial: bool
def _parse_range(header: str | None, total_size: int) -> tuple[int, int | None, bool]:
"""Return (start, end, is_partial). Raises RangeNotSatisfiableError on invalid range."""
if header is None:
return 0, None, False
m = _RANGE_RE.fullmatch(header.strip())
if not m:
return 0, None, False # malformed → treat as absent per RFC 7233
start = int(m.group(1))
end: int | None = int(m.group(2)) if m.group(2) else None
if start >= total_size:
raise RangeNotSatisfiableError(total_size)
if end is not None:
if end >= total_size:
end = total_size - 1
if end < start:
raise RangeNotSatisfiableError(total_size)
return start, end, True
class StreamingService:
def __init__(self, tracks: TrackRepository, storage: FileStorage) -> None:
self._tracks = tracks
self._storage = storage
async def open_stream(
self,
track_id: uuid.UUID,
range_header: str | None,
) -> StreamResult:
track = await self._tracks.get_by_id(track_id)
if track is None:
raise NotFoundError("Track not found.")
stat = await self._storage.stat(track.file_path)
total_size = stat.size
content_type = stat.content_type or _FORMAT_CONTENT_TYPE.get(
track.file_format.lower(), "application/octet-stream"
)
start, end, is_partial = _parse_range(range_header, total_size)
stream, _ = await self._storage.open_range(track.file_path, start, end)
actual_end = end if end is not None else total_size - 1
content_length = actual_end - start + 1
return StreamResult(
stream=stream,
total_size=total_size,
content_length=content_length,
content_type=content_type,
start=start,
end=actual_end,
is_partial=is_partial,
)