audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

tag filter: persist selection, no empty flash, sort left (#1218)

* tag filter: persist selection, no empty flash, selected tags sort left

- Persist activeTags in localStorage so selections survive page refresh
- Restore saved tags on init (TracksCache + TagFilter SvelteSet)
- Don't clear tracks array in setTags() — keep stale data visible while
loading to avoid the blank flash (loading spinner still shows)
- Sort selected tags to the front of the chip row

The "slow clear" feeling was caused by the empty flash: setTags([])
cleared tracks to [] immediately, then the unfiltered /tracks/ request
takes 250-1200ms (atprotofans.validate_supporter external HTTP call).
Now old tracks stay visible during the fetch.

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

* perf: cache atprotofans validation in Redis, parallelize with DB work

The validate_supporter external HTTP call to atprotofans.com was taking
80-1100ms per request and running sequentially after all DB queries.
Two fixes:

1. Redis cache (5-min TTL) for supporter validation results — subsequent
page loads skip the external call entirely.

2. Start supporter validation as an asyncio task immediately after getting
the track list, so it runs concurrently with batch aggregations, PDS
resolution, and image resolution instead of after them.

Also adds visual loading indicator (opacity dim) when track list is
refreshing from a tag filter change.

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

* fix: use MagicMock for httpx response in atprotofans tests

httpx Response.json() is synchronous — AsyncMock wraps it as a
coroutine which causes the call to silently fail.

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

---------

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

authored by

nate nowack
Claude Opus 4.6
and committed by
GitHub
532b516b 7110a028

+230 -20
+51
backend/src/backend/_internal/atprotofans.py
··· 15 15 """ 16 16 17 17 import asyncio 18 + import logging 18 19 19 20 import httpx 20 21 import logfire 21 22 from pydantic import BaseModel 23 + from redis.exceptions import RedisError 24 + 25 + from backend.utilities.redis import get_async_redis_client 26 + 27 + logger = logging.getLogger(__name__) 28 + 29 + SUPPORTER_CACHE_TTL = 300 # 5 minutes 22 30 23 31 24 32 class SupporterValidation(BaseModel): ··· 28 36 profile: dict | None = None 29 37 30 38 39 + def _cache_key(supporter_did: str, artist_did: str) -> str: 40 + return f"supporter:{supporter_did}:{artist_did}" 41 + 42 + 43 + async def _get_cached(supporter_did: str, artist_did: str) -> bool | None: 44 + """check Redis for cached supporter validation. returns True/False or None on miss.""" 45 + try: 46 + redis = get_async_redis_client() 47 + val = await redis.get(_cache_key(supporter_did, artist_did)) 48 + if val is not None: 49 + return val == "1" 50 + except (RuntimeError, RedisError): 51 + pass 52 + return None 53 + 54 + 55 + async def _set_cached(supporter_did: str, artist_did: str, valid: bool) -> None: 56 + """cache supporter validation result in Redis.""" 57 + try: 58 + redis = get_async_redis_client() 59 + await redis.set( 60 + _cache_key(supporter_did, artist_did), 61 + "1" if valid else "0", 62 + ex=SUPPORTER_CACHE_TTL, 63 + ) 64 + except (RuntimeError, RedisError): 65 + logger.debug("failed to cache supporter validation") 66 + 67 + 31 68 async def validate_supporter( 32 69 supporter_did: str, 33 70 artist_did: str, ··· 36 73 """validate if a user supports an artist via atprotofans. 37 74 38 75 for direct atprotofans contributions, the signer is the artist's DID. 76 + results are cached in Redis for 5 minutes. 39 77 40 78 args: 41 79 supporter_did: DID of the potential supporter ··· 45 83 returns: 46 84 SupporterValidation with valid=True if supporter, valid=False otherwise 47 85 """ 86 + # check cache first 87 + cached = await _get_cached(supporter_did, artist_did) 88 + if cached is not None: 89 + logfire.info( 90 + "atprotofans cache hit", 91 + valid=cached, 92 + supporter_did=supporter_did, 93 + artist_did=artist_did, 94 + ) 95 + return SupporterValidation(valid=cached) 96 + 48 97 url = "https://atprotofans.com/xrpc/com.atprotofans.validateSupporter" 49 98 params = { 50 99 "supporter": supporter_did, ··· 67 116 status_code=response.status_code, 68 117 response_text=response.text[:200], 69 118 ) 119 + await _set_cached(supporter_did, artist_did, False) 70 120 return SupporterValidation(valid=False) 71 121 72 122 data = response.json() ··· 78 128 has_profile=data.get("profile") is not None, 79 129 ) 80 130 131 + await _set_cached(supporter_did, artist_did, is_valid) 81 132 return SupporterValidation( 82 133 valid=is_valid, 83 134 profile=data.get("profile"),
+17 -14
backend/src/backend/api/tracks/listing.py
··· 184 184 # generate next cursor from the last track's created_at 185 185 next_cursor = tracks[-1].created_at.isoformat() if has_more and tracks else None 186 186 187 + # kick off supporter validation early — it makes external HTTP calls that 188 + # benefit from running concurrently with DB aggregations and PDS resolution 189 + viewer_did = session.did if session else None 190 + supporter_task: asyncio.Task[set[str]] | None = None 191 + if viewer_did: 192 + gated_artist_dids = { 193 + t.artist_did 194 + for t in tracks 195 + if t.support_gate and t.artist_did != viewer_did 196 + } 197 + if gated_artist_dids: 198 + supporter_task = asyncio.create_task( 199 + get_supported_artists(viewer_did, gated_artist_dids) 200 + ) 201 + 187 202 # batch fetch like counts, comment counts, and tags for all tracks 188 203 # note: copyright_info is intentionally excluded here - it requires an HTTP call 189 204 # to the moderation service and is only displayed in /tracks/me (artist portal) ··· 271 286 await asyncio.gather(*[resolve_image(t) for t in tracks_needing_images]) 272 287 await db.commit() 273 288 274 - # resolve supporter status for gated content 275 - viewer_did = session.did if session else None 276 - supported_artist_dids: set[str] = set() 277 - if viewer_did: 278 - # collect artist DIDs with gated tracks (excluding viewer's own tracks) 279 - gated_artist_dids = { 280 - t.artist_did 281 - for t in tracks 282 - if t.support_gate and t.artist_did != viewer_did 283 - } 284 - if gated_artist_dids: 285 - supported_artist_dids = await get_supported_artists( 286 - viewer_did, gated_artist_dids 287 - ) 289 + # await supporter validation (started earlier, ran concurrently with above work) 290 + supported_artist_dids = await supporter_task if supporter_task else set() 288 291 289 292 # fetch all track responses concurrently with like status and counts 290 293 with logfire.span("build track responses", track_count=len(tracks)):
+123
backend/tests/_internal/test_atprotofans.py
··· 1 + """tests for atprotofans supporter validation caching.""" 2 + 3 + from unittest.mock import AsyncMock, MagicMock, patch 4 + 5 + import pytest 6 + 7 + from backend._internal.atprotofans import ( 8 + SUPPORTER_CACHE_TTL, 9 + _cache_key, 10 + get_supported_artists, 11 + validate_supporter, 12 + ) 13 + from backend.utilities.redis import get_async_redis_client 14 + 15 + SUPPORTER_DID = "did:plc:supporter123" 16 + ARTIST_DID = "did:plc:artist456" 17 + 18 + 19 + @pytest.fixture(autouse=True) 20 + async def _clear_supporter_cache() -> None: 21 + """clear supporter cache keys before each test.""" 22 + try: 23 + redis = get_async_redis_client() 24 + await redis.delete(_cache_key(SUPPORTER_DID, ARTIST_DID)) 25 + except RuntimeError: 26 + pass 27 + 28 + 29 + async def test_validate_supporter_caches_result() -> None: 30 + """first call hits external API, second call hits Redis cache.""" 31 + mock_response = MagicMock() 32 + mock_response.status_code = 200 33 + mock_response.json.return_value = {"valid": True, "profile": None} 34 + 35 + mock_client = AsyncMock() 36 + mock_client.__aenter__ = AsyncMock(return_value=mock_client) 37 + mock_client.__aexit__ = AsyncMock(return_value=False) 38 + mock_client.get = AsyncMock(return_value=mock_response) 39 + 40 + with patch( 41 + "backend._internal.atprotofans.httpx.AsyncClient", return_value=mock_client 42 + ): 43 + # first call — should hit external API 44 + result1 = await validate_supporter(SUPPORTER_DID, ARTIST_DID) 45 + assert result1.valid is True 46 + assert mock_client.get.call_count == 1 47 + 48 + # second call — should hit cache, not external API 49 + result2 = await validate_supporter(SUPPORTER_DID, ARTIST_DID) 50 + assert result2.valid is True 51 + assert mock_client.get.call_count == 1 # no additional call 52 + 53 + # verify value is in Redis 54 + redis = get_async_redis_client() 55 + cached = await redis.get(_cache_key(SUPPORTER_DID, ARTIST_DID)) 56 + assert cached == "1" 57 + 58 + 59 + async def test_validate_supporter_caches_negative() -> None: 60 + """non-supporter results are also cached to avoid repeated lookups.""" 61 + mock_response = MagicMock() 62 + mock_response.status_code = 200 63 + mock_response.json.return_value = {"valid": False} 64 + 65 + mock_client = AsyncMock() 66 + mock_client.__aenter__ = AsyncMock(return_value=mock_client) 67 + mock_client.__aexit__ = AsyncMock(return_value=False) 68 + mock_client.get = AsyncMock(return_value=mock_response) 69 + 70 + with patch( 71 + "backend._internal.atprotofans.httpx.AsyncClient", return_value=mock_client 72 + ): 73 + result1 = await validate_supporter(SUPPORTER_DID, ARTIST_DID) 74 + assert result1.valid is False 75 + 76 + result2 = await validate_supporter(SUPPORTER_DID, ARTIST_DID) 77 + assert result2.valid is False 78 + assert mock_client.get.call_count == 1 79 + 80 + redis = get_async_redis_client() 81 + cached = await redis.get(_cache_key(SUPPORTER_DID, ARTIST_DID)) 82 + assert cached == "0" 83 + 84 + 85 + async def test_cache_ttl_is_set() -> None: 86 + """cached value should have a TTL.""" 87 + mock_response = MagicMock() 88 + mock_response.status_code = 200 89 + mock_response.json.return_value = {"valid": True, "profile": None} 90 + 91 + mock_client = AsyncMock() 92 + mock_client.__aenter__ = AsyncMock(return_value=mock_client) 93 + mock_client.__aexit__ = AsyncMock(return_value=False) 94 + mock_client.get = AsyncMock(return_value=mock_response) 95 + 96 + with patch( 97 + "backend._internal.atprotofans.httpx.AsyncClient", return_value=mock_client 98 + ): 99 + await validate_supporter(SUPPORTER_DID, ARTIST_DID) 100 + 101 + redis = get_async_redis_client() 102 + ttl = await redis.ttl(_cache_key(SUPPORTER_DID, ARTIST_DID)) 103 + assert 0 < ttl <= SUPPORTER_CACHE_TTL 104 + 105 + 106 + async def test_get_supported_artists_uses_cache() -> None: 107 + """batch check should benefit from per-pair caching.""" 108 + # pre-populate cache 109 + redis = get_async_redis_client() 110 + await redis.set(_cache_key(SUPPORTER_DID, ARTIST_DID), "1", ex=300) 111 + 112 + mock_client = AsyncMock() 113 + mock_client.__aenter__ = AsyncMock(return_value=mock_client) 114 + mock_client.__aexit__ = AsyncMock(return_value=False) 115 + 116 + with patch( 117 + "backend._internal.atprotofans.httpx.AsyncClient", return_value=mock_client 118 + ): 119 + result = await get_supported_artists(SUPPORTER_DID, {ARTIST_DID}) 120 + 121 + assert ARTIST_DID in result 122 + # should not have made any HTTP calls — all from cache 123 + mock_client.get.assert_not_called()
+12 -2
frontend/src/lib/components/TagFilter.svelte
··· 2 2 import { onMount } from 'svelte'; 3 3 import { SvelteSet } from 'svelte/reactivity'; 4 4 import { API_URL } from '$lib/config'; 5 + import { tracksCache } from '$lib/tracks.svelte'; 5 6 6 7 interface Tag { 7 8 name: string; ··· 16 17 let { onTagsChange, hiddenTags = [] }: Props = $props(); 17 18 18 19 let tags = $state<Tag[]>([]); 19 - let selectedTags = new SvelteSet<string>(); 20 + let selectedTags = new SvelteSet<string>(tracksCache.activeTags); 20 21 let loaded = $state(false); 21 22 22 - let visibleTags = $derived(tags.filter((t) => !hiddenTags.includes(t.name))); 23 + // selected tags sort to the front, then by track count 24 + let visibleTags = $derived( 25 + tags 26 + .filter((t) => !hiddenTags.includes(t.name)) 27 + .toSorted((a, b) => { 28 + const aSelected = selectedTags.has(a.name) ? 1 : 0; 29 + const bSelected = selectedTags.has(b.name) ? 1 : 0; 30 + return bSelected - aSelected || b.track_count - a.track_count; 31 + }) 32 + ); 23 33 24 34 /** deterministic hue from tag name (0–360) */ 25 35 function tagHue(name: string): number {
+18 -2
frontend/src/lib/tracks.svelte.ts
··· 42 42 return { tracks: [], nextCursor: null, hasMore: true }; 43 43 } 44 44 45 + function loadSavedTags(): string[] { 46 + if (typeof window === 'undefined') return []; 47 + try { 48 + const saved = localStorage.getItem('active_tags'); 49 + return saved ? JSON.parse(saved) : []; 50 + } catch { 51 + return []; 52 + } 53 + } 54 + 45 55 // global tracks cache using Svelte 5 runes 46 56 class TracksCache { 47 57 tracks = $state<Track[]>(loadCachedTracks().tracks); ··· 49 59 loadingMore = $state(false); 50 60 nextCursor = $state<string | null>(loadCachedTracks().nextCursor); 51 61 hasMore = $state(loadCachedTracks().hasMore); 52 - activeTags = $state<string[]>([]); 62 + activeTags = $state<string[]>(loadSavedTags()); 53 63 54 64 private persistToStorage(): void { 55 65 if (typeof window !== 'undefined' && this.activeTags.length === 0) { ··· 141 151 142 152 setTags(tags: string[]): void { 143 153 this.activeTags = tags; 144 - this.tracks = []; 145 154 this.nextCursor = null; 146 155 this.hasMore = true; 156 + if (typeof window !== 'undefined') { 157 + if (tags.length > 0) { 158 + localStorage.setItem('active_tags', JSON.stringify(tags)); 159 + } else { 160 + localStorage.removeItem('active_tags'); 161 + } 162 + } 147 163 this.fetch(true); 148 164 } 149 165 }
+8 -1
frontend/src/routes/+page.svelte
··· 241 241 {:else if !hasTracks} 242 242 <p class="empty">no tracks yet</p> 243 243 {:else} 244 - <div class="track-list"> 244 + <div class="track-list" class:refreshing={loadingTracks && hasTracks}> 245 245 {#each tracks as track, i} 246 246 <TrackItem 247 247 {track} ··· 435 435 display: flex; 436 436 flex-direction: column; 437 437 gap: 0.5rem; 438 + transition: opacity 0.15s; 439 + } 440 + 441 + .track-list.refreshing { 442 + opacity: 0.5; 443 + transition: opacity 0.15s; 444 + pointer-events: none; 438 445 } 439 446 440 447 .scroll-sentinel {
+1 -1
loq.toml
··· 39 39 40 40 [[rules]] 41 41 path = "backend/src/backend/api/tracks/listing.py" 42 - max_lines = 562 42 + max_lines = 563 43 43 44 44 [[rules]] 45 45 path = "backend/src/backend/api/tracks/mutations.py"