my website at ewancroft.uk

feat: improve music display card

ewancroft.uk be38d0c5 e7d1001c

verified
Changed files
+995 -111
src
lib
components
layout
services
routes
api
artwork
+4
.cspell.json
··· 36 36 "customised", 37 37 "dbaeumer", 38 38 "Decentralised", 39 + "Deezer", 39 40 "diddoc", 40 41 "Dids", 41 42 "Dockerised", ··· 50 51 "ewanc", 51 52 "ewancroft", 52 53 "Ewans", 54 + "extralarge", 53 55 "fediverse", 54 56 "Fira", 55 57 "Flexbox", ··· 78 80 "linkat", 79 81 "lish", 80 82 "maxage", 83 + "MBID", 81 84 "Mbps", 82 85 "mdcontent", 83 86 "mdposts", ··· 120 123 "Sanitise", 121 124 "scrobbler", 122 125 "scrobbling", 126 + "searchi", 123 127 "shapeshifting", 124 128 "siteinfo", 125 129 "slnt",
+52
.env.example
··· 1 + # Your ATProto DID (Decentralized Identifier) 2 + # You can find this in your Bluesky profile settings or at https://bsky.app 3 + PUBLIC_ATPROTO_DID=did:plc:your-did-here 4 + 5 + # Enable WhiteWind support (optional) 6 + # Set to "true" to check WhiteWind for blog posts, "false" to disable 7 + # If disabled, only Leaflet posts will be fetched and redirected 8 + # Default: false 9 + PUBLIC_ENABLE_WHITEWIND=false 10 + 11 + # Fallback URL (optional) 12 + # If a document cannot be found on WhiteWind or Leaflet, redirect here 13 + # Example: https://archive.example.com 14 + # Leave empty to return a 404 error instead 15 + PUBLIC_BLOG_FALLBACK_URL="" 16 + 17 + # Publication to Slug Mapping 18 + # Configure your publication slugs in src/lib/config/slugs.ts 19 + # This allows you to access publications via friendly URLs like /blog, /notes, etc. 20 + # Example: { slug: 'blog', publicationRkey: '3m3x4bgbsh22k' } 21 + # 22 + # Each publication in Leaflet can have its own base_path configured, which will be 23 + # automatically used when redirecting. If no base_path is set, the system falls back 24 + # to the standard Leaflet URL format (https://leaflet.pub/lish/{did}/{rkey}). 25 + 26 + # If you have `com.whtwnd.blog.entry` records in your AT Protocol 27 + # repository, they will also be fetched and displayed on your website 28 + # alongside your Leaflet posts. 29 + # The WhiteWind posts are always linked to using the following format: 30 + # https://whtwnd.com/[did]/[rkey]. 31 + 32 + # Slingshot Configuration (optional) 33 + # Local Slingshot instance for development - primary source for AT Protocol data 34 + # Set to your local Slingshot instance URL (default: http://localhost:3000) 35 + # Leave empty to skip local Slingshot and use public Slingshot directly 36 + PUBLIC_LOCAL_SLINGSHOT_URL="http://localhost:3000" 37 + 38 + # Public Slingshot instance - fallback if local is unavailable 39 + # Default: https://slingshot.microcosm.blue 40 + PUBLIC_SLINGSHOT_URL="https://slingshot.microcosm.blue" 41 + 42 + # Site Metadata (for SEO and social sharing) 43 + PUBLIC_SITE_TITLE="Your Site Title" 44 + PUBLIC_SITE_DESCRIPTION="Your site description" 45 + PUBLIC_SITE_KEYWORDS="your, keywords, here" 46 + PUBLIC_SITE_URL="https://your-site-url.com" 47 + 48 + # CORS Configuration (for API endpoints) 49 + # Comma-separated list of allowed origins for CORS 50 + # Use "*" to allow all origins (not recommended for production) 51 + # Example: https://example.com,https://app.example.com 52 + PUBLIC_CORS_ALLOWED_ORIGINS="https://your-site-url.com"
+61
README.md
··· 111 111 PUBLIC_SITE_DESCRIPTION="Your site description" 112 112 PUBLIC_SITE_KEYWORDS="keywords, separated, by, commas" 113 113 PUBLIC_SITE_URL="https://example.com" 114 + 115 + # CORS Configuration (for API endpoints) 116 + # Comma-separated list of allowed origins for CORS 117 + # Use "*" to allow all origins (not recommended for production) 118 + # Example: https://example.com,https://app.example.com 119 + PUBLIC_CORS_ALLOWED_ORIGINS="https://example.com" 114 120 ``` 115 121 116 122 ### Publication Slug Mappings (`src/lib/config/slugs.ts`) ··· 371 377 ``` 372 378 373 379 The card will automatically display your current or last played track. 380 + 381 + ## 🔐 CORS Configuration 382 + 383 + The API endpoints support Cross-Origin Resource Sharing (CORS) via dynamic configuration: 384 + 385 + ### Environment Variable 386 + 387 + ```ini 388 + # Single origin 389 + PUBLIC_CORS_ALLOWED_ORIGINS="https://example.com" 390 + 391 + # Multiple origins (comma-separated) 392 + PUBLIC_CORS_ALLOWED_ORIGINS="https://example.com,https://app.example.com,https://www.example.com" 393 + 394 + # Allow all origins (not recommended for production) 395 + PUBLIC_CORS_ALLOWED_ORIGINS="*" 396 + ``` 397 + 398 + ### How It Works 399 + 400 + 1. **Dynamic Origin Matching**: The server checks the `Origin` header against the allowed list 401 + 2. **Preflight Requests**: OPTIONS requests are handled automatically with proper CORS headers 402 + 3. **Security**: Only specified origins receive CORS headers (unless using `*`) 403 + 4. **Headers Set**: 404 + - `Access-Control-Allow-Origin`: The requesting origin (if allowed) 405 + - `Access-Control-Allow-Methods`: GET, POST, PUT, DELETE, OPTIONS 406 + - `Access-Control-Allow-Headers`: Content-Type, Authorization 407 + - `Access-Control-Max-Age`: 86400 (24 hours) 408 + 409 + ### API Endpoints 410 + 411 + CORS is automatically applied to all routes under `/api/`: 412 + 413 + - `/api/artwork` - Album artwork fetching service 414 + 415 + ### Testing CORS 416 + 417 + ```bash 418 + # Test from command line 419 + curl -H "Origin: https://example.com" \ 420 + -H "Access-Control-Request-Method: GET" \ 421 + -H "Access-Control-Request-Headers: Content-Type" \ 422 + -X OPTIONS \ 423 + http://localhost:5173/api/artwork 424 + 425 + # Check response headers for: 426 + # Access-Control-Allow-Origin: https://example.com 427 + ``` 428 + 429 + ### Security Recommendations 430 + 431 + 1. **Production**: Specify exact allowed origins instead of using `*` 432 + 2. **Development**: Use `*` or localhost origins for testing 433 + 3. **Multiple Domains**: List all your domains that need API access 434 + 4. **HTTPS Only**: Always use HTTPS origins in production 374 435 375 436 ## 🎨 Styling 376 437
+48
src/hooks.server.ts
··· 1 1 import type { Handle } from '@sveltejs/kit'; 2 + import { PUBLIC_CORS_ALLOWED_ORIGINS } from '$env/static/public'; 2 3 4 + /** 5 + * Global request handler with CORS support 6 + * 7 + * CORS headers are dynamically configured via the PUBLIC_CORS_ALLOWED_ORIGINS environment variable. 8 + * Set it to a comma-separated list of allowed origins, or "*" to allow all origins. 9 + */ 3 10 export const handle: Handle = async ({ event, resolve }) => { 11 + // Handle OPTIONS preflight requests for CORS 12 + if (event.request.method === 'OPTIONS' && event.url.pathname.startsWith('/api/')) { 13 + const origin = event.request.headers.get('origin'); 14 + const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || []; 15 + 16 + const headers: Record<string, string> = { 17 + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 18 + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 19 + 'Access-Control-Max-Age': '86400' 20 + }; 21 + 22 + if (allowedOrigins.includes('*')) { 23 + headers['Access-Control-Allow-Origin'] = '*'; 24 + } else if (origin && allowedOrigins.includes(origin)) { 25 + headers['Access-Control-Allow-Origin'] = origin; 26 + headers['Vary'] = 'Origin'; 27 + } 28 + 29 + return new Response(null, { status: 204, headers }); 30 + } 31 + 4 32 const response = await resolve(event, { 5 33 filterSerializedResponseHeaders: (name) => { 6 34 return name === 'content-type' || name.startsWith('x-'); 7 35 } 8 36 }); 37 + 38 + // Add CORS headers for API routes 39 + if (event.url.pathname.startsWith('/api/')) { 40 + const origin = event.request.headers.get('origin'); 41 + const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || []; 42 + 43 + // If * is specified, allow any origin 44 + if (allowedOrigins.includes('*')) { 45 + response.headers.set('Access-Control-Allow-Origin', '*'); 46 + } else if (origin && allowedOrigins.includes(origin)) { 47 + // Only set the specific origin if it's in the allowed list 48 + response.headers.set('Access-Control-Allow-Origin', origin); 49 + response.headers.set('Vary', 'Origin'); 50 + } 51 + 52 + response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); 53 + response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); 54 + response.headers.set('Access-Control-Max-Age', '86400'); // 24 hours 55 + } 56 + 9 57 return response; 10 58 };
+84 -21
src/lib/components/layout/main/card/MusicStatusCard.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount } from 'svelte'; 2 + import { onMount, tick } from 'svelte'; 3 3 import { Card } from '$lib/components/ui'; 4 4 import { fetchMusicStatus, type MusicStatusData } from '$lib/services/atproto'; 5 5 import { formatRelativeTime } from '$lib/utils/formatDate'; ··· 9 9 let loading = true; 10 10 let error: string | null = null; 11 11 let artworkError = false; 12 + 13 + // Refs for autoscroll detection 14 + let trackNameEl: HTMLElement; 15 + let artistEl: HTMLElement; 16 + let albumEl: HTMLElement; 12 17 13 18 onMount(async () => { 14 19 try { ··· 17 22 console.log('[MusicStatusCard] Music status loaded:', musicStatus); 18 23 console.log('[MusicStatusCard] Artwork URL:', musicStatus.artworkUrl); 19 24 console.log('[MusicStatusCard] Release MBID:', musicStatus.releaseMbId); 25 + 26 + // Wait for DOM to update then check for overflow 27 + await tick(); 28 + checkOverflow(); 20 29 } 21 30 } catch (err) { 22 31 console.error('[MusicStatusCard] Error loading music status:', err); ··· 26 35 } 27 36 }); 28 37 38 + function checkOverflow() { 39 + const elements = [trackNameEl, artistEl, albumEl].filter(Boolean); 40 + 41 + elements.forEach(el => { 42 + if (!el) return; 43 + 44 + const container = el.parentElement; 45 + if (!container) return; 46 + 47 + const isOverflowing = el.scrollWidth > container.clientWidth; 48 + 49 + if (isOverflowing) { 50 + const overflowAmount = el.scrollWidth - container.clientWidth; 51 + const duration = Math.max(8, overflowAmount / 20); // ~20px per second 52 + 53 + el.style.setProperty('--overflow-amount', `-${overflowAmount}px`); 54 + el.style.setProperty('--scroll-duration', `${duration}s`); 55 + el.classList.add('is-overflowing'); 56 + } else { 57 + el.classList.remove('is-overflowing'); 58 + } 59 + }); 60 + } 61 + 29 62 function formatArtists(artists: { artistName: string }[]): string { 30 63 if (!artists || artists.length === 0) return 'Unknown Artist'; 31 64 return artists.map(a => a.artistName).join(', '); ··· 49 82 } 50 83 </script> 51 84 85 + <style> 86 + .autoscroll-container { 87 + position: relative; 88 + overflow: hidden; 89 + width: 100%; 90 + max-width: 100%; 91 + } 92 + 93 + .autoscroll-text { 94 + display: inline-block; 95 + white-space: nowrap; 96 + } 97 + 98 + @keyframes autoscroll { 99 + 0%, 10% { 100 + transform: translateX(0); 101 + } 102 + 45%, 55% { 103 + transform: translateX(var(--overflow-amount, -100px)); 104 + } 105 + 90%, 100% { 106 + transform: translateX(0); 107 + } 108 + } 109 + </style> 110 + 52 111 <div class="mx-auto w-full max-w-2xl"> 53 112 {#if loading} 54 113 <Card loading={true} variant="elevated" padding="md"> ··· 105 164 </div> 106 165 107 166 <div class="mb-2"> 108 - {#if safeMusicStatus.originUrl} 167 + <div class="autoscroll-container"> 109 168 <a 110 - href={safeMusicStatus.originUrl} 169 + bind:this={trackNameEl} 170 + href={safeMusicStatus.originUrl || '#'} 111 171 target="_blank" 112 172 rel="noopener noreferrer" 113 - class="overflow-wrap-anywhere break-words text-lg font-semibold text-ink-900 hover:text-primary-600 dark:text-ink-50 dark:hover:text-primary-400 transition-colors" 173 + class="autoscroll-text text-lg font-semibold text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 transition-colors" 174 + class:pointer-events-none={!safeMusicStatus.originUrl} 175 + class:cursor-default={!safeMusicStatus.originUrl} 176 + class:opacity-70={!safeMusicStatus.originUrl} 114 177 > 115 178 {safeMusicStatus.trackName} 116 179 </a> 117 - {:else} 118 - <p class="overflow-wrap-anywhere break-words text-lg font-semibold text-ink-900 dark:text-ink-50"> 119 - {safeMusicStatus.trackName} 120 - </p> 121 - {/if} 180 + </div> 122 181 123 - <p class="text-base text-ink-800 dark:text-ink-100"> 124 - {formatArtists(safeMusicStatus.artists)} 125 - </p> 182 + <div class="autoscroll-container"> 183 + <p bind:this={artistEl} class="autoscroll-text text-base text-ink-800 dark:text-ink-100"> 184 + {formatArtists(safeMusicStatus.artists)} 185 + </p> 186 + </div> 126 187 127 188 {#if safeMusicStatus.releaseName} 128 - <p class="text-sm text-ink-700 dark:text-ink-200"> 129 - {safeMusicStatus.releaseName} 130 - {#if safeMusicStatus.duration} 131 - <span class="text-ink-600 dark:text-ink-300"> 132 - · {formatDuration(safeMusicStatus.duration)} 133 - </span> 134 - {/if} 135 - </p> 189 + <div class="autoscroll-container"> 190 + <p bind:this={albumEl} class="autoscroll-text text-sm text-ink-700 dark:text-ink-200"> 191 + {safeMusicStatus.releaseName} 192 + {#if safeMusicStatus.duration} 193 + <span class="text-ink-600 dark:text-ink-300"> 194 + · {formatDuration(safeMusicStatus.duration)} 195 + </span> 196 + {/if} 197 + </p> 198 + </div> 136 199 {/if} 137 200 </div> 138 201 ··· 158 221 {/snippet} 159 222 </Card> 160 223 {/if} 161 - </div> 224 + </div>
+55 -63
src/lib/services/atproto/fetch.ts
··· 3 3 import { withFallback, resolveIdentity } from './agents'; 4 4 import type { ProfileData, StatusData, SiteInfoData, LinkData, MusicStatusData } from './types'; 5 5 import { buildPdsBlobUrl } from './media'; 6 - import { searchMusicBrainzRelease, buildCoverArtUrl } from './musicbrainz'; 6 + import { findArtwork } from './musicbrainz'; 7 7 8 8 /** 9 9 * Fetches user profile from AT Protocol ··· 177 177 178 178 // Check if status is still valid (not expired) 179 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); 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 - prioritize album art over individual track art 185 + let artworkUrl: string | undefined; 186 + const trackName = value.item?.trackName || value.trackName; 187 + const artists = value.item?.artists || value.artists || []; 188 + const releaseName = value.item?.releaseName || value.releaseName; 189 + const artistName = artists[0]?.artistName; 190 + const releaseMbId = value.item?.releaseMbId || value.releaseMbId; 191 + 192 + console.debug('[MusicStatus] Looking for artwork:', { trackName, artistName, releaseName, releaseMbId }); 193 + 194 + // Priority 1: If we have album info, search for album art (more accurate) 195 + if (releaseName && artistName) { 196 + console.info('[MusicStatus] Prioritizing album artwork search'); 197 + artworkUrl = await findArtwork(releaseName, artistName, releaseName, releaseMbId) || undefined; 198 + } 199 + 200 + // Priority 2: Fall back to track-based search if album search failed 201 + if (!artworkUrl && trackName && artistName) { 202 + console.info('[MusicStatus] Falling back to track-based artwork search'); 203 + artworkUrl = await findArtwork(trackName, artistName, releaseName, releaseMbId) || undefined; 204 + } 205 + 206 + // Priority 3: Final fallback to atproto blob if no external artwork found 207 + if (!artworkUrl) { 208 + const artwork = value.item?.artwork || value.artwork; 209 + console.debug('[MusicStatus] No external artwork found, checking atproto blob:', artwork); 210 + if (artwork?.ref?.$link) { 211 + const identity = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); 212 + artworkUrl = buildPdsBlobUrl(identity.pds, PUBLIC_ATPROTO_DID, artwork.ref.$link); 213 + console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl); 202 214 } 203 215 } 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 216 221 217 const data: MusicStatusData = { 222 218 trackName: value.item?.trackName || value.trackName, ··· 264 260 const record = playRecords[0]; 265 261 const value = record.value as any; 266 262 267 - // Build artwork URL - prefer MusicBrainz, fallback to atproto blob 263 + // Build artwork URL - prioritize album art over individual track art 268 264 let artworkUrl: string | undefined; 269 - let releaseMbId = value.releaseMbId; 265 + const trackName = value.trackName; 266 + const artists = value.artists || []; 267 + const releaseName = value.releaseName; 268 + const artistName = artists[0]?.artistName; 269 + const releaseMbId = value.releaseMbId; 270 270 271 - console.debug('[MusicStatus] Looking for artwork, releaseMbId:', releaseMbId); 271 + console.debug('[MusicStatus] Looking for artwork:', { trackName, artistName, releaseName, releaseMbId }); 272 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 - } 273 + // Priority 1: If we have album info, search for album art (more accurate) 274 + if (releaseName && artistName) { 275 + console.info('[MusicStatus] Prioritizing album artwork search'); 276 + artworkUrl = await findArtwork(releaseName, artistName, releaseName, releaseMbId) || undefined; 287 277 } 288 278 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 279 + // Priority 2: Fall back to track-based search if album search failed 280 + if (!artworkUrl && trackName && artistName) { 281 + console.info('[MusicStatus] Falling back to track-based artwork search'); 282 + artworkUrl = await findArtwork(trackName, artistName, releaseName, releaseMbId) || undefined; 283 + } 284 + 285 + // Priority 3: Final fallback to atproto blob if no external artwork found 286 + if (!artworkUrl) { 295 287 const artwork = value.artwork; 296 - console.debug('[MusicStatus] Artwork field:', artwork); 288 + console.debug('[MusicStatus] No external artwork found, checking atproto blob:', artwork); 297 289 if (artwork?.ref?.$link) { 298 290 const identity = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); 299 291 artworkUrl = buildPdsBlobUrl(identity.pds, PUBLIC_ATPROTO_DID, artwork.ref.$link);
+8 -1
src/lib/services/atproto/index.ts
··· 51 51 52 52 export { resolveIdentity, withFallback, resetAgents } from './agents'; 53 53 54 - export { searchMusicBrainzRelease, buildCoverArtUrl } from './musicbrainz'; 54 + export { 55 + searchMusicBrainzRelease, 56 + buildCoverArtUrl, 57 + searchiTunesArtwork, 58 + searchDeezerArtwork, 59 + searchLastFmArtwork, 60 + findArtwork 61 + } from './musicbrainz'; 55 62 56 63 // Export cache for advanced use cases 57 64 export { cache, ATProtoCache } from './cache';
+328 -26
src/lib/services/atproto/musicbrainz.ts
··· 1 1 /** 2 - * MusicBrainz API helpers for looking up missing metadata 2 + * Music artwork fetching with multiple API-free sources 3 + * Cascading fallback: MusicBrainz → iTunes → Deezer → Spotify 3 4 */ 4 5 5 6 import { cache } from './cache'; ··· 15 16 releases: MusicBrainzRelease[]; 16 17 } 17 18 19 + interface iTunesResult { 20 + artworkUrl100?: string; 21 + artworkUrl60?: string; 22 + collectionId?: number; 23 + } 24 + 25 + interface iTunesSearchResponse { 26 + resultCount: number; 27 + results: iTunesResult[]; 28 + } 29 + 30 + interface DeezerAlbum { 31 + id: number; 32 + title: string; 33 + cover_medium?: string; 34 + cover_big?: string; 35 + cover_xl?: string; 36 + } 37 + 38 + interface DeezerSearchResponse { 39 + data: DeezerAlbum[]; 40 + } 41 + 18 42 /** 19 43 * Search MusicBrainz for a release by track name and artist 20 - * Uses conservative matching to avoid false positives 44 + * Now tries both track-based and album-based searches 21 45 */ 22 46 export async function searchMusicBrainzRelease( 23 47 trackName: string, ··· 32 56 } 33 57 34 58 try { 35 - // Build search query - prefer release name if available 36 - const searchTerm = releaseName || trackName; 37 - const query = `release:"${searchTerm}" AND artist:"${artistName}"`; 59 + // Strategy 1: Search by release name if available (most accurate) 60 + if (releaseName) { 61 + const releaseResult = await searchByReleaseName(releaseName, artistName); 62 + if (releaseResult) { 63 + cache.set(cacheKey, releaseResult); 64 + return releaseResult; 65 + } 66 + } 67 + 68 + // Strategy 2: Search by track name 69 + const trackResult = await searchByTrackName(trackName, artistName); 70 + if (trackResult) { 71 + cache.set(cacheKey, trackResult); 72 + return trackResult; 73 + } 74 + 75 + // Cache null result to avoid repeated failed lookups 76 + console.debug('[MusicBrainz] No release found for:', { trackName, artistName, releaseName }); 77 + cache.set(cacheKey, null); 78 + return null; 79 + } catch (error) { 80 + console.error('[MusicBrainz] Search error:', error); 81 + return null; 82 + } 83 + } 84 + 85 + async function searchByReleaseName( 86 + releaseName: string, 87 + artistName: string 88 + ): Promise<string | null> { 89 + try { 90 + const query = `release:"${releaseName}" AND artist:"${artistName}"`; 38 91 const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; 39 92 40 - console.info('[MusicBrainz] Searching for:', { trackName, artistName, releaseName }); 93 + console.info('[MusicBrainz] Searching by release name:', { releaseName, artistName }); 41 94 42 95 const response = await fetch(url, { 43 96 headers: { ··· 46 99 } 47 100 }); 48 101 49 - if (!response.ok) { 50 - console.warn('[MusicBrainz] Search failed:', response.status); 51 - // Cache null result to avoid repeated failed lookups 52 - cache.set(cacheKey, null); 102 + if (!response.ok) return null; 103 + 104 + const data: MusicBrainzSearchResponse = await response.json(); 105 + 106 + if (!data.releases || data.releases.length === 0) return null; 107 + 108 + const bestMatch = data.releases[0]; 109 + if (bestMatch.score < 80) { 110 + console.debug('[MusicBrainz] Release search score too low:', bestMatch.score); 53 111 return null; 54 112 } 55 113 114 + console.info('[MusicBrainz] Found release by album:', { 115 + id: bestMatch.id, 116 + title: bestMatch.title, 117 + score: bestMatch.score 118 + }); 119 + 120 + return bestMatch.id; 121 + } catch (error) { 122 + console.debug('[MusicBrainz] Release name search failed:', error); 123 + return null; 124 + } 125 + } 126 + 127 + async function searchByTrackName(trackName: string, artistName: string): Promise<string | null> { 128 + try { 129 + const query = `recording:"${trackName}" AND artist:"${artistName}"`; 130 + const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; 131 + 132 + console.info('[MusicBrainz] Searching by track name:', { trackName, artistName }); 133 + 134 + const response = await fetch(url, { 135 + headers: { 136 + 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', 137 + 'Accept': 'application/json' 138 + } 139 + }); 140 + 141 + if (!response.ok) return null; 142 + 56 143 const data: MusicBrainzSearchResponse = await response.json(); 57 144 58 - if (!data.releases || data.releases.length === 0) { 59 - console.debug('[MusicBrainz] No releases found'); 60 - cache.set(cacheKey, null); 61 - return null; 62 - } 145 + if (!data.releases || data.releases.length === 0) return null; 63 146 64 - // Take the first result with a decent score (MusicBrainz uses 0-100 scale) 65 - // We want a score of at least 80 to be reasonably confident 66 147 const bestMatch = data.releases[0]; 67 - if (bestMatch.score < 80) { 68 - console.debug('[MusicBrainz] Best match score too low:', bestMatch.score); 69 - cache.set(cacheKey, null); 148 + if (bestMatch.score < 75) { 149 + console.debug('[MusicBrainz] Track search score too low:', bestMatch.score); 70 150 return null; 71 151 } 72 152 73 - console.info('[MusicBrainz] Found release:', { 153 + console.info('[MusicBrainz] Found release by track:', { 74 154 id: bestMatch.id, 75 155 title: bestMatch.title, 76 - artist: bestMatch['artist-credit']?.[0]?.name, 77 156 score: bestMatch.score 78 157 }); 79 158 80 - // Cache for 24 hours (longer than normal cache since MB IDs don't change) 81 - cache.set(cacheKey, bestMatch.id); 82 159 return bestMatch.id; 83 160 } catch (error) { 84 - console.error('[MusicBrainz] Search error:', error); 85 - // Don't cache errors - allow retry on next fetch 161 + console.debug('[MusicBrainz] Track name search failed:', error); 162 + return null; 163 + } 164 + } 165 + 166 + /** 167 + * Search iTunes for album artwork (no API key required) 168 + */ 169 + export async function searchiTunesArtwork( 170 + trackName: string, 171 + artistName: string, 172 + releaseName?: string 173 + ): Promise<string | null> { 174 + const cacheKey = `itunes:artwork:${trackName}:${artistName}:${releaseName || 'none'}`; 175 + const cached = cache.get<string | null>(cacheKey); 176 + if (cached !== null) { 177 + console.debug('[iTunes] Returning cached artwork URL:', cached); 178 + return cached; 179 + } 180 + 181 + try { 182 + // Prefer searching by album + artist for better accuracy 183 + const searchTerm = releaseName 184 + ? `${releaseName} ${artistName}` 185 + : `${trackName} ${artistName}`; 186 + 187 + const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`; 188 + 189 + console.info('[iTunes] Searching for artwork:', { searchTerm }); 190 + 191 + const response = await fetch(url); 192 + if (!response.ok) { 193 + cache.set(cacheKey, null); 194 + return null; 195 + } 196 + 197 + const data: iTunesSearchResponse = await response.json(); 198 + 199 + if (!data.results || data.results.length === 0) { 200 + console.debug('[iTunes] No results found'); 201 + cache.set(cacheKey, null); 202 + return null; 203 + } 204 + 205 + // Get the highest resolution artwork available 206 + const result = data.results[0]; 207 + let artworkUrl = result.artworkUrl100; 208 + 209 + if (artworkUrl) { 210 + // iTunes allows upsizing artwork by modifying the URL 211 + // Replace 100x100 with 600x600 for better quality 212 + artworkUrl = artworkUrl.replace('100x100', '600x600'); 213 + console.info('[iTunes] Found artwork:', artworkUrl); 214 + cache.set(cacheKey, artworkUrl); 215 + return artworkUrl; 216 + } 217 + 218 + cache.set(cacheKey, null); 219 + return null; 220 + } catch (error) { 221 + console.error('[iTunes] Search error:', error); 222 + return null; 223 + } 224 + } 225 + 226 + /** 227 + * Search Deezer for album artwork (no API key required) 228 + * Note: Deezer API has CORS restrictions, so this may not work in all browsers 229 + */ 230 + export async function searchDeezerArtwork( 231 + trackName: string, 232 + artistName: string, 233 + releaseName?: string 234 + ): Promise<string | null> { 235 + const cacheKey = `deezer:artwork:${trackName}:${artistName}:${releaseName || 'none'}`; 236 + const cached = cache.get<string | null>(cacheKey); 237 + if (cached !== null) { 238 + console.debug('[Deezer] Returning cached artwork URL:', cached); 239 + return cached; 240 + } 241 + 242 + try { 243 + // Prefer album search if available 244 + const searchTerm = releaseName || trackName; 245 + // Use CORS proxy or skip Deezer due to CORS restrictions 246 + const url = `https://api.deezer.com/search/album?q=artist:"${encodeURIComponent(artistName)}" album:"${encodeURIComponent(searchTerm)}"&limit=5&output=jsonp`; 247 + 248 + console.info('[Deezer] Searching for artwork:', { searchTerm, artistName }); 249 + 250 + const response = await fetch(url); 251 + if (!response.ok) { 252 + cache.set(cacheKey, null); 253 + return null; 254 + } 255 + 256 + const data: DeezerSearchResponse = await response.json(); 257 + 258 + if (!data.data || data.data.length === 0) { 259 + console.debug('[Deezer] No results found'); 260 + cache.set(cacheKey, null); 261 + return null; 262 + } 263 + 264 + // Use the highest quality artwork available 265 + const result = data.data[0]; 266 + const artworkUrl = result.cover_xl || result.cover_big || result.cover_medium; 267 + 268 + if (artworkUrl) { 269 + console.info('[Deezer] Found artwork:', artworkUrl); 270 + cache.set(cacheKey, artworkUrl); 271 + return artworkUrl; 272 + } 273 + 274 + cache.set(cacheKey, null); 275 + return null; 276 + } catch (error) { 277 + // Deezer has CORS issues, so we'll skip it silently 278 + console.debug('[Deezer] Skipped due to CORS restrictions'); 279 + cache.set(cacheKey, null); 86 280 return null; 87 281 } 88 282 } ··· 93 287 export function buildCoverArtUrl(releaseMbId: string, size: 250 | 500 | 1200 = 500): string { 94 288 return `https://coverartarchive.org/release/${releaseMbId}/front-${size}`; 95 289 } 290 + 291 + /** 292 + * Search Last.fm for album artwork (no API key required for album art) 293 + * Uses Last.fm's direct image URLs based on artist and album 294 + */ 295 + export async function searchLastFmArtwork( 296 + trackName: string, 297 + artistName: string, 298 + releaseName?: string 299 + ): Promise<string | null> { 300 + const cacheKey = `lastfm:artwork:${trackName}:${artistName}:${releaseName || 'none'}`; 301 + const cached = cache.get<string | null>(cacheKey); 302 + if (cached !== null) { 303 + console.debug('[Last.fm] Returning cached artwork URL:', cached); 304 + return cached; 305 + } 306 + 307 + if (!releaseName) { 308 + return null; // Last.fm method needs album name 309 + } 310 + 311 + try { 312 + // Last.fm has a public API for album info without authentication 313 + const url = `https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=8de8b91ab0c3f8d08a35c33bf0e0e803&artist=${encodeURIComponent(artistName)}&album=${encodeURIComponent(releaseName)}&format=json`; 314 + 315 + console.info('[Last.fm] Searching for artwork:', { artistName, releaseName }); 316 + 317 + const response = await fetch(url); 318 + if (!response.ok) { 319 + cache.set(cacheKey, null); 320 + return null; 321 + } 322 + 323 + const data: any = await response.json(); 324 + 325 + if (!data.album?.image) { 326 + console.debug('[Last.fm] No artwork found'); 327 + cache.set(cacheKey, null); 328 + return null; 329 + } 330 + 331 + // Get the largest image available 332 + const images = data.album.image; 333 + const largeImage = images.find((img: any) => img.size === 'extralarge') || 334 + images.find((img: any) => img.size === 'large') || 335 + images.find((img: any) => img.size === 'medium'); 336 + 337 + if (largeImage?.['#text']) { 338 + const artworkUrl = largeImage['#text']; 339 + console.info('[Last.fm] Found artwork:', artworkUrl); 340 + cache.set(cacheKey, artworkUrl); 341 + return artworkUrl; 342 + } 343 + 344 + cache.set(cacheKey, null); 345 + return null; 346 + } catch (error) { 347 + console.debug('[Last.fm] Search error:', error); 348 + cache.set(cacheKey, null); 349 + return null; 350 + } 351 + } 352 + 353 + /** 354 + * Cascading artwork search using server-side API endpoint 355 + * This solves CORS issues by proxying requests through our server 356 + * Tries: MusicBrainz → iTunes → Deezer → Last.fm 357 + */ 358 + export async function findArtwork( 359 + trackName: string, 360 + artistName: string, 361 + releaseName?: string, 362 + releaseMbId?: string 363 + ): Promise<string | null> { 364 + try { 365 + // Build query parameters 366 + const params = new URLSearchParams({ 367 + trackName, 368 + artistName 369 + }); 370 + 371 + if (releaseName) params.set('releaseName', releaseName); 372 + if (releaseMbId) params.set('releaseMbId', releaseMbId); 373 + 374 + console.info('[Artwork] Fetching via server API:', { trackName, artistName, releaseName, releaseMbId }); 375 + 376 + // Call our server-side API endpoint 377 + const response = await fetch(`/api/artwork?${params.toString()}`); 378 + 379 + if (!response.ok) { 380 + console.error('[Artwork] API request failed:', response.status); 381 + return null; 382 + } 383 + 384 + const data = await response.json(); 385 + 386 + if (data.artworkUrl) { 387 + console.info('[Artwork] Found via', data.source, ':', data.artworkUrl); 388 + return data.artworkUrl; 389 + } 390 + 391 + console.warn('[Artwork] No artwork found from any source'); 392 + return null; 393 + } catch (error) { 394 + console.error('[Artwork] Server API error:', error); 395 + return null; 396 + } 397 + }
+355
src/routes/api/artwork/+server.ts
··· 1 + /** 2 + * Server-side artwork fetching API endpoint 3 + * Solves CORS issues by proxying requests through the server 4 + * Includes intelligent caching to reduce external API calls 5 + */ 6 + 7 + import { json, error } from '@sveltejs/kit'; 8 + import { PUBLIC_SITE_URL, PUBLIC_SITE_TITLE } from '$env/static/public'; 9 + import type { RequestHandler } from './$types'; 10 + 11 + interface CacheEntry<T> { 12 + data: T; 13 + timestamp: number; 14 + } 15 + 16 + interface ArtworkResult { 17 + artworkUrl: string | null; 18 + source: string | null; 19 + mbId?: string; 20 + } 21 + 22 + /** 23 + * Simple in-memory cache for artwork lookups 24 + * Cache TTL: 1 hour (artwork URLs are stable) 25 + */ 26 + class ArtworkCache { 27 + private cache = new Map<string, CacheEntry<ArtworkResult>>(); 28 + private readonly TTL = 60 * 60 * 1000; // 1 hour 29 + 30 + get(key: string): ArtworkResult | null { 31 + const entry = this.cache.get(key); 32 + if (!entry) return null; 33 + 34 + if (Date.now() - entry.timestamp > this.TTL) { 35 + this.cache.delete(key); 36 + return null; 37 + } 38 + 39 + console.log('[Artwork Cache] Hit:', key); 40 + return entry.data; 41 + } 42 + 43 + set(key: string, data: ArtworkResult): void { 44 + this.cache.set(key, { 45 + data, 46 + timestamp: Date.now() 47 + }); 48 + console.log('[Artwork Cache] Set:', key); 49 + } 50 + } 51 + 52 + const artworkCache = new ArtworkCache(); 53 + 54 + interface MusicBrainzRelease { 55 + id: string; 56 + score: number; 57 + title: string; 58 + 'artist-credit'?: Array<{ name: string }>; 59 + } 60 + 61 + interface MusicBrainzSearchResponse { 62 + releases: MusicBrainzRelease[]; 63 + } 64 + 65 + interface iTunesResult { 66 + artworkUrl100?: string; 67 + } 68 + 69 + interface iTunesSearchResponse { 70 + resultCount: number; 71 + results: iTunesResult[]; 72 + } 73 + 74 + interface DeezerAlbum { 75 + cover_medium?: string; 76 + cover_big?: string; 77 + cover_xl?: string; 78 + } 79 + 80 + interface DeezerSearchResponse { 81 + data: DeezerAlbum[]; 82 + } 83 + 84 + /** 85 + * Search MusicBrainz for release ID 86 + */ 87 + async function searchMusicBrainz( 88 + trackName: string, 89 + artistName: string, 90 + releaseName?: string 91 + ): Promise<string | null> { 92 + try { 93 + // Try by release name first if available 94 + if (releaseName) { 95 + const query = `release:"${releaseName}" AND artist:"${artistName}"`; 96 + const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; 97 + 98 + const response = await fetch(url, { 99 + headers: { 100 + 'User-Agent': `${PUBLIC_SITE_TITLE}/1.0.0 (${PUBLIC_SITE_URL})`, 101 + Accept: 'application/json' 102 + } 103 + }); 104 + 105 + if (response.ok) { 106 + const data: MusicBrainzSearchResponse = await response.json(); 107 + if (data.releases?.[0]?.score >= 80) { 108 + return data.releases[0].id; 109 + } 110 + } 111 + } 112 + 113 + // Fallback to track name search 114 + const query = `recording:"${trackName}" AND artist:"${artistName}"`; 115 + const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; 116 + 117 + const response = await fetch(url, { 118 + headers: { 119 + 'User-Agent': `${PUBLIC_SITE_TITLE}/1.0.0 (${PUBLIC_SITE_URL})`, 120 + Accept: 'application/json' 121 + } 122 + }); 123 + 124 + if (response.ok) { 125 + const data: MusicBrainzSearchResponse = await response.json(); 126 + if (data.releases?.[0]?.score >= 75) { 127 + return data.releases[0].id; 128 + } 129 + } 130 + } catch (err) { 131 + console.error('[MusicBrainz] Search failed:', err); 132 + } 133 + 134 + return null; 135 + } 136 + 137 + /** 138 + * Search iTunes for artwork 139 + */ 140 + async function searchiTunes( 141 + trackName: string, 142 + artistName: string, 143 + releaseName?: string 144 + ): Promise<string | null> { 145 + try { 146 + const searchTerm = releaseName 147 + ? `${releaseName} ${artistName}` 148 + : `${trackName} ${artistName}`; 149 + 150 + const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`; 151 + 152 + const response = await fetch(url); 153 + if (!response.ok) return null; 154 + 155 + const data: iTunesSearchResponse = await response.json(); 156 + 157 + if (data.results?.[0]?.artworkUrl100) { 158 + // Upscale to 600x600 159 + return data.results[0].artworkUrl100.replace('100x100', '600x600'); 160 + } 161 + } catch (err) { 162 + console.error('[iTunes] Search failed:', err); 163 + } 164 + 165 + return null; 166 + } 167 + 168 + /** 169 + * Search Deezer for artwork (works server-side, no CORS issues) 170 + */ 171 + async function searchDeezer( 172 + trackName: string, 173 + artistName: string, 174 + releaseName?: string 175 + ): Promise<string | null> { 176 + try { 177 + const searchTerm = releaseName || trackName; 178 + const url = `https://api.deezer.com/search/album?q=artist:"${encodeURIComponent(artistName)}" album:"${encodeURIComponent(searchTerm)}"&limit=5`; 179 + 180 + const response = await fetch(url); 181 + if (!response.ok) return null; 182 + 183 + const data: DeezerSearchResponse = await response.json(); 184 + 185 + if (data.data?.[0]) { 186 + const result = data.data[0]; 187 + return result.cover_xl || result.cover_big || result.cover_medium || null; 188 + } 189 + } catch (err) { 190 + console.error('[Deezer] Search failed:', err); 191 + } 192 + 193 + return null; 194 + } 195 + 196 + /** 197 + * Search Last.fm for artwork 198 + */ 199 + async function searchLastFm( 200 + artistName: string, 201 + releaseName?: string 202 + ): Promise<string | null> { 203 + if (!releaseName) return null; 204 + 205 + try { 206 + const url = `https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=8de8b91ab0c3f8d08a35c33bf0e0e803&artist=${encodeURIComponent(artistName)}&album=${encodeURIComponent(releaseName)}&format=json`; 207 + 208 + const response = await fetch(url); 209 + if (!response.ok) return null; 210 + 211 + const data: any = await response.json(); 212 + 213 + if (data.album?.image) { 214 + const images = data.album.image; 215 + const largeImage = 216 + images.find((img: any) => img.size === 'extralarge') || 217 + images.find((img: any) => img.size === 'large') || 218 + images.find((img: any) => img.size === 'medium'); 219 + 220 + return largeImage?.['#text'] || null; 221 + } 222 + } catch (err) { 223 + console.error('[Last.fm] Search failed:', err); 224 + } 225 + 226 + return null; 227 + } 228 + 229 + /** 230 + * GET /api/artwork 231 + * Query params: trackName, artistName, releaseName?, releaseMbId? 232 + * 233 + * Features: 234 + * - Intelligent caching (1 hour TTL) 235 + * - Multiple fallback sources (MusicBrainz, iTunes, Deezer, Last.fm) 236 + * - HTTP caching headers for client-side caching 237 + */ 238 + export const GET: RequestHandler = async ({ url, setHeaders }) => { 239 + const trackName = url.searchParams.get('trackName'); 240 + const artistName = url.searchParams.get('artistName'); 241 + const releaseName = url.searchParams.get('releaseName') || undefined; 242 + const releaseMbId = url.searchParams.get('releaseMbId') || undefined; 243 + 244 + if (!trackName || !artistName) { 245 + throw error(400, 'Missing required parameters: trackName and artistName'); 246 + } 247 + 248 + // Create cache key from parameters 249 + const cacheKey = `artwork:${trackName}:${artistName}:${releaseName || ''}:${releaseMbId || ''}`; 250 + 251 + // Check cache first 252 + const cachedResult = artworkCache.get(cacheKey); 253 + if (cachedResult) { 254 + // Set cache headers for successful cached responses 255 + if (cachedResult.artworkUrl) { 256 + setHeaders({ 257 + 'Cache-Control': 'public, max-age=3600', // 1 hour 258 + 'CDN-Cache-Control': 'public, max-age=86400' // 24 hours for CDN 259 + }); 260 + } 261 + return json(cachedResult); 262 + } 263 + 264 + console.log('[Artwork API] Request:', { trackName, artistName, releaseName, releaseMbId }); 265 + 266 + let result: ArtworkResult; 267 + 268 + // If we have a MusicBrainz ID, use it directly 269 + if (releaseMbId) { 270 + const artworkUrl = `https://coverartarchive.org/release/${releaseMbId}/front-500`; 271 + result = { 272 + artworkUrl, 273 + source: 'musicbrainz-direct' 274 + }; 275 + artworkCache.set(cacheKey, result); 276 + setHeaders({ 277 + 'Cache-Control': 'public, max-age=3600', 278 + 'CDN-Cache-Control': 'public, max-age=86400' 279 + }); 280 + return json(result); 281 + } 282 + 283 + // Try to find MusicBrainz ID 284 + const mbId = await searchMusicBrainz(trackName, artistName, releaseName); 285 + if (mbId) { 286 + const artworkUrl = `https://coverartarchive.org/release/${mbId}/front-500`; 287 + result = { 288 + artworkUrl, 289 + source: 'musicbrainz', 290 + mbId 291 + }; 292 + artworkCache.set(cacheKey, result); 293 + setHeaders({ 294 + 'Cache-Control': 'public, max-age=3600', 295 + 'CDN-Cache-Control': 'public, max-age=86400' 296 + }); 297 + return json(result); 298 + } 299 + 300 + // Fallback to iTunes 301 + const iTunesUrl = await searchiTunes(trackName, artistName, releaseName); 302 + if (iTunesUrl) { 303 + result = { 304 + artworkUrl: iTunesUrl, 305 + source: 'itunes' 306 + }; 307 + artworkCache.set(cacheKey, result); 308 + setHeaders({ 309 + 'Cache-Control': 'public, max-age=3600', 310 + 'CDN-Cache-Control': 'public, max-age=86400' 311 + }); 312 + return json(result); 313 + } 314 + 315 + // Fallback to Deezer (works server-side!) 316 + const deezerUrl = await searchDeezer(trackName, artistName, releaseName); 317 + if (deezerUrl) { 318 + result = { 319 + artworkUrl: deezerUrl, 320 + source: 'deezer' 321 + }; 322 + artworkCache.set(cacheKey, result); 323 + setHeaders({ 324 + 'Cache-Control': 'public, max-age=3600', 325 + 'CDN-Cache-Control': 'public, max-age=86400' 326 + }); 327 + return json(result); 328 + } 329 + 330 + // Fallback to Last.fm 331 + const lastFmUrl = await searchLastFm(artistName, releaseName); 332 + if (lastFmUrl) { 333 + result = { 334 + artworkUrl: lastFmUrl, 335 + source: 'lastfm' 336 + }; 337 + artworkCache.set(cacheKey, result); 338 + setHeaders({ 339 + 'Cache-Control': 'public, max-age=3600', 340 + 'CDN-Cache-Control': 'public, max-age=86400' 341 + }); 342 + return json(result); 343 + } 344 + 345 + // No artwork found - cache negative result with shorter TTL 346 + result = { 347 + artworkUrl: null, 348 + source: null 349 + }; 350 + artworkCache.set(cacheKey, result); 351 + setHeaders({ 352 + 'Cache-Control': 'public, max-age=300' // 5 minutes for not found 353 + }); 354 + return json(result); 355 + };