feat: local storage logic & endpoints
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user