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,255 @@
|
||||
"""Unit tests for RemoteLibraryService — DB-free, in-memory fakes (plan: Model C
|
||||
remote browse + lazy materialization)."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from app.application.remote_library_service import RemoteLibraryService
|
||||
from app.domain.entities import Artist, DownloadJob, Track
|
||||
from app.domain.errors import NotFoundError, ValidationError
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class FakeArtistRepo:
|
||||
def __init__(self) -> None:
|
||||
self._by_name: dict[str, Artist] = {}
|
||||
|
||||
async def get_or_create(self, name: str) -> Artist:
|
||||
if name not in self._by_name:
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
self._by_name[name] = Artist(
|
||||
id=uuid.uuid4(),
|
||||
name=name,
|
||||
source=None,
|
||||
source_id=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
return self._by_name[name]
|
||||
|
||||
|
||||
class FakeTrackRepo:
|
||||
def __init__(self) -> None:
|
||||
self.by_id: dict[uuid.UUID, Track] = {}
|
||||
self.by_source: dict[tuple[str, str], Track] = {}
|
||||
|
||||
async def get_by_id(self, track_id: uuid.UUID) -> Track | None:
|
||||
return self.by_id.get(track_id)
|
||||
|
||||
async def get_by_source(self, source: str, source_id: str) -> Track | None:
|
||||
return self.by_source.get((source, source_id))
|
||||
|
||||
async def add(self, **kw: object) -> Track:
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
track = Track(
|
||||
id=kw["id"], # type: ignore[arg-type]
|
||||
title=str(kw["title"]),
|
||||
artist_id=kw["artist_id"], # type: ignore[arg-type]
|
||||
album_id=None,
|
||||
storage_uri=kw["storage_uri"], # type: ignore[arg-type]
|
||||
file_format=kw["file_format"], # type: ignore[arg-type]
|
||||
file_size=kw["file_size"], # type: ignore[arg-type]
|
||||
source=str(kw["source"]),
|
||||
source_id=str(kw["source_id"]),
|
||||
duration_seconds=None,
|
||||
genre=None,
|
||||
year=None,
|
||||
track_number=None,
|
||||
metadata_status=str(kw["metadata_status"]),
|
||||
metadata_error=None,
|
||||
enriched_at=None,
|
||||
availability=str(kw["availability"]),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self.by_id[track.id] = track
|
||||
self.by_source[(track.source, track.source_id)] = track
|
||||
return track
|
||||
|
||||
|
||||
def _local_track(source: str = "youtube", source_id: str = "local-1") -> Track:
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
return Track(
|
||||
id=uuid.uuid4(),
|
||||
title="Already Here",
|
||||
artist_id=uuid.uuid4(),
|
||||
album_id=None,
|
||||
storage_uri="tracks/aa/aa.m4a",
|
||||
file_format="m4a",
|
||||
file_size=123,
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
duration_seconds=None,
|
||||
genre=None,
|
||||
year=None,
|
||||
track_number=None,
|
||||
metadata_status="pending",
|
||||
metadata_error=None,
|
||||
enriched_at=None,
|
||||
availability="local",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
class FakeJobRepo:
|
||||
def __init__(self) -> None:
|
||||
self.jobs: dict[uuid.UUID, DownloadJob] = {}
|
||||
self.active: dict[tuple[str, str], DownloadJob] = {}
|
||||
|
||||
async def add(self, **kw: object) -> DownloadJob:
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
job = DownloadJob(
|
||||
id=uuid.uuid4(),
|
||||
source=str(kw["source"]),
|
||||
source_id=kw.get("source_id"), # type: ignore[arg-type]
|
||||
query=kw.get("query"), # type: ignore[arg-type]
|
||||
requested_by=kw.get("requested_by"), # type: ignore[arg-type]
|
||||
status="queued",
|
||||
progress=0.0,
|
||||
error_message=None,
|
||||
retry_count=0,
|
||||
track_id=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self.jobs[job.id] = job
|
||||
return job
|
||||
|
||||
async def get_by_id(self, job_id: uuid.UUID) -> DownloadJob | None:
|
||||
return self.jobs.get(job_id)
|
||||
|
||||
async def get_active_for_source(self, source: str, source_id: str) -> DownloadJob | None:
|
||||
return self.active.get((source, source_id))
|
||||
|
||||
async def set_status(self, job_id: uuid.UUID, **kw: object) -> None:
|
||||
job = self.jobs[job_id]
|
||||
track_id = kw.get("track_id")
|
||||
if track_id is not None:
|
||||
self.jobs[job_id] = DownloadJob(
|
||||
id=job.id,
|
||||
source=job.source,
|
||||
source_id=job.source_id,
|
||||
query=job.query,
|
||||
requested_by=job.requested_by,
|
||||
status=str(kw.get("status", job.status)),
|
||||
progress=job.progress,
|
||||
error_message=job.error_message,
|
||||
retry_count=job.retry_count,
|
||||
track_id=track_id, # type: ignore[arg-type]
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _service(
|
||||
*,
|
||||
tracks: FakeTrackRepo,
|
||||
artists: FakeArtistRepo,
|
||||
jobs: FakeJobRepo,
|
||||
enqueued: list[uuid.UUID],
|
||||
) -> RemoteLibraryService:
|
||||
async def enqueue_materialize(job_id: uuid.UUID) -> None:
|
||||
enqueued.append(job_id)
|
||||
|
||||
return RemoteLibraryService(
|
||||
tracks=tracks, # type: ignore[arg-type]
|
||||
artists=artists, # type: ignore[arg-type]
|
||||
jobs=jobs, # type: ignore[arg-type]
|
||||
enqueue_materialize=enqueue_materialize,
|
||||
)
|
||||
|
||||
|
||||
async def test_save_remote_creates_placeholder() -> None:
|
||||
tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
|
||||
service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)
|
||||
|
||||
track = await service.save_remote(
|
||||
source="youtube", source_id="abc", title="Bohemian Rhapsody", artist="Queen", added_by=None
|
||||
)
|
||||
|
||||
assert track.availability == "remote"
|
||||
assert track.storage_uri is None
|
||||
assert track.file_format is None
|
||||
assert track.source == "youtube"
|
||||
assert track.source_id == "abc"
|
||||
|
||||
|
||||
async def test_save_remote_is_idempotent() -> None:
|
||||
tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
|
||||
service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)
|
||||
|
||||
first = await service.save_remote(
|
||||
source="youtube", source_id="abc", title="A", artist="Queen", added_by=None
|
||||
)
|
||||
second = await service.save_remote(
|
||||
source="youtube", source_id="abc", title="B", artist="Other", added_by=None
|
||||
)
|
||||
|
||||
assert first.id == second.id
|
||||
assert second.title == "A" # untouched by the second call
|
||||
|
||||
|
||||
async def test_materialize_already_local_is_noop() -> None:
|
||||
tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
|
||||
track = _local_track()
|
||||
tracks.by_id[track.id] = track
|
||||
service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)
|
||||
|
||||
outcome = await service.request_materialize(track.id, requested_by=None)
|
||||
|
||||
assert outcome.job is None
|
||||
assert outcome.track.id == track.id
|
||||
assert enq == []
|
||||
|
||||
|
||||
async def test_materialize_remote_creates_and_enqueues_job() -> None:
|
||||
tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
|
||||
service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)
|
||||
track = await service.save_remote(
|
||||
source="youtube", source_id="abc", title="A", artist="Queen", added_by=None
|
||||
)
|
||||
|
||||
outcome = await service.request_materialize(track.id, requested_by=None)
|
||||
|
||||
assert outcome.job is not None
|
||||
assert outcome.job.source == "youtube"
|
||||
assert outcome.job.source_id == "abc"
|
||||
assert outcome.job.track_id == track.id
|
||||
assert enq == [outcome.job.id]
|
||||
|
||||
|
||||
async def test_materialize_reuses_active_job() -> None:
|
||||
tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
|
||||
service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)
|
||||
track = await service.save_remote(
|
||||
source="youtube", source_id="abc", title="A", artist="Queen", added_by=None
|
||||
)
|
||||
existing = await jobs.add(source="youtube", source_id="abc", query=None, requested_by=None)
|
||||
jobs.active[("youtube", "abc")] = existing
|
||||
|
||||
outcome = await service.request_materialize(track.id, requested_by=None)
|
||||
|
||||
assert outcome.job is not None
|
||||
assert outcome.job.id == existing.id
|
||||
assert enq == [] # not re-enqueued
|
||||
|
||||
|
||||
async def test_materialize_missing_track_raises() -> None:
|
||||
tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
|
||||
service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
await service.request_materialize(uuid.uuid4(), requested_by=None)
|
||||
|
||||
|
||||
async def test_save_remote_requires_source_id() -> None:
|
||||
tracks, artists, jobs, enq = FakeTrackRepo(), FakeArtistRepo(), FakeJobRepo(), []
|
||||
service = _service(tracks=tracks, artists=artists, jobs=jobs, enqueued=enq)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
await service.save_remote(
|
||||
source="youtube", source_id=" ", title="A", artist=None, added_by=None
|
||||
)
|
||||
Reference in New Issue
Block a user