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:
@@ -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}"}
|
||||
|
||||
Reference in New Issue
Block a user