diff --git a/apps/web/src/features/tracks/hooks/useTrackOpenGraph.ts b/apps/web/src/features/tracks/hooks/useTrackOpenGraph.ts
new file mode 100644
index 000000000..3ac9736cc
--- /dev/null
+++ b/apps/web/src/features/tracks/hooks/useTrackOpenGraph.ts
@@ -0,0 +1,103 @@
+import { useEffect } from 'react';
+
+// Runtime injection of per-track + tags
+// on /tracks/:id. v1.0.9 W3 Day 15.
+//
+// Limitations : pure HTML scrapers (Twitterbot, Facebookexternalhit,
+// Slackbot in some modes) don't execute JS, so they read the static
+// OG tags from index.html instead of these runtime ones. Modern
+// scrapers that DO render JS (LinkedIn, recent Slack unfurls, browsing
+// users hitting the share dialog) see the per-track tags.
+//
+// Long-term : v1.1+ adds an SSR / bot-detector route on the backend
+// that serves OG-only HTML to known scraper user-agents. For v1.0
+// the embed widget itself (/embed/track/:id) carries server-rendered
+// OG tags ; this hook is the SPA-side complement.
+
+export interface TrackOpenGraphInput {
+ id: string;
+ title: string;
+ artist?: string | null;
+ coverArtURL?: string | null;
+}
+
+const META_TAGS = [
+ { selector: 'meta[property="og:title"]', attr: 'content' as const },
+ { selector: 'meta[property="og:type"]', attr: 'content' as const },
+ { selector: 'meta[property="og:url"]', attr: 'content' as const },
+ { selector: 'meta[property="og:image"]', attr: 'content' as const },
+ { selector: 'meta[property="og:audio"]', attr: 'content' as const, optional: true },
+ { selector: 'meta[name="twitter:card"]', attr: 'content' as const },
+ { selector: 'meta[name="twitter:title"]', attr: 'content' as const },
+ { selector: 'meta[name="twitter:player"]', attr: 'content' as const, optional: true },
+ { selector: 'meta[name="twitter:player:width"]', attr: 'content' as const, optional: true },
+ { selector: 'meta[name="twitter:player:height"]', attr: 'content' as const, optional: true },
+];
+
+function ensureMeta(selector: string, key: 'name' | 'property', value: string): HTMLMetaElement {
+ let el = document.head.querySelector(selector);
+ if (!el) {
+ el = document.createElement('meta');
+ el.setAttribute(key, value);
+ document.head.appendChild(el);
+ }
+ return el;
+}
+
+function ensureLink(rel: string, type: string, href: string, title?: string): HTMLLinkElement {
+ const selector = `link[rel="${rel}"][type="${type}"]`;
+ let el = document.head.querySelector(selector);
+ if (!el) {
+ el = document.createElement('link');
+ el.rel = rel;
+ el.type = type;
+ document.head.appendChild(el);
+ }
+ el.href = href;
+ if (title) el.title = title;
+ return el;
+}
+
+export function useTrackOpenGraph(track: TrackOpenGraphInput | null | undefined): void {
+ useEffect(() => {
+ if (!track) return;
+
+ const origin = window.location.origin;
+ const trackURL = `${origin}/tracks/${track.id}`;
+ const embedURL = `${origin}/embed/track/${track.id}`;
+ const oembedURL = `${origin}/oembed?url=${encodeURIComponent(trackURL)}&format=json`;
+ const titleAttr = track.artist ? `${track.title} — ${track.artist}` : track.title;
+ const coverImage = track.coverArtURL || `${origin}/icons/icon-512x512.png`;
+
+ // Open Graph + Twitter player card.
+ const setOg = (property: string, value: string) => {
+ const el = ensureMeta(`meta[property="${property}"]`, 'property', property);
+ el.setAttribute('content', value);
+ };
+ const setTwitter = (name: string, value: string) => {
+ const el = ensureMeta(`meta[name="${name}"]`, 'name', name);
+ el.setAttribute('content', value);
+ };
+ setOg('og:type', 'music.song');
+ setOg('og:title', titleAttr);
+ setOg('og:url', trackURL);
+ setOg('og:image', coverImage);
+ setTwitter('twitter:card', 'player');
+ setTwitter('twitter:title', titleAttr);
+ setTwitter('twitter:player', embedURL);
+ setTwitter('twitter:player:width', '480');
+ setTwitter('twitter:player:height', '166');
+ setTwitter('twitter:image', coverImage);
+
+ // oEmbed discovery — Twitter, Slack, Discord scan for this link.
+ ensureLink('alternate', 'application/json+oembed', oembedURL, 'Veza oEmbed');
+
+ return () => {
+ // We DON'T tear the meta tags down on unmount — leaving generic
+ // Veza OG tags from index.html in place would fight with the
+ // mount of the next page that might want its own values. The
+ // next route's effect overrides them.
+ void META_TAGS;
+ };
+ }, [track]);
+}
diff --git a/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPage.tsx b/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPage.tsx
index 275550935..19cd8ef00 100644
--- a/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPage.tsx
+++ b/apps/web/src/features/tracks/pages/track-detail-page/TrackDetailPage.tsx
@@ -11,6 +11,7 @@ import { TrackDetailPageNotFound } from './TrackDetailPageNotFound';
import { TrackDetailPageSkeleton } from './TrackDetailPageSkeleton';
import { useTrackDetailPage } from './useTrackDetailPage';
import type { TrackDetailPageProps } from './types';
+import { useTrackOpenGraph } from '@/features/tracks/hooks/useTrackOpenGraph';
export function TrackDetailPage(props?: TrackDetailPageProps) {
const trackIdOverride = props?.trackId;
@@ -37,6 +38,21 @@ export function TrackDetailPage(props?: TrackDetailPageProps) {
}
}, [track?.title]);
+ // v1.0.9 W3 Day 15: per-track OG + Twitter card + oEmbed discovery.
+ useTrackOpenGraph(
+ track
+ ? {
+ id: track.id,
+ title: track.title,
+ artist: track.artist,
+ coverArtURL:
+ (track as { cover_art_url?: string | null; coverArtPath?: string | null }).cover_art_url ??
+ (track as { coverArtPath?: string | null }).coverArtPath ??
+ null,
+ }
+ : null,
+ );
+
if (isLoading) {
return ;
}
diff --git a/tests/e2e/30-embed-and-share.spec.ts b/tests/e2e/30-embed-and-share.spec.ts
new file mode 100644
index 000000000..ab3f6bf0a
--- /dev/null
+++ b/tests/e2e/30-embed-and-share.spec.ts
@@ -0,0 +1,158 @@
+import { test, expect } from '@chromatic-com/playwright';
+import { CONFIG } from './helpers';
+
+/**
+ * v1.0.9 W3 Day 15 — embed widget + oEmbed + share token roundtrip.
+ *
+ * Tests :
+ * 30. GET /embed/track/:id renders standalone HTML with OG tags + a
+ * playable