"""UploadService — handles user file uploads.""" import contextlib import hashlib import os import tempfile import uuid from collections.abc import Awaitable, Callable 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 EnrichEnqueuer = Callable[[uuid.UUID], Awaitable[None]] 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, enqueue_enrich: EnrichEnqueuer | None = None, ) -> None: self._tracks = tracks self._artists = artists self._storage = storage self._tmp_dir = tmp_dir self._enqueue_enrich = enqueue_enrich 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 if self._enqueue_enrich is not None: await self._enqueue_enrich(track.id) return UploadResult( track_id=track.id, title=track.title, already_exists=False, ) finally: await anyio.Path(tmp_path).unlink(missing_ok=True)