Files
Senko-san 58b98ab5ed
Docker Build & Publish / build (push) Successful in 1m10s
Docker Build & Publish / push (push) Failing after 7s
Docker Build & Publish / Prune old image versions (push) Has been skipped
feat(library): lazy materialization foundation for remote tracks (§Phase1)
Adds nullable storage fields + availability column on tracks, remote
source/source_id identity on albums/artists, TrackRepository.materialize()
and get_or_create_remote() repos — groundwork for on-demand YTM library
(placeholders saved without audio, materialized in-place on first play).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:51:43 +03:00

101 lines
2.8 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.")
storage_uri = track.storage_uri
if storage_uri is None:
raise NotFoundError("Track is not yet downloaded.")
stat = await self._storage.stat(storage_uri)
total_size = stat.size
content_type = stat.content_type or _FORMAT_CONTENT_TYPE.get(
(track.file_format or "").lower(), "application/octet-stream"
)
start, end, is_partial = _parse_range(range_header, total_size)
stream, _ = await self._storage.open_range(storage_uri, 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,
)