- "src/server/routes/adhoc-download.ts" - "src/__tests__/adhoc-download-api.test.ts" - "src/server/index.ts" - "drizzle/0013_flat_lady_deathstrike.sql" GSD-Task: S01/T02
175 lines
5.3 KiB
TypeScript
175 lines
5.3 KiB
TypeScript
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<string, unknown>): 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<string, unknown>, 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<void> {
|
|
// ── 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<string, unknown>;
|
|
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',
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|