feat: add constellation client for network-wide like counts (#360)

* feat: add constellation client for network-wide like counts

integrates with constellation.microcosm.blue backlink index to query
like counts across the entire atproto network, not just plyr.fm.

- adds get_like_count() for raw queries
- exports get_like_count_safe() from _internal (fallback on error)
- uses settings.atproto.like_collection for environment awareness

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: add user-agent header to constellation requests

per fig's request in constellation readme - be a good citizen

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub d45bdd28 6a822449

Changed files
+170
backend
src
backend
tests
+2
backend/src/backend/_internal/__init__.py
··· 15 15 start_oauth_flow, 16 16 update_session_tokens, 17 17 ) 18 + from backend._internal.constellation import get_like_count_safe 18 19 from backend._internal.notifications import notification_service 19 20 from backend._internal.queue import queue_service 20 21 ··· 25 26 "create_exchange_token", 26 27 "create_session", 27 28 "delete_session", 29 + "get_like_count_safe", 28 30 "get_session", 29 31 "handle_oauth_callback", 30 32 "notification_service",
+57
backend/src/backend/_internal/constellation.py
··· 1 + """client for constellation atproto backlink index. 2 + 3 + constellation indexes backlinks across the atproto network, enabling 4 + queries like "how many likes does this track have?" across all apps, 5 + not just plyr.fm. 6 + 7 + public instance: https://constellation.microcosm.blue 8 + source: https://github.com/at-microcosm/microcosm-rs/tree/main/constellation 9 + """ 10 + 11 + import logging 12 + 13 + import httpx 14 + 15 + from backend.config import settings 16 + 17 + logger = logging.getLogger(__name__) 18 + 19 + CONSTELLATION_URL = "https://constellation.microcosm.blue" 20 + USER_AGENT = "plyr.fm (zzstoatzz.io)" 21 + 22 + 23 + async def get_like_count(target_uri: str) -> int: 24 + """get network-wide like count for a target record. 25 + 26 + queries constellation's backlink index for all fm.plyr.like records 27 + pointing to the target URI. 28 + 29 + args: 30 + target_uri: the at:// URI of the record to count likes for 31 + 32 + returns: 33 + total like count from across the atproto network 34 + """ 35 + async with httpx.AsyncClient(headers={"User-Agent": USER_AGENT}) as client: 36 + resp = await client.get( 37 + f"{CONSTELLATION_URL}/links/count", 38 + params={ 39 + "target": target_uri, 40 + "collection": settings.atproto.like_collection, 41 + "path": ".subject.uri", 42 + }, 43 + ) 44 + resp.raise_for_status() 45 + return resp.json()["count"] 46 + 47 + 48 + async def get_like_count_safe(target_uri: str, fallback: int = 0) -> int: 49 + """get like count with fallback on error. 50 + 51 + use this when you don't want constellation failures to break the request. 52 + """ 53 + try: 54 + return await get_like_count(target_uri) 55 + except Exception as e: 56 + logger.warning(f"constellation query failed for {target_uri}: {e}") 57 + return fallback
+111
backend/tests/test_constellation.py
··· 1 + """tests for constellation client.""" 2 + 3 + from unittest.mock import AsyncMock, MagicMock, patch 4 + 5 + import pytest 6 + 7 + from backend._internal.constellation import get_like_count, get_like_count_safe 8 + 9 + 10 + @pytest.fixture 11 + def mock_settings(): 12 + """mock settings with like_collection.""" 13 + with patch("backend._internal.constellation.settings") as mock: 14 + mock.atproto.like_collection = "fm.plyr.dev.like" 15 + yield mock 16 + 17 + 18 + class TestGetLikeCount: 19 + """tests for get_like_count.""" 20 + 21 + async def test_returns_count_from_response(self, mock_settings): 22 + """should return count from constellation response.""" 23 + mock_response = MagicMock() 24 + mock_response.json.return_value = {"count": 42} 25 + mock_response.raise_for_status = MagicMock() 26 + 27 + with patch("backend._internal.constellation.httpx.AsyncClient") as mock_client: 28 + mock_client.return_value.__aenter__.return_value.get = AsyncMock( 29 + return_value=mock_response 30 + ) 31 + 32 + result = await get_like_count("at://did:plc:xxx/fm.plyr.track/abc") 33 + 34 + assert result == 42 35 + 36 + async def test_calls_correct_endpoint(self, mock_settings): 37 + """should call constellation with correct params.""" 38 + mock_response = MagicMock() 39 + mock_response.json.return_value = {"count": 0} 40 + mock_response.raise_for_status = MagicMock() 41 + 42 + with patch("backend._internal.constellation.httpx.AsyncClient") as mock_client: 43 + mock_get = AsyncMock(return_value=mock_response) 44 + mock_client.return_value.__aenter__.return_value.get = mock_get 45 + 46 + await get_like_count("at://did:plc:xxx/fm.plyr.track/abc") 47 + 48 + mock_get.assert_called_once_with( 49 + "https://constellation.microcosm.blue/links/count", 50 + params={ 51 + "target": "at://did:plc:xxx/fm.plyr.track/abc", 52 + "collection": "fm.plyr.dev.like", 53 + "path": ".subject.uri", 54 + }, 55 + ) 56 + 57 + async def test_raises_on_http_error(self, mock_settings): 58 + """should raise when constellation returns error.""" 59 + mock_response = MagicMock() 60 + mock_response.raise_for_status.side_effect = Exception("500 error") 61 + 62 + with patch("backend._internal.constellation.httpx.AsyncClient") as mock_client: 63 + mock_client.return_value.__aenter__.return_value.get = AsyncMock( 64 + return_value=mock_response 65 + ) 66 + 67 + with pytest.raises(Exception, match="500 error"): 68 + await get_like_count("at://did:plc:xxx/fm.plyr.track/abc") 69 + 70 + 71 + class TestGetLikeCountSafe: 72 + """tests for get_like_count_safe.""" 73 + 74 + async def test_returns_count_on_success(self, mock_settings): 75 + """should return count when successful.""" 76 + mock_response = MagicMock() 77 + mock_response.json.return_value = {"count": 10} 78 + mock_response.raise_for_status = MagicMock() 79 + 80 + with patch("backend._internal.constellation.httpx.AsyncClient") as mock_client: 81 + mock_client.return_value.__aenter__.return_value.get = AsyncMock( 82 + return_value=mock_response 83 + ) 84 + 85 + result = await get_like_count_safe("at://did:plc:xxx/fm.plyr.track/abc") 86 + 87 + assert result == 10 88 + 89 + async def test_returns_fallback_on_error(self, mock_settings): 90 + """should return fallback when constellation fails.""" 91 + with patch("backend._internal.constellation.httpx.AsyncClient") as mock_client: 92 + mock_client.return_value.__aenter__.return_value.get = AsyncMock( 93 + side_effect=Exception("connection failed") 94 + ) 95 + 96 + result = await get_like_count_safe( 97 + "at://did:plc:xxx/fm.plyr.track/abc", fallback=99 98 + ) 99 + 100 + assert result == 99 101 + 102 + async def test_default_fallback_is_zero(self, mock_settings): 103 + """should default to 0 fallback.""" 104 + with patch("backend._internal.constellation.httpx.AsyncClient") as mock_client: 105 + mock_client.return_value.__aenter__.return_value.get = AsyncMock( 106 + side_effect=Exception("connection failed") 107 + ) 108 + 109 + result = await get_like_count_safe("at://did:plc:xxx/fm.plyr.track/abc") 110 + 111 + assert result == 0