feat(library): remote browse status + save/materialize API (§Phase2-3)
Search results now report whether a hit is already saved (in_library,
track_id, availability). New RemoteLibraryService backs POST
/tracks/remote (idempotent placeholder save) and POST
/tracks/{id}/materialize (on-demand fetch via a new materialize_track
arq task, reusing in-flight jobs).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user