feat: sync avatar on login and add to ATProto profile record (#685)

* feat: sync avatar on login and add to ATProto profile record

- add optional avatar field to fm.plyr.actor.profile lexicon
- update profile record builder to accept and include avatar
- refresh avatar from bluesky on login sync
- update postgres if avatar changed
- sync avatar to ATProto profile record

fixes stale avatars in likers tooltip and other displays

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

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

* add one-time avatar backfill script

refreshes avatar_url for all artists from bluesky. run with:
cd backend && uv run python ../scripts/backfill_avatars.py --dry-run

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

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

---------

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

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub 9bebe901 56811776

Changed files
+199 -12
backend
src
backend
_internal
atproto
records
fm_plyr
lexicons
scripts
+17 -3
backend/src/backend/_internal/atproto/records/fm_plyr/profile.py
··· 11 11 12 12 def build_profile_record( 13 13 bio: str | None = None, 14 + avatar: str | None = None, 14 15 created_at: datetime | None = None, 15 16 updated_at: datetime | None = None, 16 17 ) -> dict[str, Any]: ··· 18 19 19 20 args: 20 21 bio: artist bio/description 22 + avatar: URL to avatar image 21 23 created_at: creation timestamp (defaults to now) 22 24 updated_at: optional last modification timestamp 23 25 ··· 31 33 .replace("+00:00", "Z"), 32 34 } 33 35 36 + if avatar: 37 + record["avatar"] = avatar 34 38 if bio: 35 39 record["bio"] = bio 36 40 if updated_at: ··· 42 46 async def upsert_profile_record( 43 47 auth_session: AuthSession, 44 48 bio: str | None = None, 49 + avatar: str | None = None, 45 50 ) -> tuple[str, str] | None: 46 51 """Create or update the user's plyr.fm profile record. 47 52 48 53 uses putRecord with rkey="self" for upsert semantics - creates if 49 54 doesn't exist, updates if it does. skips write if record already 50 - exists with the same bio (no-op for unchanged data). 55 + exists with the same bio and avatar (no-op for unchanged data). 51 56 52 57 args: 53 58 auth_session: authenticated user session 54 59 bio: artist bio/description 60 + avatar: URL to avatar image 55 61 56 62 returns: 57 63 tuple of (record_uri, record_cid) or None if skipped (unchanged) ··· 59 65 # check if profile already exists to preserve createdAt and skip if unchanged 60 66 existing_created_at = None 61 67 existing_bio = None 68 + existing_avatar = None 62 69 existing_uri = None 63 70 existing_cid = None 64 71 ··· 85 92 existing_cid = existing.get("cid") 86 93 if "value" in existing: 87 94 existing_bio = existing["value"].get("bio") 95 + existing_avatar = existing["value"].get("avatar") 88 96 if "createdAt" in existing["value"]: 89 97 existing_created_at = datetime.fromisoformat( 90 98 existing["value"]["createdAt"].replace("Z", "+00:00") ··· 93 101 # record doesn't exist yet, that's fine 94 102 pass 95 103 96 - # skip write if record exists with same bio (no changes needed) 97 - if existing_uri and existing_cid and existing_bio == bio: 104 + # skip write if record exists with same bio and avatar (no changes needed) 105 + if ( 106 + existing_uri 107 + and existing_cid 108 + and existing_bio == bio 109 + and existing_avatar == avatar 110 + ): 98 111 return None 99 112 100 113 record = build_profile_record( 101 114 bio=bio, 115 + avatar=avatar, 102 116 created_at=existing_created_at, 103 117 updated_at=datetime.now(UTC) if existing_created_at else None, 104 118 )
+31 -9
backend/src/backend/_internal/atproto/sync.py
··· 5 5 from sqlalchemy import select 6 6 7 7 from backend._internal import Session as AuthSession 8 + from backend._internal.atproto.profile import fetch_user_avatar 8 9 from backend._internal.atproto.records.fm_plyr import ( 9 10 get_record_public, 10 11 upsert_album_list_record, ··· 55 56 """ 56 57 from backend.models import Album, Artist, Track, TrackLike, UserPreferences 57 58 58 - # sync profile record 59 + # sync profile record with avatar refresh 59 60 async with db_session() as session: 60 61 artist_result = await session.execute( 61 62 select(Artist).where(Artist.did == user_did) 62 63 ) 63 64 artist = artist_result.scalar_one_or_none() 64 - artist_bio = artist.bio if artist else None 65 + 66 + if not artist: 67 + logger.debug( 68 + f"no artist profile found for {user_did}, skipping profile sync" 69 + ) 70 + else: 71 + artist_bio = artist.bio 72 + artist_avatar = artist.avatar_url 73 + 74 + # fetch current avatar from bluesky 75 + fresh_avatar = await fetch_user_avatar(user_did) 76 + 77 + # update postgres if avatar changed 78 + if fresh_avatar != artist_avatar: 79 + artist.avatar_url = fresh_avatar 80 + await session.commit() 81 + logger.info(f"refreshed avatar for {user_did}") 82 + artist_avatar = fresh_avatar 65 83 66 - if artist_bio is not None or artist: 67 - try: 68 - profile_result = await upsert_profile_record(auth_session, bio=artist_bio) 69 - if profile_result: 70 - logger.info(f"synced ATProto profile record for {user_did}") 71 - except Exception as e: 72 - logger.warning(f"failed to sync ATProto profile record for {user_did}: {e}") 84 + # sync to ATProto profile record 85 + try: 86 + profile_result = await upsert_profile_record( 87 + auth_session, bio=artist_bio, avatar=artist_avatar 88 + ) 89 + if profile_result: 90 + logger.info(f"synced ATProto profile record for {user_did}") 91 + except Exception as e: 92 + logger.warning( 93 + f"failed to sync ATProto profile record for {user_did}: {e}" 94 + ) 73 95 74 96 # query and sync album list records 75 97 async with db_session() as session:
+5
lexicons/profile.json
··· 10 10 "type": "object", 11 11 "required": ["createdAt"], 12 12 "properties": { 13 + "avatar": { 14 + "type": "string", 15 + "format": "uri", 16 + "description": "URL to avatar image." 17 + }, 13 18 "bio": { 14 19 "type": "string", 15 20 "description": "Artist bio or description.",
+146
scripts/backfill_avatars.py
··· 1 + #!/usr/bin/env -S uv run --script --quiet 2 + """backfill avatar_url for all artists from bluesky. 3 + 4 + ## Context 5 + 6 + Avatar URLs were only set at artist creation and never refreshed. This caused 7 + stale/broken avatars throughout the app (likers tooltip, profiles, etc). 8 + 9 + PR #685 added avatar sync on login, but users who don't log in will still have 10 + stale avatars. This script does a one-time refresh of all avatars. 11 + 12 + ## What This Script Does 13 + 14 + 1. Fetches all artists from the database 15 + 2. For each artist, fetches current avatar from Bluesky public API 16 + 3. Updates avatar_url in database if changed 17 + 4. Reports summary of changes 18 + 19 + ## Usage 20 + 21 + ```bash 22 + # dry run (show what would be updated) 23 + uv run scripts/backfill_avatars.py --dry-run 24 + 25 + # actually update the database 26 + uv run scripts/backfill_avatars.py 27 + 28 + # target specific environment 29 + DATABASE_URL=postgresql://... uv run scripts/backfill_avatars.py 30 + ``` 31 + """ 32 + 33 + import asyncio 34 + import logging 35 + import sys 36 + 37 + # scripts are run from backend/ directory via: uv run python ../scripts/backfill_avatars.py 38 + 39 + from sqlalchemy import select 40 + 41 + from backend._internal.atproto.profile import fetch_user_avatar 42 + from backend.models import Artist 43 + from backend.utilities.database import db_session 44 + 45 + logging.basicConfig( 46 + level=logging.INFO, 47 + format="%(asctime)s - %(levelname)s - %(message)s", 48 + ) 49 + logger = logging.getLogger(__name__) 50 + 51 + # rate limit to avoid hammering bluesky API 52 + CONCURRENCY_LIMIT = 5 53 + DELAY_BETWEEN_BATCHES = 0.5 # seconds 54 + 55 + 56 + async def backfill_avatars(dry_run: bool = False) -> None: 57 + """backfill avatar_url for all artists from bluesky.""" 58 + 59 + async with db_session() as db: 60 + result = await db.execute(select(Artist)) 61 + artists = result.scalars().all() 62 + 63 + if not artists: 64 + logger.info("no artists found") 65 + return 66 + 67 + logger.info(f"found {len(artists)} artists to check") 68 + 69 + if dry_run: 70 + logger.info("dry run mode - checking avatars without updating:") 71 + 72 + updated = 0 73 + unchanged = 0 74 + failed = 0 75 + cleared = 0 76 + 77 + # process in batches to rate limit 78 + semaphore = asyncio.Semaphore(CONCURRENCY_LIMIT) 79 + 80 + async def process_artist( 81 + artist: Artist, 82 + ) -> tuple[str, str | None, str | None, Exception | None]: 83 + """fetch avatar for artist, return (did, old_url, new_url, error).""" 84 + async with semaphore: 85 + try: 86 + fresh_avatar = await fetch_user_avatar(artist.did) 87 + return (artist.did, artist.avatar_url, fresh_avatar, None) 88 + except Exception as e: 89 + return (artist.did, artist.avatar_url, None, e) 90 + 91 + # fetch all avatars concurrently (with semaphore limiting) 92 + logger.info("fetching avatars from bluesky...") 93 + results = await asyncio.gather(*[process_artist(a) for a in artists]) 94 + 95 + # process results 96 + for did, old_url, new_url, error in results: 97 + artist = next(a for a in artists if a.did == did) 98 + 99 + if error: 100 + failed += 1 101 + logger.warning(f"failed to fetch avatar for {artist.handle}: {error}") 102 + continue 103 + 104 + if old_url == new_url: 105 + unchanged += 1 106 + continue 107 + 108 + if new_url is None and old_url is not None: 109 + cleared += 1 110 + action = "would clear" if dry_run else "clearing" 111 + logger.info( 112 + f"{action} avatar for {artist.handle} (was: {old_url[:50]}...)" 113 + ) 114 + elif new_url is not None and old_url is None: 115 + updated += 1 116 + action = "would set" if dry_run else "setting" 117 + logger.info(f"{action} avatar for {artist.handle}") 118 + else: 119 + updated += 1 120 + action = "would update" if dry_run else "updating" 121 + logger.info(f"{action} avatar for {artist.handle}") 122 + 123 + if not dry_run: 124 + artist.avatar_url = new_url 125 + 126 + if not dry_run: 127 + await db.commit() 128 + 129 + logger.info( 130 + f"backfill complete: {updated} updated, {cleared} cleared, " 131 + f"{unchanged} unchanged, {failed} failed" 132 + ) 133 + 134 + 135 + async def main() -> None: 136 + """main entry point.""" 137 + dry_run = "--dry-run" in sys.argv 138 + 139 + if dry_run: 140 + logger.info("running in DRY RUN mode - no changes will be made") 141 + 142 + await backfill_avatars(dry_run=dry_run) 143 + 144 + 145 + if __name__ == "__main__": 146 + asyncio.run(main())