e45e578f54
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>
123 lines
4.5 KiB
Python
123 lines
4.5 KiB
Python
"""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)
|