5c5df5d3cc
Add S3FileStorage adapter (any S3-compatible backend: AWS, MinIO, Garage) alongside the local adapter, selected via STORAGE_BACKEND. Proxied range streaming via get_object+Range; as_local_path downloads to a tempfile for ffmpeg/fpcalc. Rename track.file_path -> storage_uri across domain entity, ORM, repositories, port, and services, with an Alembic migration. Adds mocked S3 unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
117 lines
3.3 KiB
Python
117 lines
3.3 KiB
Python
"""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,
|
|
storage_uri=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)
|