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,
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""UploadService — handles user file uploads."""
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
import anyio
|
||||
|
||||
from app.domain.entities.user import User
|
||||
from app.domain.ports import ArtistRepository, FileStorage, TrackRepository
|
||||
|
||||
|
||||
class UploadFileProtocol(Protocol):
|
||||
filename: str | None
|
||||
|
||||
async def read(self, size: int = -1) -> bytes: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UploadResult:
|
||||
track_id: uuid.UUID
|
||||
title: str
|
||||
already_exists: bool
|
||||
|
||||
|
||||
async def _stream_to_temp(upload: UploadFileProtocol, dest: Path) -> tuple[str, int]:
|
||||
h = hashlib.sha256()
|
||||
size = 0
|
||||
async with await anyio.open_file(dest, "wb") as out:
|
||||
while True:
|
||||
chunk = await upload.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
await out.write(chunk)
|
||||
size += len(chunk)
|
||||
return h.hexdigest(), size
|
||||
|
||||
|
||||
class UploadService:
|
||||
def __init__(
|
||||
self,
|
||||
tracks: TrackRepository,
|
||||
artists: ArtistRepository,
|
||||
storage: FileStorage,
|
||||
tmp_dir: Path | None = None,
|
||||
) -> None:
|
||||
self._tracks = tracks
|
||||
self._artists = artists
|
||||
self._storage = storage
|
||||
self._tmp_dir = tmp_dir
|
||||
|
||||
async def handle_upload(
|
||||
self,
|
||||
*,
|
||||
upload: UploadFileProtocol,
|
||||
user: User,
|
||||
) -> UploadResult:
|
||||
filename = upload.filename or "unknown"
|
||||
ext = Path(filename).suffix.lower().lstrip(".") or "bin"
|
||||
title = Path(filename).stem or "Unknown"
|
||||
|
||||
fd, tmp_str = tempfile.mkstemp(
|
||||
suffix=f".{ext}",
|
||||
dir=str(self._tmp_dir) if self._tmp_dir else None,
|
||||
)
|
||||
tmp_path = Path(tmp_str)
|
||||
try:
|
||||
os.close(fd)
|
||||
sha256_hex, file_size = await _stream_to_temp(upload, tmp_path)
|
||||
|
||||
existing = await self._tracks.get_by_source("upload", sha256_hex)
|
||||
if existing is not None:
|
||||
return UploadResult(
|
||||
track_id=existing.id,
|
||||
title=existing.title,
|
||||
already_exists=True,
|
||||
)
|
||||
|
||||
track_id = uuid.uuid4()
|
||||
key = f"tracks/{str(track_id)[:2]}/{track_id}.{ext}"
|
||||
|
||||
await self._storage.save_file(key, tmp_path)
|
||||
try:
|
||||
artist = await self._artists.get_or_create("Unknown Artist")
|
||||
track = await self._tracks.add(
|
||||
id=track_id,
|
||||
title=title,
|
||||
artist_id=artist.id,
|
||||
file_path=key,
|
||||
file_format=ext,
|
||||
file_size=file_size,
|
||||
source="upload",
|
||||
source_id=sha256_hex,
|
||||
metadata_status="pending",
|
||||
added_by=user.id,
|
||||
)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._storage.delete(key)
|
||||
raise
|
||||
|
||||
# TODO(1D): enqueue metadata enrichment task
|
||||
|
||||
return UploadResult(
|
||||
track_id=track.id,
|
||||
title=track.title,
|
||||
already_exists=False,
|
||||
)
|
||||
finally:
|
||||
await anyio.Path(tmp_path).unlink(missing_ok=True)
|
||||
Reference in New Issue
Block a user