diff --git a/.cspell.json b/.cspell.json index d036aca..ac7d9ea 100644 --- a/.cspell.json +++ b/.cspell.json @@ -36,6 +36,7 @@ "customised", "dbaeumer", "Decentralised", + "Deezer", "diddoc", "Dids", "Dockerised", @@ -50,6 +51,7 @@ "ewanc", "ewancroft", "Ewans", + "extralarge", "fediverse", "Fira", "Flexbox", @@ -78,6 +80,7 @@ "linkat", "lish", "maxage", + "MBID", "Mbps", "mdcontent", "mdposts", @@ -120,6 +123,7 @@ "Sanitise", "scrobbler", "scrobbling", + "searchi", "shapeshifting", "siteinfo", "slnt", diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..75cf2f8 --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# Your ATProto DID (Decentralized Identifier) +# You can find this in your Bluesky profile settings or at https://bsky.app +PUBLIC_ATPROTO_DID=did:plc:your-did-here + +# Enable WhiteWind support (optional) +# Set to "true" to check WhiteWind for blog posts, "false" to disable +# If disabled, only Leaflet posts will be fetched and redirected +# Default: false +PUBLIC_ENABLE_WHITEWIND=false + +# Fallback URL (optional) +# If a document cannot be found on WhiteWind or Leaflet, redirect here +# Example: https://archive.example.com +# Leave empty to return a 404 error instead +PUBLIC_BLOG_FALLBACK_URL="" + +# Publication to Slug Mapping +# Configure your publication slugs in src/lib/config/slugs.ts +# This allows you to access publications via friendly URLs like /blog, /notes, etc. +# Example: { slug: 'blog', publicationRkey: '3m3x4bgbsh22k' } +# +# Each publication in Leaflet can have its own base_path configured, which will be +# automatically used when redirecting. If no base_path is set, the system falls back +# to the standard Leaflet URL format (https://leaflet.pub/lish/{did}/{rkey}). + +# If you have `com.whtwnd.blog.entry` records in your AT Protocol +# repository, they will also be fetched and displayed on your website +# alongside your Leaflet posts. +# The WhiteWind posts are always linked to using the following format: +# https://whtwnd.com/[did]/[rkey]. + +# Slingshot Configuration (optional) +# Local Slingshot instance for development - primary source for AT Protocol data +# Set to your local Slingshot instance URL (default: http://localhost:3000) +# Leave empty to skip local Slingshot and use public Slingshot directly +PUBLIC_LOCAL_SLINGSHOT_URL="http://localhost:3000" + +# Public Slingshot instance - fallback if local is unavailable +# Default: https://slingshot.microcosm.blue +PUBLIC_SLINGSHOT_URL="https://slingshot.microcosm.blue" + +# Site Metadata (for SEO and social sharing) +PUBLIC_SITE_TITLE="Your Site Title" +PUBLIC_SITE_DESCRIPTION="Your site description" +PUBLIC_SITE_KEYWORDS="your, keywords, here" +PUBLIC_SITE_URL="https://your-site-url.com" + +# CORS Configuration (for API endpoints) +# Comma-separated list of allowed origins for CORS +# Use "*" to allow all origins (not recommended for production) +# Example: https://example.com,https://app.example.com +PUBLIC_CORS_ALLOWED_ORIGINS="https://your-site-url.com" diff --git a/README.md b/README.md index a8c58cb..88b36d1 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,12 @@ PUBLIC_SITE_TITLE="Your Site Title" PUBLIC_SITE_DESCRIPTION="Your site description" PUBLIC_SITE_KEYWORDS="keywords, separated, by, commas" PUBLIC_SITE_URL="https://example.com" + +# CORS Configuration (for API endpoints) +# Comma-separated list of allowed origins for CORS +# Use "*" to allow all origins (not recommended for production) +# Example: https://example.com,https://app.example.com +PUBLIC_CORS_ALLOWED_ORIGINS="https://example.com" ``` ### Publication Slug Mappings (`src/lib/config/slugs.ts`) @@ -372,6 +378,61 @@ PUBLIC_ATPROTO_DID=did:plc:your-did-here The card will automatically display your current or last played track. +## ๐Ÿ” CORS Configuration + +The API endpoints support Cross-Origin Resource Sharing (CORS) via dynamic configuration: + +### Environment Variable + +```ini +# Single origin +PUBLIC_CORS_ALLOWED_ORIGINS="https://example.com" + +# Multiple origins (comma-separated) +PUBLIC_CORS_ALLOWED_ORIGINS="https://example.com,https://app.example.com,https://www.example.com" + +# Allow all origins (not recommended for production) +PUBLIC_CORS_ALLOWED_ORIGINS="*" +``` + +### How It Works + +1. **Dynamic Origin Matching**: The server checks the `Origin` header against the allowed list +2. **Preflight Requests**: OPTIONS requests are handled automatically with proper CORS headers +3. **Security**: Only specified origins receive CORS headers (unless using `*`) +4. **Headers Set**: + - `Access-Control-Allow-Origin`: The requesting origin (if allowed) + - `Access-Control-Allow-Methods`: GET, POST, PUT, DELETE, OPTIONS + - `Access-Control-Allow-Headers`: Content-Type, Authorization + - `Access-Control-Max-Age`: 86400 (24 hours) + +### API Endpoints + +CORS is automatically applied to all routes under `/api/`: + +- `/api/artwork` - Album artwork fetching service + +### Testing CORS + +```bash +# Test from command line +curl -H "Origin: https://example.com" \ + -H "Access-Control-Request-Method: GET" \ + -H "Access-Control-Request-Headers: Content-Type" \ + -X OPTIONS \ + http://localhost:5173/api/artwork + +# Check response headers for: +# Access-Control-Allow-Origin: https://example.com +``` + +### Security Recommendations + +1. **Production**: Specify exact allowed origins instead of using `*` +2. **Development**: Use `*` or localhost origins for testing +3. **Multiple Domains**: List all your domains that need API access +4. **HTTPS Only**: Always use HTTPS origins in production + ## ๐ŸŽจ Styling The project uses: diff --git a/src/hooks.server.ts b/src/hooks.server.ts index b2eaf55..17f3a37 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,10 +1,58 @@ import type { Handle } from '@sveltejs/kit'; +import { PUBLIC_CORS_ALLOWED_ORIGINS } from '$env/static/public'; +/** + * Global request handler with CORS support + * + * CORS headers are dynamically configured via the PUBLIC_CORS_ALLOWED_ORIGINS environment variable. + * Set it to a comma-separated list of allowed origins, or "*" to allow all origins. + */ export const handle: Handle = async ({ event, resolve }) => { + // Handle OPTIONS preflight requests for CORS + if (event.request.method === 'OPTIONS' && event.url.pathname.startsWith('/api/')) { + const origin = event.request.headers.get('origin'); + const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || []; + + const headers: Record = { + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400' + }; + + if (allowedOrigins.includes('*')) { + headers['Access-Control-Allow-Origin'] = '*'; + } else if (origin && allowedOrigins.includes(origin)) { + headers['Access-Control-Allow-Origin'] = origin; + headers['Vary'] = 'Origin'; + } + + return new Response(null, { status: 204, headers }); + } + const response = await resolve(event, { filterSerializedResponseHeaders: (name) => { return name === 'content-type' || name.startsWith('x-'); } }); + + // Add CORS headers for API routes + if (event.url.pathname.startsWith('/api/')) { + const origin = event.request.headers.get('origin'); + const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || []; + + // If * is specified, allow any origin + if (allowedOrigins.includes('*')) { + response.headers.set('Access-Control-Allow-Origin', '*'); + } else if (origin && allowedOrigins.includes(origin)) { + // Only set the specific origin if it's in the allowed list + response.headers.set('Access-Control-Allow-Origin', origin); + response.headers.set('Vary', 'Origin'); + } + + response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + response.headers.set('Access-Control-Max-Age', '86400'); // 24 hours + } + return response; }; \ No newline at end of file diff --git a/src/lib/components/layout/main/card/MusicStatusCard.svelte b/src/lib/components/layout/main/card/MusicStatusCard.svelte index fda60e7..2b7458e 100644 --- a/src/lib/components/layout/main/card/MusicStatusCard.svelte +++ b/src/lib/components/layout/main/card/MusicStatusCard.svelte @@ -1,5 +1,5 @@ + +
{#if loading} @@ -105,34 +164,38 @@
- {#if safeMusicStatus.originUrl} +
{safeMusicStatus.trackName} - {:else} -

- {safeMusicStatus.trackName} -

- {/if} +
-

- {formatArtists(safeMusicStatus.artists)} -

+
+

+ {formatArtists(safeMusicStatus.artists)} +

+
{#if safeMusicStatus.releaseName} -

- {safeMusicStatus.releaseName} - {#if safeMusicStatus.duration} - - ยท {formatDuration(safeMusicStatus.duration)} - - {/if} -

+
+

+ {safeMusicStatus.releaseName} + {#if safeMusicStatus.duration} + + ยท {formatDuration(safeMusicStatus.duration)} + + {/if} +

+
{/if}
@@ -158,4 +221,4 @@ {/snippet} {/if} - + \ No newline at end of file diff --git a/src/lib/services/atproto/fetch.ts b/src/lib/services/atproto/fetch.ts index 9795f22..d9f499c 100644 --- a/src/lib/services/atproto/fetch.ts +++ b/src/lib/services/atproto/fetch.ts @@ -3,7 +3,7 @@ import { cache } from './cache'; import { withFallback, resolveIdentity } from './agents'; import type { ProfileData, StatusData, SiteInfoData, LinkData, MusicStatusData } from './types'; import { buildPdsBlobUrl } from './media'; -import { searchMusicBrainzRelease, buildCoverArtUrl } from './musicbrainz'; +import { findArtwork } from './musicbrainz'; /** * Fetches user profile from AT Protocol @@ -177,46 +177,42 @@ export async function fetchMusicStatus(fetchFn?: typeof fetch): Promise expiryTime) { - console.debug('[MusicStatus] Actor status expired, falling back to feed play'); - } else { - // Build artwork URL - prefer MusicBrainz, fallback to atproto blob - let artworkUrl: string | undefined; - let releaseMbId = value.item?.releaseMbId || value.releaseMbId; - - console.debug('[MusicStatus] Looking for artwork, releaseMbId:', releaseMbId); - - // If no releaseMbId, try to search MusicBrainz - if (!releaseMbId) { - const trackName = value.item?.trackName || value.trackName; - const artists = value.item?.artists || value.artists || []; - const releaseName = value.item?.releaseName || value.releaseName; - const artistName = artists[0]?.artistName; - - if (trackName && artistName) { - console.debug('[MusicStatus] Searching MusicBrainz for missing release ID'); - releaseMbId = await searchMusicBrainzRelease(trackName, artistName, releaseName); - if (releaseMbId) { - console.info('[MusicStatus] Found release via MusicBrainz search:', releaseMbId); + const expiryTime = parseInt(value.expiry) * 1000; + if (Date.now() > expiryTime) { + console.debug('[MusicStatus] Actor status expired, falling back to feed play'); + } else { + // Build artwork URL - prioritize album art over individual track art + let artworkUrl: string | undefined; + const trackName = value.item?.trackName || value.trackName; + const artists = value.item?.artists || value.artists || []; + const releaseName = value.item?.releaseName || value.releaseName; + const artistName = artists[0]?.artistName; + const releaseMbId = value.item?.releaseMbId || value.releaseMbId; + + console.debug('[MusicStatus] Looking for artwork:', { trackName, artistName, releaseName, releaseMbId }); + + // Priority 1: If we have album info, search for album art (more accurate) + if (releaseName && artistName) { + console.info('[MusicStatus] Prioritizing album artwork search'); + artworkUrl = await findArtwork(releaseName, artistName, releaseName, releaseMbId) || undefined; + } + + // Priority 2: Fall back to track-based search if album search failed + if (!artworkUrl && trackName && artistName) { + console.info('[MusicStatus] Falling back to track-based artwork search'); + artworkUrl = await findArtwork(trackName, artistName, releaseName, releaseMbId) || undefined; + } + + // Priority 3: Final fallback to atproto blob if no external artwork found + if (!artworkUrl) { + const artwork = value.item?.artwork || value.artwork; + console.debug('[MusicStatus] No external artwork found, checking atproto blob:', artwork); + if (artwork?.ref?.$link) { + const identity = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); + artworkUrl = buildPdsBlobUrl(identity.pds, PUBLIC_ATPROTO_DID, artwork.ref.$link); + console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl); } } - } - - if (releaseMbId) { - // Use MusicBrainz Cover Art Archive (no API key required) - artworkUrl = buildCoverArtUrl(releaseMbId); - console.info('[MusicStatus] Using MusicBrainz artwork URL:', artworkUrl); - } else { - // Fallback to atproto blob if available - const artwork = value.item?.artwork || value.artwork; - console.debug('[MusicStatus] Artwork field:', artwork); - if (artwork?.ref?.$link) { - const identity = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn); - artworkUrl = buildPdsBlobUrl(identity.pds, PUBLIC_ATPROTO_DID, artwork.ref.$link); - console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl); - } - } const data: MusicStatusData = { trackName: value.item?.trackName || value.trackName, @@ -264,36 +260,32 @@ export async function fetchMusicStatus(fetchFn?: typeof fetch): Promise { + try { + const query = `release:"${releaseName}" AND artist:"${artistName}"`; const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; - console.info('[MusicBrainz] Searching for:', { trackName, artistName, releaseName }); + console.info('[MusicBrainz] Searching by release name:', { releaseName, artistName }); const response = await fetch(url, { headers: { @@ -46,43 +99,184 @@ export async function searchMusicBrainzRelease( } }); - if (!response.ok) { - console.warn('[MusicBrainz] Search failed:', response.status); - // Cache null result to avoid repeated failed lookups - cache.set(cacheKey, null); - return null; - } + if (!response.ok) return null; const data: MusicBrainzSearchResponse = await response.json(); - if (!data.releases || data.releases.length === 0) { - console.debug('[MusicBrainz] No releases found'); - cache.set(cacheKey, null); + if (!data.releases || data.releases.length === 0) return null; + + const bestMatch = data.releases[0]; + if (bestMatch.score < 80) { + console.debug('[MusicBrainz] Release search score too low:', bestMatch.score); return null; } - // Take the first result with a decent score (MusicBrainz uses 0-100 scale) - // We want a score of at least 80 to be reasonably confident + console.info('[MusicBrainz] Found release by album:', { + id: bestMatch.id, + title: bestMatch.title, + score: bestMatch.score + }); + + return bestMatch.id; + } catch (error) { + console.debug('[MusicBrainz] Release name search failed:', error); + return null; + } +} + +async function searchByTrackName(trackName: string, artistName: string): Promise { + try { + const query = `recording:"${trackName}" AND artist:"${artistName}"`; + const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; + + console.info('[MusicBrainz] Searching by track name:', { trackName, artistName }); + + const response = await fetch(url, { + headers: { + 'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)', + 'Accept': 'application/json' + } + }); + + if (!response.ok) return null; + + const data: MusicBrainzSearchResponse = await response.json(); + + if (!data.releases || data.releases.length === 0) return null; + const bestMatch = data.releases[0]; - if (bestMatch.score < 80) { - console.debug('[MusicBrainz] Best match score too low:', bestMatch.score); - cache.set(cacheKey, null); + if (bestMatch.score < 75) { + console.debug('[MusicBrainz] Track search score too low:', bestMatch.score); return null; } - console.info('[MusicBrainz] Found release:', { + console.info('[MusicBrainz] Found release by track:', { id: bestMatch.id, title: bestMatch.title, - artist: bestMatch['artist-credit']?.[0]?.name, score: bestMatch.score }); - // Cache for 24 hours (longer than normal cache since MB IDs don't change) - cache.set(cacheKey, bestMatch.id); return bestMatch.id; } catch (error) { - console.error('[MusicBrainz] Search error:', error); - // Don't cache errors - allow retry on next fetch + console.debug('[MusicBrainz] Track name search failed:', error); + return null; + } +} + +/** + * Search iTunes for album artwork (no API key required) + */ +export async function searchiTunesArtwork( + trackName: string, + artistName: string, + releaseName?: string +): Promise { + const cacheKey = `itunes:artwork:${trackName}:${artistName}:${releaseName || 'none'}`; + const cached = cache.get(cacheKey); + if (cached !== null) { + console.debug('[iTunes] Returning cached artwork URL:', cached); + return cached; + } + + try { + // Prefer searching by album + artist for better accuracy + const searchTerm = releaseName + ? `${releaseName} ${artistName}` + : `${trackName} ${artistName}`; + + const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`; + + console.info('[iTunes] Searching for artwork:', { searchTerm }); + + const response = await fetch(url); + if (!response.ok) { + cache.set(cacheKey, null); + return null; + } + + const data: iTunesSearchResponse = await response.json(); + + if (!data.results || data.results.length === 0) { + console.debug('[iTunes] No results found'); + cache.set(cacheKey, null); + return null; + } + + // Get the highest resolution artwork available + const result = data.results[0]; + let artworkUrl = result.artworkUrl100; + + if (artworkUrl) { + // iTunes allows upsizing artwork by modifying the URL + // Replace 100x100 with 600x600 for better quality + artworkUrl = artworkUrl.replace('100x100', '600x600'); + console.info('[iTunes] Found artwork:', artworkUrl); + cache.set(cacheKey, artworkUrl); + return artworkUrl; + } + + cache.set(cacheKey, null); + return null; + } catch (error) { + console.error('[iTunes] Search error:', error); + return null; + } +} + +/** + * Search Deezer for album artwork (no API key required) + * Note: Deezer API has CORS restrictions, so this may not work in all browsers + */ +export async function searchDeezerArtwork( + trackName: string, + artistName: string, + releaseName?: string +): Promise { + const cacheKey = `deezer:artwork:${trackName}:${artistName}:${releaseName || 'none'}`; + const cached = cache.get(cacheKey); + if (cached !== null) { + console.debug('[Deezer] Returning cached artwork URL:', cached); + return cached; + } + + try { + // Prefer album search if available + const searchTerm = releaseName || trackName; + // Use CORS proxy or skip Deezer due to CORS restrictions + const url = `https://api.deezer.com/search/album?q=artist:"${encodeURIComponent(artistName)}" album:"${encodeURIComponent(searchTerm)}"&limit=5&output=jsonp`; + + console.info('[Deezer] Searching for artwork:', { searchTerm, artistName }); + + const response = await fetch(url); + if (!response.ok) { + cache.set(cacheKey, null); + return null; + } + + const data: DeezerSearchResponse = await response.json(); + + if (!data.data || data.data.length === 0) { + console.debug('[Deezer] No results found'); + cache.set(cacheKey, null); + return null; + } + + // Use the highest quality artwork available + const result = data.data[0]; + const artworkUrl = result.cover_xl || result.cover_big || result.cover_medium; + + if (artworkUrl) { + console.info('[Deezer] Found artwork:', artworkUrl); + cache.set(cacheKey, artworkUrl); + return artworkUrl; + } + + cache.set(cacheKey, null); + return null; + } catch (error) { + // Deezer has CORS issues, so we'll skip it silently + console.debug('[Deezer] Skipped due to CORS restrictions'); + cache.set(cacheKey, null); return null; } } @@ -93,3 +287,111 @@ export async function searchMusicBrainzRelease( export function buildCoverArtUrl(releaseMbId: string, size: 250 | 500 | 1200 = 500): string { return `https://coverartarchive.org/release/${releaseMbId}/front-${size}`; } + +/** + * Search Last.fm for album artwork (no API key required for album art) + * Uses Last.fm's direct image URLs based on artist and album + */ +export async function searchLastFmArtwork( + trackName: string, + artistName: string, + releaseName?: string +): Promise { + const cacheKey = `lastfm:artwork:${trackName}:${artistName}:${releaseName || 'none'}`; + const cached = cache.get(cacheKey); + if (cached !== null) { + console.debug('[Last.fm] Returning cached artwork URL:', cached); + return cached; + } + + if (!releaseName) { + return null; // Last.fm method needs album name + } + + try { + // Last.fm has a public API for album info without authentication + const url = `https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=8de8b91ab0c3f8d08a35c33bf0e0e803&artist=${encodeURIComponent(artistName)}&album=${encodeURIComponent(releaseName)}&format=json`; + + console.info('[Last.fm] Searching for artwork:', { artistName, releaseName }); + + const response = await fetch(url); + if (!response.ok) { + cache.set(cacheKey, null); + return null; + } + + const data: any = await response.json(); + + if (!data.album?.image) { + console.debug('[Last.fm] No artwork found'); + cache.set(cacheKey, null); + return null; + } + + // Get the largest image available + const images = data.album.image; + const largeImage = images.find((img: any) => img.size === 'extralarge') || + images.find((img: any) => img.size === 'large') || + images.find((img: any) => img.size === 'medium'); + + if (largeImage?.['#text']) { + const artworkUrl = largeImage['#text']; + console.info('[Last.fm] Found artwork:', artworkUrl); + cache.set(cacheKey, artworkUrl); + return artworkUrl; + } + + cache.set(cacheKey, null); + return null; + } catch (error) { + console.debug('[Last.fm] Search error:', error); + cache.set(cacheKey, null); + return null; + } +} + +/** + * Cascading artwork search using server-side API endpoint + * This solves CORS issues by proxying requests through our server + * Tries: MusicBrainz โ†’ iTunes โ†’ Deezer โ†’ Last.fm + */ +export async function findArtwork( + trackName: string, + artistName: string, + releaseName?: string, + releaseMbId?: string +): Promise { + try { + // Build query parameters + const params = new URLSearchParams({ + trackName, + artistName + }); + + if (releaseName) params.set('releaseName', releaseName); + if (releaseMbId) params.set('releaseMbId', releaseMbId); + + console.info('[Artwork] Fetching via server API:', { trackName, artistName, releaseName, releaseMbId }); + + // Call our server-side API endpoint + const response = await fetch(`/api/artwork?${params.toString()}`); + + if (!response.ok) { + console.error('[Artwork] API request failed:', response.status); + return null; + } + + const data = await response.json(); + + if (data.artworkUrl) { + console.info('[Artwork] Found via', data.source, ':', data.artworkUrl); + return data.artworkUrl; + } + + console.warn('[Artwork] No artwork found from any source'); + return null; + } catch (error) { + console.error('[Artwork] Server API error:', error); + return null; + } +} diff --git a/src/routes/api/artwork/+server.ts b/src/routes/api/artwork/+server.ts new file mode 100644 index 0000000..18a542e --- /dev/null +++ b/src/routes/api/artwork/+server.ts @@ -0,0 +1,355 @@ +/** + * Server-side artwork fetching API endpoint + * Solves CORS issues by proxying requests through the server + * Includes intelligent caching to reduce external API calls + */ + +import { json, error } from '@sveltejs/kit'; +import { PUBLIC_SITE_URL, PUBLIC_SITE_TITLE } from '$env/static/public'; +import type { RequestHandler } from './$types'; + +interface CacheEntry { + data: T; + timestamp: number; +} + +interface ArtworkResult { + artworkUrl: string | null; + source: string | null; + mbId?: string; +} + +/** + * Simple in-memory cache for artwork lookups + * Cache TTL: 1 hour (artwork URLs are stable) + */ +class ArtworkCache { + private cache = new Map>(); + private readonly TTL = 60 * 60 * 1000; // 1 hour + + get(key: string): ArtworkResult | null { + const entry = this.cache.get(key); + if (!entry) return null; + + if (Date.now() - entry.timestamp > this.TTL) { + this.cache.delete(key); + return null; + } + + console.log('[Artwork Cache] Hit:', key); + return entry.data; + } + + set(key: string, data: ArtworkResult): void { + this.cache.set(key, { + data, + timestamp: Date.now() + }); + console.log('[Artwork Cache] Set:', key); + } +} + +const artworkCache = new ArtworkCache(); + +interface MusicBrainzRelease { + id: string; + score: number; + title: string; + 'artist-credit'?: Array<{ name: string }>; +} + +interface MusicBrainzSearchResponse { + releases: MusicBrainzRelease[]; +} + +interface iTunesResult { + artworkUrl100?: string; +} + +interface iTunesSearchResponse { + resultCount: number; + results: iTunesResult[]; +} + +interface DeezerAlbum { + cover_medium?: string; + cover_big?: string; + cover_xl?: string; +} + +interface DeezerSearchResponse { + data: DeezerAlbum[]; +} + +/** + * Search MusicBrainz for release ID + */ +async function searchMusicBrainz( + trackName: string, + artistName: string, + releaseName?: string +): Promise { + try { + // Try by release name first if available + if (releaseName) { + const query = `release:"${releaseName}" AND artist:"${artistName}"`; + const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; + + const response = await fetch(url, { + headers: { + 'User-Agent': `${PUBLIC_SITE_TITLE}/1.0.0 (${PUBLIC_SITE_URL})`, + Accept: 'application/json' + } + }); + + if (response.ok) { + const data: MusicBrainzSearchResponse = await response.json(); + if (data.releases?.[0]?.score >= 80) { + return data.releases[0].id; + } + } + } + + // Fallback to track name search + const query = `recording:"${trackName}" AND artist:"${artistName}"`; + const url = `https://musicbrainz.org/ws/2/release/?query=${encodeURIComponent(query)}&fmt=json&limit=5`; + + const response = await fetch(url, { + headers: { + 'User-Agent': `${PUBLIC_SITE_TITLE}/1.0.0 (${PUBLIC_SITE_URL})`, + Accept: 'application/json' + } + }); + + if (response.ok) { + const data: MusicBrainzSearchResponse = await response.json(); + if (data.releases?.[0]?.score >= 75) { + return data.releases[0].id; + } + } + } catch (err) { + console.error('[MusicBrainz] Search failed:', err); + } + + return null; +} + +/** + * Search iTunes for artwork + */ +async function searchiTunes( + trackName: string, + artistName: string, + releaseName?: string +): Promise { + try { + const searchTerm = releaseName + ? `${releaseName} ${artistName}` + : `${trackName} ${artistName}`; + + const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`; + + const response = await fetch(url); + if (!response.ok) return null; + + const data: iTunesSearchResponse = await response.json(); + + if (data.results?.[0]?.artworkUrl100) { + // Upscale to 600x600 + return data.results[0].artworkUrl100.replace('100x100', '600x600'); + } + } catch (err) { + console.error('[iTunes] Search failed:', err); + } + + return null; +} + +/** + * Search Deezer for artwork (works server-side, no CORS issues) + */ +async function searchDeezer( + trackName: string, + artistName: string, + releaseName?: string +): Promise { + try { + const searchTerm = releaseName || trackName; + const url = `https://api.deezer.com/search/album?q=artist:"${encodeURIComponent(artistName)}" album:"${encodeURIComponent(searchTerm)}"&limit=5`; + + const response = await fetch(url); + if (!response.ok) return null; + + const data: DeezerSearchResponse = await response.json(); + + if (data.data?.[0]) { + const result = data.data[0]; + return result.cover_xl || result.cover_big || result.cover_medium || null; + } + } catch (err) { + console.error('[Deezer] Search failed:', err); + } + + return null; +} + +/** + * Search Last.fm for artwork + */ +async function searchLastFm( + artistName: string, + releaseName?: string +): Promise { + if (!releaseName) return null; + + try { + const url = `https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=8de8b91ab0c3f8d08a35c33bf0e0e803&artist=${encodeURIComponent(artistName)}&album=${encodeURIComponent(releaseName)}&format=json`; + + const response = await fetch(url); + if (!response.ok) return null; + + const data: any = await response.json(); + + if (data.album?.image) { + const images = data.album.image; + const largeImage = + images.find((img: any) => img.size === 'extralarge') || + images.find((img: any) => img.size === 'large') || + images.find((img: any) => img.size === 'medium'); + + return largeImage?.['#text'] || null; + } + } catch (err) { + console.error('[Last.fm] Search failed:', err); + } + + return null; +} + +/** + * GET /api/artwork + * Query params: trackName, artistName, releaseName?, releaseMbId? + * + * Features: + * - Intelligent caching (1 hour TTL) + * - Multiple fallback sources (MusicBrainz, iTunes, Deezer, Last.fm) + * - HTTP caching headers for client-side caching + */ +export const GET: RequestHandler = async ({ url, setHeaders }) => { + const trackName = url.searchParams.get('trackName'); + const artistName = url.searchParams.get('artistName'); + const releaseName = url.searchParams.get('releaseName') || undefined; + const releaseMbId = url.searchParams.get('releaseMbId') || undefined; + + if (!trackName || !artistName) { + throw error(400, 'Missing required parameters: trackName and artistName'); + } + + // Create cache key from parameters + const cacheKey = `artwork:${trackName}:${artistName}:${releaseName || ''}:${releaseMbId || ''}`; + + // Check cache first + const cachedResult = artworkCache.get(cacheKey); + if (cachedResult) { + // Set cache headers for successful cached responses + if (cachedResult.artworkUrl) { + setHeaders({ + 'Cache-Control': 'public, max-age=3600', // 1 hour + 'CDN-Cache-Control': 'public, max-age=86400' // 24 hours for CDN + }); + } + return json(cachedResult); + } + + console.log('[Artwork API] Request:', { trackName, artistName, releaseName, releaseMbId }); + + let result: ArtworkResult; + + // If we have a MusicBrainz ID, use it directly + if (releaseMbId) { + const artworkUrl = `https://coverartarchive.org/release/${releaseMbId}/front-500`; + result = { + artworkUrl, + source: 'musicbrainz-direct' + }; + artworkCache.set(cacheKey, result); + setHeaders({ + 'Cache-Control': 'public, max-age=3600', + 'CDN-Cache-Control': 'public, max-age=86400' + }); + return json(result); + } + + // Try to find MusicBrainz ID + const mbId = await searchMusicBrainz(trackName, artistName, releaseName); + if (mbId) { + const artworkUrl = `https://coverartarchive.org/release/${mbId}/front-500`; + result = { + artworkUrl, + source: 'musicbrainz', + mbId + }; + artworkCache.set(cacheKey, result); + setHeaders({ + 'Cache-Control': 'public, max-age=3600', + 'CDN-Cache-Control': 'public, max-age=86400' + }); + return json(result); + } + + // Fallback to iTunes + const iTunesUrl = await searchiTunes(trackName, artistName, releaseName); + if (iTunesUrl) { + result = { + artworkUrl: iTunesUrl, + source: 'itunes' + }; + artworkCache.set(cacheKey, result); + setHeaders({ + 'Cache-Control': 'public, max-age=3600', + 'CDN-Cache-Control': 'public, max-age=86400' + }); + return json(result); + } + + // Fallback to Deezer (works server-side!) + const deezerUrl = await searchDeezer(trackName, artistName, releaseName); + if (deezerUrl) { + result = { + artworkUrl: deezerUrl, + source: 'deezer' + }; + artworkCache.set(cacheKey, result); + setHeaders({ + 'Cache-Control': 'public, max-age=3600', + 'CDN-Cache-Control': 'public, max-age=86400' + }); + return json(result); + } + + // Fallback to Last.fm + const lastFmUrl = await searchLastFm(artistName, releaseName); + if (lastFmUrl) { + result = { + artworkUrl: lastFmUrl, + source: 'lastfm' + }; + artworkCache.set(cacheKey, result); + setHeaders({ + 'Cache-Control': 'public, max-age=3600', + 'CDN-Cache-Control': 'public, max-age=86400' + }); + return json(result); + } + + // No artwork found - cache negative result with shorter TTL + result = { + artworkUrl: null, + source: null + }; + artworkCache.set(cacheKey, result); + setHeaders({ + 'Cache-Control': 'public, max-age=300' // 5 minutes for not found + }); + return json(result); +};