tubearr/src/server/routes/adhoc-download.ts
jlightner 373a2ee649 test: Created POST /api/v1/download/url/preview endpoint that resolves…
- "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
2026-04-04 05:07:24 +00:00

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',
});
}
},
);
}