"""RemoteLibraryService — save-to-library + materialize for remote browse hits (plan: Model C, on-demand YTM library). Two operations: * ``save_remote`` persists a placeholder ``Track`` (``availability="remote"``, ``storage_uri=None``) for a remote browse hit. Idempotent on ``(source, source_id)`` — CLAUDE.md dedup. * ``request_materialize`` lazily fills a placeholder's audio in place: it creates (or reuses) a ``DownloadJob`` pointing at the existing track and enqueues the materialize worker, which calls ``TrackRepository.materialize`` on completion. ``track.id`` never changes (CLAUDE.md), so likes/playlists/ queue entries referencing the placeholder keep working once it's filled in. """ import uuid from collections.abc import Awaitable, Callable from dataclasses import dataclass from app.domain.entities.download import DownloadJob from app.domain.entities.track import Track from app.domain.errors import NotFoundError, ValidationError from app.domain.ports import ArtistRepository, DownloadJobRepository, TrackRepository _UNKNOWN_ARTIST = "Unknown Artist" # (job_id) -> None — enqueue the materialize worker, same deferred pattern as # download/enrich enqueuers. MaterializeEnqueuer = Callable[[uuid.UUID], Awaitable[None]] @dataclass(frozen=True) class MaterializeOutcome: """Result of requesting materialization. ``job`` is ``None`` when the track is already ``local`` — nothing to do, the caller can stream immediately. Otherwise it's the (new or already in-flight) job filling the placeholder.""" track: Track job: DownloadJob | None class RemoteLibraryService: def __init__( self, *, tracks: TrackRepository, artists: ArtistRepository, jobs: DownloadJobRepository, enqueue_materialize: MaterializeEnqueuer | None = None, ) -> None: self._tracks = tracks self._artists = artists self._jobs = jobs self._enqueue_materialize = enqueue_materialize async def save_remote( self, *, source: str, source_id: str, title: str, artist: str | None, added_by: uuid.UUID | None, ) -> Track: """Persist a placeholder for a remote browse hit. Idempotent: a hit already saved (by ``(source, source_id)``) is returned as-is.""" source_id = source_id.strip() if not source_id: raise ValidationError("A source_id is required to save.") existing = await self._tracks.get_by_source(source, source_id) if existing is not None: return existing artist_entity = await self._artists.get_or_create(artist or _UNKNOWN_ARTIST) return await self._tracks.add( id=uuid.uuid4(), title=title, artist_id=artist_entity.id, storage_uri=None, file_format=None, file_size=None, source=source, source_id=source_id, metadata_status="pending", added_by=added_by, availability="remote", ) async def request_materialize( self, track_id: uuid.UUID, *, requested_by: uuid.UUID | None ) -> MaterializeOutcome: """Kick off (or report on) materializing a placeholder track. Already-local tracks are a no-op (``job=None``). A track with no remote ``source_id`` (e.g. a deleted upload row reused for something else) can't be materialized.""" track = await self._tracks.get_by_id(track_id) if track is None: raise NotFoundError(f"Track {track_id} not found.") if track.availability == "local": return MaterializeOutcome(track=track, job=None) if track.source_id is None: raise ValidationError("Track has no remote source to materialize from.") active = await self._jobs.get_active_for_source(track.source, track.source_id) if active is not None: return MaterializeOutcome(track=track, job=active) job = await self._jobs.add( source=track.source, source_id=track.source_id, query=None, requested_by=requested_by, ) await self._jobs.set_status(job.id, status="queued", track_id=track.id) if self._enqueue_materialize is not None: await self._enqueue_materialize(job.id) refreshed = await self._jobs.get_by_id(job.id) return MaterializeOutcome(track=track, job=refreshed if refreshed is not None else job)