feat(library): remote browse status + save/materialize API (§Phase2-3)
Docker Build & Publish / build (push) Successful in 1m11s
Docker Build & Publish / push (push) Failing after 6s
Docker Build & Publish / Prune old image versions (push) Has been skipped

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:
Senko-san
2026-06-14 18:11:01 +03:00
parent 58b98ab5ed
commit e45e578f54
12 changed files with 723 additions and 11 deletions
+108
View File
@@ -164,6 +164,114 @@ async def test_search_aggregates_fetch_sources(api: AsyncClient) -> None:
assert hit["title"] == "queen song"
async def test_search_reports_library_status(api: AsyncClient) -> None:
"""Remote browse (plan: Model C) — a fresh hit isn't in the library; after
saving it as a placeholder, the same search reports it as such."""
token = await _login(api)
headers = {"Authorization": f"Bearer {token}"}
resp = await api.get("/api/v1/search", params={"q": "queen"}, headers=headers)
hit = resp.json()["results"][0]
assert hit["in_library"] is False
assert hit["track_id"] is None
assert hit["availability"] is None
save = await api.post(
"/api/v1/tracks/remote",
json={
"source": hit["source"],
"source_id": hit["source_id"],
"title": hit["title"],
"artist": hit["artist"],
},
headers=headers,
)
assert save.status_code == 201
saved = save.json()
assert saved["availability"] == "remote"
assert saved["file_format"] is None
resp2 = await api.get("/api/v1/search", params={"q": "queen"}, headers=headers)
hit2 = resp2.json()["results"][0]
assert hit2["in_library"] is True
assert hit2["track_id"] == saved["id"]
assert hit2["availability"] == "remote"
async def test_save_remote_is_idempotent(api: AsyncClient) -> None:
token = await _login(api)
headers = {"Authorization": f"Bearer {token}"}
payload = {"source": "youtube", "source_id": "vid-idem", "title": "A", "artist": "Artist"}
first = await api.post("/api/v1/tracks/remote", json=payload, headers=headers)
second = await api.post("/api/v1/tracks/remote", json=payload, headers=headers)
assert first.status_code == 201
assert second.status_code == 201
assert first.json()["id"] == second.json()["id"]
async def test_materialize_flow(
api: AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Save a placeholder, materialize it on demand, and confirm it streams
afterwards (plan: Model C lazy materialization)."""
token = await _login(api)
headers = {"Authorization": f"Bearer {token}"}
save = await api.post(
"/api/v1/tracks/remote",
json={
"source": "youtube",
"source_id": "vid-mat-1",
"title": "Materialize Me",
"artist": "Artist",
},
headers=headers,
)
track_id = save.json()["id"]
assert save.json()["availability"] == "remote"
# Streaming a placeholder before materialization fails (no audio yet).
stream_before = await api.get(f"/api/v1/stream/{track_id}", headers=headers)
assert stream_before.status_code == 404
materialize = await api.post(f"/api/v1/tracks/{track_id}/materialize", headers=headers)
assert materialize.status_code == 200
body = materialize.json()
assert body["job"] is not None
job_id = body["job"]["id"]
assert body["job"]["track_id"] == track_id
# A second materialize request reuses the same in-flight job.
again = await api.post(f"/api/v1/tracks/{track_id}/materialize", headers=headers)
assert again.json()["job"]["id"] == job_id
# Run the worker task directly (bypasses Redis) with the fake fetch source.
import app.workers.tasks.materialize_task as mat_task
worker_dir = tmp_path / "worker-mat"
worker_dir.mkdir()
fake = SourceRegistry([FakeFetchSource(worker_dir)]) # type: ignore[list-item]
monkeypatch.setattr(mat_task, "build_source_registry", lambda _settings: fake)
result = await mat_task.materialize_track({}, job_id=job_id)
assert result["status"] == "done"
assert result["track_id"] == track_id
got = await api.get(f"/api/v1/tracks/{track_id}", headers=headers)
assert got.json()["availability"] == "local"
assert got.json()["file_format"] == "webm"
# Streaming now works.
stream_after = await api.get(f"/api/v1/stream/{track_id}", headers=headers)
assert stream_after.status_code == 200
# Materializing an already-local track is a no-op.
noop = await api.post(f"/api/v1/tracks/{track_id}/materialize", headers=headers)
assert noop.json()["job"] is None
async def test_source_scoped_search(api: AsyncClient) -> None:
token = await _login(api)
headers = {"Authorization": f"Bearer {token}"}
+255
View File
@@ -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
)