import { type FastifyInstance } from 'fastify'; import { execYtDlp, parseSingleJson, YtDlpError } from '../../sources/yt-dlp'; import type { Platform, ContentType } from '../../types/index'; // ── Types ── interface PreviewRequestBody { url: string; } export interface UrlPreviewResponse { title: string; thumbnail: string | null; duration: number | null; platform: string; channelName: string | null; contentType: ContentType; platformContentId: string; url: string; } // ── Helpers ── const URL_PATTERN = /^https?:\/\/.+/i; /** * Infer platform from yt-dlp extractor key / webpage_url. */ function inferPlatform(extractorKey: string, url: string): Platform { const key = (extractorKey || '').toLowerCase(); if (key.includes('youtube') || url.includes('youtube.com') || url.includes('youtu.be')) { return 'youtube'; } if (key.includes('soundcloud') || url.includes('soundcloud.com')) { return 'soundcloud'; } return 'generic'; } /** * Infer content type from yt-dlp metadata. */ function inferContentType(info: Record): ContentType { const isLive = info.is_live === true || info.was_live === true; if (isLive) return 'livestream'; // SoundCloud and audio-only extractors const extractor = String(info.extractor_key || '').toLowerCase(); if (extractor.includes('soundcloud')) return 'audio'; // Fallback: if there's no video codec, it's audio const vcodec = String(info.vcodec || 'none'); if (vcodec === 'none') return 'audio'; return 'video'; } /** * Map raw yt-dlp --dump-json output to a preview response. */ function mapToPreview(info: Record, originalUrl: string): UrlPreviewResponse { const extractorKey = String(info.extractor_key || info.extractor || ''); const webpageUrl = String(info.webpage_url || info.url || originalUrl); return { title: String(info.title || info.fulltitle || 'Untitled'), thumbnail: (info.thumbnail as string) || null, duration: typeof info.duration === 'number' ? Math.round(info.duration) : null, platform: inferPlatform(extractorKey, webpageUrl), channelName: (info.channel as string) || (info.uploader as string) || null, contentType: inferContentType(info), platformContentId: String(info.id || ''), url: webpageUrl, }; } // ── Route Plugin ── /** * Ad-hoc download route plugin. * * Registers: * POST /api/v1/download/url/preview — resolve metadata for a URL via yt-dlp */ export async function adhocDownloadRoutes(fastify: FastifyInstance): Promise { // ── POST /api/v1/download/url/preview ── fastify.post<{ Body: PreviewRequestBody }>( '/api/v1/download/url/preview', { schema: { body: { type: 'object', required: ['url'], properties: { url: { type: 'string' }, }, }, }, }, async (request, reply) => { const { url } = request.body; // Validate URL format if (!url || !URL_PATTERN.test(url)) { return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'A valid HTTP or HTTPS URL is required', }); } try { // Use --dump-json to get metadata without downloading const result = await execYtDlp( ['--dump-json', '--no-download', '--no-playlist', url], { timeout: 30_000 }, ); const info = parseSingleJson(result.stdout) as Record; const preview = mapToPreview(info, url); return reply.status(200).send(preview); } catch (err) { if (err instanceof YtDlpError) { request.log.warn( { url, category: err.category, exitCode: err.exitCode }, 'URL preview failed: %s', err.message, ); // Map error categories to HTTP status codes if (err.isRateLimit) { return reply.status(429).send({ statusCode: 429, error: 'Too Many Requests', message: 'Rate limited by platform. Try again later.', }); } if (err.category === 'private' || err.category === 'geo_blocked' || err.category === 'copyright') { return reply.status(422).send({ statusCode: 422, error: 'Unprocessable Entity', message: `Content is not accessible: ${err.category.replace('_', ' ')}`, }); } if (err.category === 'network') { return reply.status(502).send({ statusCode: 502, error: 'Bad Gateway', message: 'Failed to reach the content platform. Check the URL and try again.', }); } // Generic yt-dlp failure — likely an unsupported URL or invalid content return reply.status(422).send({ statusCode: 422, error: 'Unprocessable Entity', message: 'Could not resolve metadata for this URL. Verify it points to a supported video or audio page.', }); } // Unexpected errors request.log.error({ err, url }, 'Unexpected error during URL preview'); return reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', message: 'An unexpected error occurred while resolving URL metadata', }); } }, ); }