"""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, )