98 lines
2.7 KiB
Python
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,
|
|
)
|