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}