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