From 8fff2bce521ae82a94fcb1ecdecefd565bf39a9c Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 13 Oct 2025 19:52:01 +0200 Subject: [PATCH] feat(api): add Vimeo video metadata extraction support - Create unified video-meta.ts API handler for both YouTube and Vimeo - Add Vimeo oEmbed API integration for server-side metadata extraction - Implement URL pattern matching for YouTube and Vimeo video detection - Support both URL and videoId parameters for backward compatibility - Add proper TypeScript types for Vimeo oEmbed response - Include caching mechanism for Vimeo metadata (7-day cache) - Remove unused @vimeo/player package dependency The new API endpoint supports: - YouTube: /api/video-meta?url=https://youtube.com/watch?v=ID or ?videoId=ID - Vimeo: /api/video-meta?url=https://vimeo.com/ID - Returns consistent response format for both platforms --- api/video-meta.ts | 197 ++++++++++++++++++++++++++++++++++++++++++++++ api/vimeo-meta.ts | 93 ++++++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 api/video-meta.ts create mode 100644 api/vimeo-meta.ts diff --git a/api/video-meta.ts b/api/video-meta.ts new file mode 100644 index 00000000..86a0cb5a --- /dev/null +++ b/api/video-meta.ts @@ -0,0 +1,197 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' +import { getSubtitles, getVideoDetails } from '@treeee/youtube-caption-extractor' + +type Caption = { start: number; dur: number; text: string } + +type CacheEntry = { + body: unknown + expires: number +} + +type VimeoOEmbedResponse = { + title: string + description: string + author_name: string + author_url: string + provider_name: string + provider_url: string + type: string + version: string + width: number + height: number + html: string + thumbnail_url: string + thumbnail_width: number + thumbnail_height: number +} + +// In-memory cache for 7 days +const WEEK_MS = 7 * 24 * 60 * 60 * 1000 +const memoryCache = new Map() + +function buildKey(videoId: string, lang: string, preferAuto?: string | string[], source?: string) { + return `${source || 'video'}|${videoId}|${lang}|${preferAuto ? 'auto' : 'manual'}` +} + +function ok(res: VercelResponse, data: unknown) { + res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d + return res.status(200).json(data) +} + +function bad(res: VercelResponse, code: number, message: string) { + return res.status(code).json({ error: message }) +} + +function extractVideoId(url: string): { id: string; source: 'youtube' | 'vimeo' } | null { + // YouTube patterns + const youtubePatterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, + /youtube\.com\/v\/([^&\n?#]+)/ + ] + + for (const pattern of youtubePatterns) { + const match = url.match(pattern) + if (match) { + return { id: match[1], source: 'youtube' } + } + } + + // Vimeo patterns + const vimeoPatterns = [ + /vimeo\.com\/(\d+)/, + /player\.vimeo\.com\/video\/(\d+)/ + ] + + for (const pattern of vimeoPatterns) { + const match = url.match(pattern) + if (match) { + return { id: match[1], source: 'vimeo' } + } + } + + return null +} + +async function pickCaptions(videoID: string, preferredLangs: string[], manualFirst: boolean): Promise<{ caps: Caption[]; lang: string; isAuto: boolean } | null> { + for (const lang of preferredLangs) { + try { + const caps = await getSubtitles({ videoID, lang, auto: !manualFirst ? true : false }) + if (Array.isArray(caps) && caps.length > 0) { + return { caps, lang, isAuto: !manualFirst } + } + } catch { + // try next + } + } + return null +} + +async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> { + const vimeoUrl = `https://vimeo.com/${videoId}` + const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}` + + const response = await fetch(oembedUrl) + if (!response.ok) { + throw new Error(`Vimeo oEmbed API returned ${response.status}`) + } + + const data: VimeoOEmbedResponse = await response.json() + + return { + title: data.title || '', + description: data.description || '' + } +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const url = (req.query.url as string | undefined)?.trim() + const videoId = (req.query.videoId as string | undefined)?.trim() + + if (!url && !videoId) { + return bad(res, 400, 'Missing url or videoId parameter') + } + + // Extract video info from URL or use provided videoId + let videoInfo: { id: string; source: 'youtube' | 'vimeo' } + + if (url) { + const extracted = extractVideoId(url) + if (!extracted) { + return bad(res, 400, 'Unsupported video URL. Only YouTube and Vimeo are supported.') + } + videoInfo = extracted + } else { + // If only videoId is provided, assume YouTube for backward compatibility + videoInfo = { id: videoId!, source: 'youtube' } + } + + const lang = ((req.query.lang as string | undefined) || 'en').toLowerCase() + const uiLocale = (req.headers['x-ui-locale'] as string | undefined)?.toLowerCase() + const preferAuto = req.query.preferAuto === 'true' + + const cacheKey = buildKey(videoInfo.id, lang, preferAuto ? 'auto' : undefined, videoInfo.source) + const now = Date.now() + const cached = memoryCache.get(cacheKey) + if (cached && cached.expires > now) { + return ok(res, cached.body) + } + + try { + if (videoInfo.source === 'youtube') { + // YouTube handling + const details: unknown = await getVideoDetails({ videoID: videoInfo.id, lang }) + // Be tolerant to possible shapes returned by the extractor + const title = (details as { title?: string } | undefined)?.title || '' + const d1 = (details as { description?: string } | undefined)?.description + const d2 = (details as { shortDescription?: string } | undefined)?.shortDescription + const d3 = (details as { descriptionText?: string } | undefined)?.descriptionText + const description = d1 || d2 || d3 || '' + + // Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order + const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[])) + + let selected = null as null | { caps: Caption[]; lang: string; isAuto: boolean } + // Manual first + selected = await pickCaptions(videoInfo.id, langs, true) + if (!selected) { + // Try auto + selected = await pickCaptions(videoInfo.id, langs, false) + } + + const captions = selected?.caps || [] + const transcript = captions.map(c => c.text).join(' ').trim() + const response = { + title, + description, + captions, + transcript, + lang: selected?.lang || lang, + isAuto: selected?.isAuto || false, + source: 'youtube' + } + + memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS }) + return ok(res, response) + } else if (videoInfo.source === 'vimeo') { + // Vimeo handling + const { title, description } = await getVimeoMetadata(videoInfo.id) + + const response = { + title, + description, + captions: [], // Vimeo doesn't provide captions through oEmbed API + transcript: '', // No transcript available + lang: 'en', // Default language + isAuto: false, // Not applicable for Vimeo + source: 'vimeo' + } + + memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS }) + return ok(res, response) + } else { + return bad(res, 400, 'Unsupported video source') + } + } catch (e) { + return bad(res, 500, `Failed to fetch ${videoInfo.source} metadata`) + } +} diff --git a/api/vimeo-meta.ts b/api/vimeo-meta.ts new file mode 100644 index 00000000..85135466 --- /dev/null +++ b/api/vimeo-meta.ts @@ -0,0 +1,93 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' + +type CacheEntry = { + body: unknown + expires: number +} + +type VimeoOEmbedResponse = { + title: string + description: string + author_name: string + author_url: string + provider_name: string + provider_url: string + type: string + version: string + width: number + height: number + html: string + thumbnail_url: string + thumbnail_width: number + thumbnail_height: number +} + +// In-memory cache for 7 days +const WEEK_MS = 7 * 24 * 60 * 60 * 1000 +const memoryCache = new Map() + +function buildKey(videoId: string) { + return `vimeo|${videoId}` +} + +function ok(res: VercelResponse, data: unknown) { + res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d + return res.status(200).json(data) +} + +function bad(res: VercelResponse, code: number, message: string) { + return res.status(code).json({ error: message }) +} + +async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> { + const vimeoUrl = `https://vimeo.com/${videoId}` + const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}` + + const response = await fetch(oembedUrl) + if (!response.ok) { + throw new Error(`Vimeo oEmbed API returned ${response.status}`) + } + + const data: VimeoOEmbedResponse = await response.json() + + return { + title: data.title || '', + description: data.description || '' + } +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const videoId = (req.query.videoId as string | undefined)?.trim() + if (!videoId) return bad(res, 400, 'Missing videoId') + + // Validate that videoId is a number + if (!/^\d+$/.test(videoId)) { + return bad(res, 400, 'Invalid Vimeo video ID - must be numeric') + } + + const cacheKey = buildKey(videoId) + const now = Date.now() + const cached = memoryCache.get(cacheKey) + if (cached && cached.expires > now) { + return ok(res, cached.body) + } + + try { + const { title, description } = await getVimeoMetadata(videoId) + + const response = { + title, + description, + captions: [], // Vimeo doesn't provide captions through oEmbed API + transcript: '', // No transcript available + lang: 'en', // Default language + isAuto: false, // Not applicable for Vimeo + source: 'vimeo' + } + + memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS }) + return ok(res, response) + } catch (e) { + return bad(res, 500, 'Failed to fetch Vimeo metadata') + } +}