audio streaming app plyr.fm
38
fork

Configure Feed

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

fix: normalize ATProto avatar URLs to use CDN (#317)

* fix: normalize ATProto avatar URLs to use CDN

Updates the artist profile fetching and storage logic to automatically normalize PDS blob URLs to Bluesky CDN URLs. This prevents issues with self-hosted PDS instances that may have SSL or availability problems (e.g., mbdio.uk).

* fix(frontend): use silhouette fallback for missing/broken avatars

Updates TrackItem and TrackInfo components to use a person silhouette SVG (consistent with artist metadata) as the fallback for missing or broken track artwork/avatars. Adds onerror handlers to gracefully degrade from broken images.

authored by

nate nowack and committed by
GitHub
cf1a0018 dd78a2bf

+80 -17
+12 -6
frontend/src/lib/components/TrackItem.svelte
··· 33 33 34 34 let showLikersTooltip = $state(false); 35 35 let likeCount = $state(track.like_count || 0); 36 + let trackImageError = $state(false); 37 + let avatarError = $state(false); 36 38 37 39 // sync likeCount when track changes 38 40 $effect(() => { 39 41 likeCount = track.like_count || 0; 42 + // reset error states when track changes (e.g. recycled component) 43 + trackImageError = false; 44 + avatarError = false; 40 45 }); 41 46 42 47 // construct shareable URL - use /track/[id] for link previews ··· 91 96 onPlay(track); 92 97 }} 93 98 > 94 - {#if track.image_url} 99 + {#if track.image_url && !trackImageError} 95 100 <div class="track-image"> 96 101 <img 97 102 src={track.image_url} ··· 100 105 height="48" 101 106 loading={imageLoading} 102 107 fetchpriority={imageFetchPriority} 108 + onerror={() => trackImageError = true} 103 109 /> 104 110 </div> 105 - {:else if track.artist_avatar_url} 111 + {:else if track.artist_avatar_url && !avatarError} 106 112 <a 107 113 href="/u/{track.artist_handle}" 108 114 class="track-avatar" ··· 114 120 height="48" 115 121 loading={imageLoading} 116 122 fetchpriority={imageFetchPriority} 123 + onerror={() => avatarError = true} 117 124 /> 118 125 </a> 119 126 {:else} 120 127 <div class="track-image-placeholder"> 121 - <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 122 - <path d="M9 18V5l12-2v13"></path> 123 - <circle cx="6" cy="18" r="3"></circle> 124 - <circle cx="18" cy="16" r="3"></circle> 128 + <svg width="24" height="24" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg"> 129 + <circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.5" fill="none" /> 130 + <path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /> 125 131 </svg> 126 132 </div> 127 133 {/if}
+10 -6
frontend/src/lib/components/player/TrackInfo.svelte
··· 15 15 let titleOverflows = $state(false); 16 16 let artistOverflows = $state(false); 17 17 let albumOverflows = $state(false); 18 + let imageError = $state(false); 18 19 19 20 function checkOverflows() { 20 21 if (typeof window === 'undefined') return; ··· 54 55 55 56 <div class="player-track"> 56 57 <a href="/track/{track.id}" class="player-artwork" aria-label={`view ${track.title}`}> 57 - {#if track.image_url} 58 - <img src={track.image_url} alt="{track.title} artwork" /> 58 + {#if track.image_url && !imageError} 59 + <img 60 + src={track.image_url} 61 + alt="{track.title} artwork" 62 + onerror={() => imageError = true} 63 + /> 59 64 {:else} 60 65 <div class="player-artwork-placeholder"> 61 - <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 62 - <path d="M9 18V5l12-2v13"></path> 63 - <circle cx="6" cy="18" r="3"></circle> 64 - <circle cx="18" cy="16" r="3"></circle> 66 + <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="24" height="24"> 67 + <circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.5" fill="none" /> 68 + <path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /> 65 69 </svg> 66 70 </div> 67 71 {/if}
+6 -1
src/backend/_internal/atproto/__init__.py
··· 1 1 """ATProto integration for relay.""" 2 2 3 - from backend._internal.atproto.profile import fetch_user_avatar, fetch_user_profile 3 + from backend._internal.atproto.profile import ( 4 + fetch_user_avatar, 5 + fetch_user_profile, 6 + normalize_avatar_url, 7 + ) 4 8 from backend._internal.atproto.records import ( 5 9 create_like_record, 6 10 create_track_record, ··· 13 17 "delete_record_by_uri", 14 18 "fetch_user_avatar", 15 19 "fetch_user_profile", 20 + "normalize_avatar_url", 16 21 ]
+41 -1
src/backend/_internal/atproto/profile.py
··· 1 1 """Bluesky profile discovery utilities.""" 2 2 3 3 import logging 4 + from urllib.parse import parse_qs, urlparse 4 5 5 6 import httpx 6 7 ··· 9 10 BSKY_API_BASE = "https://public.api.bsky.app/xrpc" 10 11 11 12 13 + def normalize_avatar_url(url: str | None) -> str | None: 14 + """normalize avatar URL to use Bluesky CDN if possible. 15 + 16 + converts raw PDS blob URLs to CDN URLs to avoid SSL/availability issues 17 + with self-hosted PDS instances. 18 + 19 + args: 20 + url: original avatar URL 21 + 22 + returns: 23 + normalized URL or original URL 24 + """ 25 + if not url: 26 + return None 27 + 28 + # if it's already a CDN URL, return it 29 + if "cdn.bsky.app" in url: 30 + return url 31 + 32 + # check if it's a raw PDS blob URL 33 + # format: https://{pds}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid} 34 + if "com.atproto.sync.getBlob" in url: 35 + try: 36 + parsed = urlparse(url) 37 + query = parse_qs(parsed.query) 38 + did = query.get("did", [None])[0] 39 + cid = query.get("cid", [None])[0] 40 + 41 + if did and cid: 42 + # default to jpeg as it's most common/safe for avatars 43 + # @jpeg suffix hints CDN to serve/convert as jpeg 44 + return f"https://cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg" 45 + except Exception: 46 + # if parsing fails, return original URL 47 + pass 48 + 49 + return url 50 + 51 + 12 52 async def fetch_user_avatar(did: str) -> str | None: 13 53 """fetch user avatar URL from Bluesky public API. 14 54 ··· 30 70 31 71 if avatar: 32 72 logger.info(f"discovered avatar for {did}: {avatar}") 33 - return avatar 73 + return normalize_avatar_url(avatar) 34 74 else: 35 75 logger.info(f"no avatar found for {did}") 36 76 return None
+11 -3
src/backend/api/artists.py
··· 5 5 from typing import Annotated 6 6 7 7 from fastapi import APIRouter, Depends, HTTPException 8 - from pydantic import BaseModel, ConfigDict 8 + from pydantic import BaseModel, ConfigDict, field_validator 9 9 from sqlalchemy import func, select 10 10 from sqlalchemy.ext.asyncio import AsyncSession 11 11 12 12 from backend._internal import Session, require_auth 13 - from backend._internal.atproto import fetch_user_avatar 13 + from backend._internal.atproto import fetch_user_avatar, normalize_avatar_url 14 14 from backend.models import Artist, Track, TrackLike, get_db 15 15 16 16 logger = logging.getLogger(__name__) ··· 48 48 created_at: datetime 49 49 updated_at: datetime 50 50 51 + @field_validator("avatar_url", mode="before") 52 + @classmethod 53 + def normalize_avatar(cls, v: str | None) -> str | None: 54 + """normalize avatar URL to use Bluesky CDN.""" 55 + return normalize_avatar_url(v) 56 + 51 57 52 58 class TopItemResponse(BaseModel): 53 59 """top item in analytics.""" ··· 87 93 avatar_url = request.avatar_url 88 94 if not avatar_url: 89 95 avatar_url = await fetch_user_avatar(auth_session.did) 96 + else: 97 + avatar_url = normalize_avatar_url(avatar_url) 90 98 91 99 # resolve and cache PDS URL for performance 92 100 from atproto_identity.did.resolver import AsyncDidResolver ··· 155 163 if request.bio is not None: 156 164 artist.bio = request.bio 157 165 if request.avatar_url is not None: 158 - artist.avatar_url = request.avatar_url 166 + artist.avatar_url = normalize_avatar_url(request.avatar_url) 159 167 160 168 artist.updated_at = datetime.now(UTC) 161 169 await db.commit()