feat: local storage logic & endpoints

This commit is contained in:
Senko-san
2026-06-07 15:34:06 +03:00
parent dfd512a13f
commit 81ea93c371
23 changed files with 945 additions and 18 deletions
+97
View File
@@ -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,
)
+116
View File
@@ -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)