my website at ewancroft.uk
6
fork

Configure Feed

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

feat: extend ATProto fetch layer with music status, artwork resolution, and updated types

+227 -50
+194 -47
src/lib/services/atproto/fetch.ts
··· 1 1 import { PUBLIC_ATPROTO_DID } from '$env/static/public'; 2 2 import { cache } from './cache'; 3 - import { withFallback } from './agents'; 4 - import type { ProfileData, StatusData, SiteInfoData, LinkData } from './types'; 3 + import { withFallback, resolveIdentity } from './agents'; 4 + import type { ProfileData, StatusData, SiteInfoData, LinkData, MusicStatusData } from './types'; 5 + import { buildPdsBlobUrl } from './media'; 6 + import { searchMusicBrainzRelease, buildCoverArtUrl } from './musicbrainz'; 5 7 6 8 /** 7 9 * Fetches user profile from AT Protocol ··· 52 54 } 53 55 54 56 /** 55 - * Fetches user status from custom lexicon 56 - */ 57 - export async function fetchStatus(fetchFn?: typeof fetch): Promise<StatusData | null> { 58 - console.info('[Status] Fetching status data'); 59 - const cacheKey = `status:${PUBLIC_ATPROTO_DID}`; 60 - const cached = cache.get<StatusData>(cacheKey); 61 - if (cached) { 62 - console.debug('[Status] Returning cached status data'); 63 - return cached; 64 - } 65 - 66 - try { 67 - console.info('[Status] Cache miss, fetching from network'); 68 - // Custom collection, prefer PDS first 69 - const records = await withFallback( 70 - PUBLIC_ATPROTO_DID, 71 - async (agent) => { 72 - const response = await agent.com.atproto.repo.listRecords({ 73 - repo: PUBLIC_ATPROTO_DID, 74 - collection: 'uk.ewancroft.now', 75 - limit: 1 76 - }); 77 - return response.data.records; 78 - }, 79 - true, 80 - fetchFn 81 - ); // usePDSFirst = true 82 - 83 - if (records.length === 0) return null; 84 - 85 - const record = records[0]; 86 - const data: StatusData = { 87 - text: (record.value as any).text, 88 - createdAt: (record.value as any).createdAt 89 - }; 90 - 91 - cache.set(cacheKey, data); 92 - return data; 93 - } catch (error) { 94 - console.error('Failed to fetch status from all sources:', error); 95 - return null; 96 - } 97 - } 98 - 99 - /** 100 57 * Fetches site information from custom lexicon 101 58 */ 102 59 export async function fetchSiteInfo(fetchFn?: typeof fetch): Promise<SiteInfoData | null> { ··· 181 138 return null; 182 139 } 183 140 } 141 + 142 + /** 143 + * Fetches music listening status from custom lexicons 144 + * Checks both fm.teal.alpha.actor.status and fm.teal.alpha.feed.play collections 145 + */ 146 + export async function fetchMusicStatus(fetchFn?: typeof fetch): Promise<MusicStatusData | null> { 147 + console.info('[MusicStatus] Fetching music status data'); 148 + const cacheKey = `music-status:${PUBLIC_ATPROTO_DID}`; 149 + const cached = cache.get<MusicStatusData>(cacheKey); 150 + if (cached) { 151 + console.debug('[MusicStatus] Returning cached music status data'); 152 + return cached; 153 + } 154 + 155 + try { 156 + console.info('[MusicStatus] Cache miss, fetching from network'); 157 + 158 + // Try the actor status collection first (shorter-lived status) 159 + try { 160 + const statusRecords = await withFallback( 161 + PUBLIC_ATPROTO_DID, 162 + async (agent) => { 163 + const response = await agent.com.atproto.repo.listRecords({ 164 + repo: PUBLIC_ATPROTO_DID, 165 + collection: 'fm.teal.alpha.actor.status', 166 + limit: 1 167 + }); 168 + return response.data.records; 169 + }, 170 + true, 171 + fetchFn 172 + ); 173 + 174 + if (statusRecords && statusRecords.length > 0) { 175 + const record = statusRecords[0]; 176 + const value = record.value as any; 177 + 178 + // Check if status is still valid (not expired) 179 + if (value.expiry) { 180 + const expiryTime = parseInt(value.expiry) * 1000; 181 + if (Date.now() > expiryTime) { 182 + console.debug('[MusicStatus] Actor status expired, falling back to feed play'); 183 + } else { 184 + // Build artwork URL - prefer MusicBrainz, fallback to atproto blob 185 + let artworkUrl: string | undefined; 186 + let releaseMbId = value.item?.releaseMbId || value.releaseMbId; 187 + 188 + console.debug('[MusicStatus] Looking for artwork, releaseMbId:', releaseMbId); 189 + 190 + // If no releaseMbId, try to search MusicBrainz 191 + if (!releaseMbId) { 192 + const trackName = value.item?.trackName || value.trackName; 193 + const artists = value.item?.artists || value.artists || []; 194 + const releaseName = value.item?.releaseName || value.releaseName; 195 + const artistName = artists[0]?.artistName; 196 + 197 + if (trackName && artistName) { 198 + console.debug('[MusicStatus] Searching MusicBrainz for missing release ID'); 199 + releaseMbId = await searchMusicBrainzRelease(trackName, artistName, releaseName); 200 + if (releaseMbId) { 201 + console.info('[MusicStatus] Found release via MusicBrainz search:', releaseMbId); 202 + } 203 + } 204 + } 205 + 206 + if (releaseMbId) { 207 + // Use MusicBrainz Cover Art Archive (no API key required) 208 + artworkUrl = buildCoverArtUrl(releaseMbId); 209 + console.info('[MusicStatus] Using MusicBrainz artwork URL:', artworkUrl); 210 + } else { 211 + // Fallback to atproto blob if available 212 + const artwork = value.item?.artwork || value.artwork; 213 + console.debug('[MusicStatus] Artwork field:', artwork); 214 + if (artwork?.ref?.$link) { 215 + const identity = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); 216 + artworkUrl = buildPdsBlobUrl(identity.pds, PUBLIC_ATPROTO_DID, artwork.ref.$link); 217 + console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl); 218 + } 219 + } 220 + 221 + const data: MusicStatusData = { 222 + trackName: value.item?.trackName || value.trackName, 223 + artists: value.item?.artists || value.artists || [], 224 + releaseName: value.item?.releaseName || value.releaseName, 225 + playedTime: value.item?.playedTime || value.playedTime, 226 + originUrl: value.item?.originUrl || value.originUrl, 227 + recordingMbId: value.item?.recordingMbId || value.recordingMbId, 228 + releaseMbId: value.item?.releaseMbId || value.releaseMbId, 229 + isrc: value.isrc, 230 + duration: value.duration, 231 + musicServiceBaseDomain: value.item?.musicServiceBaseDomain || value.musicServiceBaseDomain, 232 + submissionClientAgent: value.item?.submissionClientAgent || value.submissionClientAgent, 233 + $type: 'fm.teal.alpha.actor.status', 234 + expiry: value.expiry, 235 + artwork: value.item?.artwork || value.artwork, 236 + artworkUrl 237 + }; 238 + console.info('[MusicStatus] Successfully fetched actor status'); 239 + cache.set(cacheKey, data); 240 + return data; 241 + } 242 + } 243 + } 244 + } catch (err) { 245 + console.debug('[MusicStatus] Actor status not found or error, trying feed play:', err); 246 + } 247 + 248 + // Fall back to feed play collection 249 + const playRecords = await withFallback( 250 + PUBLIC_ATPROTO_DID, 251 + async (agent) => { 252 + const response = await agent.com.atproto.repo.listRecords({ 253 + repo: PUBLIC_ATPROTO_DID, 254 + collection: 'fm.teal.alpha.feed.play', 255 + limit: 1 256 + }); 257 + return response.data.records; 258 + }, 259 + true, 260 + fetchFn 261 + ); 262 + 263 + if (playRecords && playRecords.length > 0) { 264 + const record = playRecords[0]; 265 + const value = record.value as any; 266 + 267 + // Build artwork URL - prefer MusicBrainz, fallback to atproto blob 268 + let artworkUrl: string | undefined; 269 + let releaseMbId = value.releaseMbId; 270 + 271 + console.debug('[MusicStatus] Looking for artwork, releaseMbId:', releaseMbId); 272 + 273 + // If no releaseMbId, try to search MusicBrainz 274 + if (!releaseMbId) { 275 + const trackName = value.trackName; 276 + const artists = value.artists || []; 277 + const releaseName = value.releaseName; 278 + const artistName = artists[0]?.artistName; 279 + 280 + if (trackName && artistName) { 281 + console.debug('[MusicStatus] Searching MusicBrainz for missing release ID'); 282 + releaseMbId = await searchMusicBrainzRelease(trackName, artistName, releaseName); 283 + if (releaseMbId) { 284 + console.info('[MusicStatus] Found release via MusicBrainz search:', releaseMbId); 285 + } 286 + } 287 + } 288 + 289 + if (releaseMbId) { 290 + // Use MusicBrainz Cover Art Archive (no API key required) 291 + artworkUrl = buildCoverArtUrl(releaseMbId); 292 + console.info('[MusicStatus] Using MusicBrainz artwork URL:', artworkUrl); 293 + } else { 294 + // Fallback to atproto blob if available 295 + const artwork = value.artwork; 296 + console.debug('[MusicStatus] Artwork field:', artwork); 297 + if (artwork?.ref?.$link) { 298 + const identity = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); 299 + artworkUrl = buildPdsBlobUrl(identity.pds, PUBLIC_ATPROTO_DID, artwork.ref.$link); 300 + console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl); 301 + } 302 + } 303 + 304 + const data: MusicStatusData = { 305 + trackName: value.trackName, 306 + artists: value.artists || [], 307 + releaseName: value.releaseName, 308 + playedTime: value.playedTime, 309 + originUrl: value.originUrl, 310 + recordingMbId: value.recordingMbId, 311 + releaseMbId: value.releaseMbId, 312 + isrc: value.isrc, 313 + duration: value.duration, 314 + musicServiceBaseDomain: value.musicServiceBaseDomain, 315 + submissionClientAgent: value.submissionClientAgent, 316 + $type: 'fm.teal.alpha.feed.play', 317 + artwork: value.artwork, 318 + artworkUrl 319 + }; 320 + console.info('[MusicStatus] Successfully fetched feed play'); 321 + cache.set(cacheKey, data); 322 + return data; 323 + } 324 + 325 + return null; 326 + } catch (error) { 327 + console.error('[MusicStatus] Failed to fetch music status from all sources:', error); 328 + return null; 329 + } 330 + }
+6 -3
src/lib/services/atproto/index.ts
··· 8 8 // Export all types 9 9 export type { 10 10 ProfileData, 11 - StatusData, 12 11 SiteInfoData, 13 12 LinkData, 14 13 LinkCard, ··· 28 27 Credit, 29 28 SectionLicense, 30 29 ResolvedIdentity, 31 - CacheEntry 30 + CacheEntry, 31 + MusicStatusData, 32 + MusicArtist 32 33 } from './types'; 33 34 34 35 export type { TangledRepo, TangledReposData } from './tangled'; 35 36 36 37 // Export fetch functions 37 - export { fetchProfile, fetchStatus, fetchSiteInfo, fetchLinks } from './fetch'; 38 + export { fetchProfile, fetchSiteInfo, fetchLinks, fetchMusicStatus } from './fetch'; 38 39 39 40 export { fetchTangledRepos } from './tangled'; 40 41 ··· 49 50 export { buildPdsBlobUrl, extractCidFromImageObject, extractImageUrlsFromValue } from './media'; 50 51 51 52 export { resolveIdentity, withFallback, resetAgents } from './agents'; 53 + 54 + export { searchMusicBrainzRelease, buildCoverArtUrl } from './musicbrainz'; 52 55 53 56 // Export cache for advanced use cases 54 57 export { cache, ATProtoCache } from './cache';
+27
src/lib/services/atproto/types.ts
··· 189 189 data: T; 190 190 timestamp: number; 191 191 } 192 + 193 + export interface MusicArtist { 194 + artistName: string; 195 + artistMbId?: string; 196 + } 197 + 198 + export interface MusicStatusData { 199 + trackName: string; 200 + artists: MusicArtist[]; 201 + releaseName?: string; 202 + playedTime: string; 203 + originUrl?: string; 204 + recordingMbId?: string; 205 + releaseMbId?: string; 206 + isrc?: string; 207 + duration?: number; 208 + musicServiceBaseDomain?: string; 209 + submissionClientAgent?: string; 210 + $type: 'fm.teal.alpha.actor.status' | 'fm.teal.alpha.feed.play'; 211 + expiry?: string; 212 + artwork?: { 213 + ref?: { $link: string }; 214 + mimeType?: string; 215 + size?: number; 216 + }; 217 + artworkUrl?: string; // Computed URL for display 218 + }