diff --git a/packages/tourmaline/src/lib/components/TrackPreview.svelte b/packages/tourmaline/src/lib/components/TrackPreview.svelte new file mode 100644 index 0000000..9a71991 --- /dev/null +++ b/packages/tourmaline/src/lib/components/TrackPreview.svelte @@ -0,0 +1,82 @@ + + + + + diff --git a/packages/tourmaline/src/lib/server/enrich.ts b/packages/tourmaline/src/lib/server/enrich.ts index c40d74f..9feb376 100644 --- a/packages/tourmaline/src/lib/server/enrich.ts +++ b/packages/tourmaline/src/lib/server/enrich.ts @@ -214,6 +214,56 @@ async function dzGetArtistImage(name: string): Promise { return artist?.picture_medium ?? null; } +interface DeezerTrack { + id: number; + title: string; + preview?: string; + artist?: { name: string }; +} + +interface DeezerTrackSearchResult { + data: DeezerTrack[]; + total: number; +} + +// Separate small cache — track previews are looked up individually (on +// demand, per play button) rather than as part of the batch artist +// enrichment flow, so they get their own namespace and shorter TTL (preview +// URLs are Deezer CDN links that can rotate). +const PREVIEW_CACHE_TTL = 24 * 60 * 60 * 1000; +const previewCache = new Map(); + +/** + * Look up a 30-second preview clip for a track via Deezer's public search + * API (no auth, same as the existing artist-image lookup). Returns null if + * no match is found or Deezer has no preview for it. + */ +export async function dzSearchTrackPreview( + track: string, + artist: string, +): Promise { + const key = `${track.toLowerCase()}|||${artist.toLowerCase()}`; + const cached = previewCache.get(key); + if (cached && Date.now() < cached.exp) return cached.url; + + const query = `track:"${track}" artist:"${artist}"`; + const url = `${DZ_BASE}/search/track?q=${encodeURIComponent(query)}&limit=1`; + + let previewUrl: string | null = null; + try { + const res = await fetch(url); + if (res.ok) { + const data: DeezerTrackSearchResult = await res.json(); + previewUrl = data.data?.[0]?.preview ?? null; + } + } catch (err) { + console.warn(`[tourmaline] dzSearchTrackPreview: error for ${track} — ${artist}:`, err); + } + + previewCache.set(key, { url: previewUrl, exp: Date.now() + PREVIEW_CACHE_TTL }); + return previewUrl; +} + // ── Public API ──────────────────────────────────────────────────────── export interface EnrichBatchResult { diff --git a/packages/tourmaline/src/routes/api/track-preview/+server.ts b/packages/tourmaline/src/routes/api/track-preview/+server.ts new file mode 100644 index 0000000..9d6012c --- /dev/null +++ b/packages/tourmaline/src/routes/api/track-preview/+server.ts @@ -0,0 +1,22 @@ +import { json } from "@sveltejs/kit"; +import { dzSearchTrackPreview } from "$lib/server/enrich"; +import type { RequestHandler } from "./$types"; + +const MAX_LEN = 256; + +export const GET: RequestHandler = async ({ url }) => { + const track = url.searchParams.get("track")?.trim() ?? ""; + const artist = url.searchParams.get("artist")?.trim() ?? ""; + + if (!track || !artist || track.length > MAX_LEN || artist.length > MAX_LEN) { + return json({ error: "Missing or invalid track/artist query params." }, { status: 400 }); + } + + try { + const previewUrl = await dzSearchTrackPreview(track, artist); + return json({ previewUrl }); + } catch (e) { + console.error("[tourmaline] track-preview lookup failed:", e); + return json({ error: "Preview lookup failed." }, { status: 502 }); + } +}; diff --git a/packages/tourmaline/src/routes/profile/[did]/+page.svelte b/packages/tourmaline/src/routes/profile/[did]/+page.svelte index 2498ed2..ad62e7a 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -39,6 +39,7 @@ import PunchcardHeatmap from './PunchcardHeatmap.svelte'; import EddingtonChart from './EddingtonChart.svelte'; import Recommendations from './Recommendations.svelte'; + import TrackPreview from '$lib/components/TrackPreview.svelte'; function noiseAvatar(canvas: HTMLCanvasElement, seed: string) { renderNoiseAvatar(canvas, seed, { displaySize: 32, gridSize: 5 }); @@ -453,6 +454,7 @@ {#each profile.topTracks.slice(0, 25) as track, i (i)}
  • {i + 1} + {track.name} — {track.artist}