diff --git a/packages/server/src/utils/embedClassifier.ts b/packages/server/src/utils/embedClassifier.ts new file mode 100644 index 00000000..65d845f0 --- /dev/null +++ b/packages/server/src/utils/embedClassifier.ts @@ -0,0 +1,108 @@ +import type { EmbedType, EmbedProvider } from '@backspace/shared'; + +export interface EmbedClassification { + embedType: EmbedType; + provider: EmbedProvider | null; + embedUrl: string | null; + needsMetadataFetch: boolean; +} + +function extractYouTubeId(url: URL): string | null { + const host = url.hostname.replace(/^www\./, ''); + + if (host === 'youtu.be') { + const id = url.pathname.slice(1).split('/')[0]; + return id || null; + } + + if (host === 'youtube.com' || host === 'm.youtube.com') { + const path = url.pathname; + + // /watch?v=ID + if (path === '/watch') { + return url.searchParams.get('v'); + } + + // /shorts/ID + const shortsMatch = path.match(/^\/shorts\/([A-Za-z0-9_-]+)/); + if (shortsMatch) return shortsMatch[1]; + + // /embed/ID + const embedMatch = path.match(/^\/embed\/([A-Za-z0-9_-]+)/); + if (embedMatch) return embedMatch[1]; + + // /v/ID (legacy) + const vMatch = path.match(/^\/v\/([A-Za-z0-9_-]+)/); + if (vMatch) return vMatch[1]; + } + + return null; +} + +export function classifyUrl(rawUrl: string): EmbedClassification { + // Direct media extension checks + if (/\.(jpg|jpeg|png|gif|webp|avif)(\?.*)?$/i.test(rawUrl)) { + return { embedType: 'image', provider: null, embedUrl: null, needsMetadataFetch: false }; + } + + if (/\.(mp3|ogg|wav|flac|opus)(\?.*)?$/i.test(rawUrl)) { + return { embedType: 'audio', provider: null, embedUrl: null, needsMetadataFetch: false }; + } + + if (/\.(mp4|webm|mov)(\?.*)?$/i.test(rawUrl)) { + return { embedType: 'video', provider: null, embedUrl: null, needsMetadataFetch: false }; + } + + // Provider matching requires a valid URL + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return { embedType: 'generic', provider: null, embedUrl: null, needsMetadataFetch: true }; + } + + const host = url.hostname.replace(/^www\./, ''); + + // YouTube + if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtu.be') { + const videoId = extractYouTubeId(url); + if (videoId) { + return { + embedType: 'video', + provider: 'youtube', + embedUrl: `https://www.youtube-nocookie.com/embed/${videoId}`, + needsMetadataFetch: true, + }; + } + } + + // Vimeo + if (host === 'vimeo.com') { + const match = url.pathname.match(/^\/(\d+)/); + if (match) { + return { + embedType: 'video', + provider: 'vimeo', + embedUrl: `https://player.vimeo.com/video/${match[1]}`, + needsMetadataFetch: true, + }; + } + } + + // Spotify + if (host === 'open.spotify.com') { + const match = url.pathname.match(/^\/(track|album|playlist)\/([A-Za-z0-9]+)/); + if (match) { + const [, type, id] = match; + return { + embedType: 'rich', + provider: 'spotify', + embedUrl: `https://open.spotify.com/embed/${type}/${id}`, + needsMetadataFetch: true, + }; + } + } + + // Everything else + return { embedType: 'generic', provider: null, embedUrl: null, needsMetadataFetch: true }; +}