+4
.cspell.json
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
};