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
This commit is contained in:
parent
8150b1f6cf
commit
373a2ee649
6 changed files with 1516 additions and 0 deletions
29
drizzle/0013_flat_lady_deathstrike.sql
Normal file
29
drizzle/0013_flat_lady_deathstrike.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_content_items` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`channel_id` integer,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`platform_content_id` text NOT NULL,
|
||||||
|
`url` text NOT NULL,
|
||||||
|
`content_type` text NOT NULL,
|
||||||
|
`duration` integer,
|
||||||
|
`file_path` text,
|
||||||
|
`file_size` integer,
|
||||||
|
`format` text,
|
||||||
|
`quality_metadata` text,
|
||||||
|
`status` text DEFAULT 'monitored' NOT NULL,
|
||||||
|
`thumbnail_url` text,
|
||||||
|
`published_at` text,
|
||||||
|
`downloaded_at` text,
|
||||||
|
`monitored` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||||
|
`updated_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||||
|
FOREIGN KEY (`channel_id`) REFERENCES `channels`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_content_items`("id", "channel_id", "title", "platform_content_id", "url", "content_type", "duration", "file_path", "file_size", "format", "quality_metadata", "status", "thumbnail_url", "published_at", "downloaded_at", "monitored", "created_at", "updated_at") SELECT "id", "channel_id", "title", "platform_content_id", "url", "content_type", "duration", "file_path", "file_size", "format", "quality_metadata", "status", "thumbnail_url", "published_at", "downloaded_at", "monitored", "created_at", "updated_at" FROM `content_items`;--> statement-breakpoint
|
||||||
|
DROP TABLE `content_items`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_content_items` RENAME TO `content_items`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
ALTER TABLE `format_profiles` ADD `embed_thumbnail` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `format_profiles` ADD `sponsor_block_remove` text;
|
||||||
1020
drizzle/meta/0013_snapshot.json
Normal file
1020
drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -92,6 +92,13 @@
|
||||||
"when": 1775520000000,
|
"when": 1775520000000,
|
||||||
"tag": "0012_adhoc_nullable_channel",
|
"tag": "0012_adhoc_nullable_channel",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775279021003,
|
||||||
|
"tag": "0013_flat_lady_deathstrike",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
283
src/__tests__/adhoc-download-api.test.ts
Normal file
283
src/__tests__/adhoc-download-api.test.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { type FastifyInstance } from 'fastify';
|
||||||
|
import { initDatabaseAsync, closeDatabase } from '../db/index';
|
||||||
|
import { runMigrations } from '../db/migrate';
|
||||||
|
import { buildServer } from '../server/index';
|
||||||
|
import { systemConfig } from '../db/schema/index';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { type LibSQLDatabase } from 'drizzle-orm/libsql';
|
||||||
|
import type * as schema from '../db/schema/index';
|
||||||
|
|
||||||
|
// Mock yt-dlp module before imports
|
||||||
|
vi.mock('../sources/yt-dlp', () => {
|
||||||
|
const YtDlpError = class YtDlpError extends Error {
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number;
|
||||||
|
isRateLimit: boolean;
|
||||||
|
category: string;
|
||||||
|
constructor(message: string, stderr: string, exitCode: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'YtDlpError';
|
||||||
|
this.stderr = stderr;
|
||||||
|
this.exitCode = exitCode;
|
||||||
|
this.isRateLimit = stderr.toLowerCase().includes('429') || stderr.toLowerCase().includes('too many requests');
|
||||||
|
// Minimal classify
|
||||||
|
const lower = stderr.toLowerCase();
|
||||||
|
if (this.isRateLimit) this.category = 'rate_limit';
|
||||||
|
else if (lower.includes('private video') || lower.includes('video unavailable')) this.category = 'private';
|
||||||
|
else if (lower.includes('not available in your country')) this.category = 'geo_blocked';
|
||||||
|
else if (lower.includes('connection') || lower.includes('timed out')) this.category = 'network';
|
||||||
|
else this.category = 'unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
execYtDlp: vi.fn(),
|
||||||
|
parseSingleJson: vi.fn((stdout: string) => JSON.parse(stdout.trim())),
|
||||||
|
YtDlpError,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { execYtDlp, YtDlpError } from '../sources/yt-dlp';
|
||||||
|
|
||||||
|
const mockExecYtDlp = vi.mocked(execYtDlp);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for the ad-hoc URL preview endpoint.
|
||||||
|
*/
|
||||||
|
describe('Adhoc Download API - URL Preview', () => {
|
||||||
|
let server: FastifyInstance;
|
||||||
|
let db: LibSQLDatabase<typeof schema>;
|
||||||
|
let apiKey: string;
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-adhoc-'));
|
||||||
|
const dbPath = join(tmpDir, 'test.db');
|
||||||
|
db = await initDatabaseAsync(dbPath);
|
||||||
|
await runMigrations(dbPath);
|
||||||
|
server = await buildServer({ db });
|
||||||
|
|
||||||
|
// Fetch API key
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(systemConfig)
|
||||||
|
.where(eq(systemConfig.key, 'api_key'));
|
||||||
|
apiKey = rows[0]?.value ?? 'test-key';
|
||||||
|
|
||||||
|
await server.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
closeDatabase();
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Happy Path ──
|
||||||
|
|
||||||
|
it('should return metadata for a valid YouTube URL', async () => {
|
||||||
|
const ytMetadata = {
|
||||||
|
id: 'dQw4w9WgXcQ',
|
||||||
|
title: 'Rick Astley - Never Gonna Give You Up',
|
||||||
|
thumbnail: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
|
||||||
|
duration: 212,
|
||||||
|
extractor_key: 'Youtube',
|
||||||
|
webpage_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
channel: 'Rick Astley',
|
||||||
|
vcodec: 'avc1.640028',
|
||||||
|
is_live: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockExecYtDlp.mockResolvedValueOnce({
|
||||||
|
stdout: JSON.stringify(ytMetadata),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.title).toBe('Rick Astley - Never Gonna Give You Up');
|
||||||
|
expect(body.platform).toBe('youtube');
|
||||||
|
expect(body.contentType).toBe('video');
|
||||||
|
expect(body.platformContentId).toBe('dQw4w9WgXcQ');
|
||||||
|
expect(body.duration).toBe(212);
|
||||||
|
expect(body.thumbnail).toBe('https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg');
|
||||||
|
expect(body.channelName).toBe('Rick Astley');
|
||||||
|
expect(body.url).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return metadata for a SoundCloud URL', async () => {
|
||||||
|
const scMetadata = {
|
||||||
|
id: '12345',
|
||||||
|
title: 'Test Track',
|
||||||
|
thumbnail: 'https://i1.sndcdn.com/artworks-test.jpg',
|
||||||
|
duration: 180,
|
||||||
|
extractor_key: 'Soundcloud',
|
||||||
|
webpage_url: 'https://soundcloud.com/artist/test-track',
|
||||||
|
uploader: 'Test Artist',
|
||||||
|
vcodec: 'none',
|
||||||
|
is_live: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockExecYtDlp.mockResolvedValueOnce({
|
||||||
|
stdout: JSON.stringify(scMetadata),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://soundcloud.com/artist/test-track' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.platform).toBe('soundcloud');
|
||||||
|
expect(body.contentType).toBe('audio');
|
||||||
|
expect(body.channelName).toBe('Test Artist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect livestream content type', async () => {
|
||||||
|
const liveMetadata = {
|
||||||
|
id: 'abc123',
|
||||||
|
title: 'Live Stream',
|
||||||
|
thumbnail: null,
|
||||||
|
duration: null,
|
||||||
|
extractor_key: 'Youtube',
|
||||||
|
webpage_url: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
channel: 'Streamer',
|
||||||
|
is_live: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockExecYtDlp.mockResolvedValueOnce({
|
||||||
|
stdout: JSON.stringify(liveMetadata),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://www.youtube.com/watch?v=abc123' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().contentType).toBe('livestream');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Validation Errors ──
|
||||||
|
|
||||||
|
it('should reject missing URL', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-HTTP URL', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'ftp://example.com/file.mp4' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().message).toContain('valid HTTP or HTTPS URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject empty string URL', async () => {
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Error Handling ──
|
||||||
|
|
||||||
|
it('should return 429 on rate limit', async () => {
|
||||||
|
mockExecYtDlp.mockRejectedValueOnce(
|
||||||
|
new YtDlpError('yt-dlp error', 'HTTP Error 429: Too Many Requests', 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://www.youtube.com/watch?v=test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(429);
|
||||||
|
expect(res.json().message).toContain('Rate limited');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 422 for private/unavailable content', async () => {
|
||||||
|
mockExecYtDlp.mockRejectedValueOnce(
|
||||||
|
new YtDlpError('yt-dlp error', 'ERROR: Private video', 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://www.youtube.com/watch?v=private123' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(422);
|
||||||
|
expect(res.json().message).toContain('not accessible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 502 for network errors', async () => {
|
||||||
|
mockExecYtDlp.mockRejectedValueOnce(
|
||||||
|
new YtDlpError('yt-dlp error', 'ERROR: Unable to download - connection timed out', 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://www.youtube.com/watch?v=test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(502);
|
||||||
|
expect(res.json().message).toContain('Failed to reach');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 422 for unsupported URLs', async () => {
|
||||||
|
mockExecYtDlp.mockRejectedValueOnce(
|
||||||
|
new YtDlpError('yt-dlp error', 'ERROR: Unsupported URL', 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await server.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/download/url/preview',
|
||||||
|
headers: { 'x-api-key': apiKey },
|
||||||
|
payload: { url: 'https://example.com/not-a-video' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(422);
|
||||||
|
expect(res.json().message).toContain('Could not resolve metadata');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -22,6 +22,7 @@ import { platformSettingsRoutes } from './routes/platform-settings';
|
||||||
import { scanRoutes } from './routes/scan';
|
import { scanRoutes } from './routes/scan';
|
||||||
import { collectRoutes } from './routes/collect';
|
import { collectRoutes } from './routes/collect';
|
||||||
import { playlistRoutes } from './routes/playlist';
|
import { playlistRoutes } from './routes/playlist';
|
||||||
|
import { adhocDownloadRoutes } from './routes/adhoc-download';
|
||||||
import { websocketRoutes } from './routes/websocket';
|
import { websocketRoutes } from './routes/websocket';
|
||||||
import type { SchedulerService } from '../services/scheduler';
|
import type { SchedulerService } from '../services/scheduler';
|
||||||
import type { DownloadService } from '../services/download';
|
import type { DownloadService } from '../services/download';
|
||||||
|
|
@ -109,6 +110,7 @@ export async function buildServer(opts: BuildServerOptions): Promise<FastifyInst
|
||||||
await server.register(scanRoutes);
|
await server.register(scanRoutes);
|
||||||
await server.register(collectRoutes);
|
await server.register(collectRoutes);
|
||||||
await server.register(playlistRoutes);
|
await server.register(playlistRoutes);
|
||||||
|
await server.register(adhocDownloadRoutes);
|
||||||
|
|
||||||
// Register WebSocket route (before static file serving so /ws is handled)
|
// Register WebSocket route (before static file serving so /ws is handled)
|
||||||
if (opts.eventBus) {
|
if (opts.eventBus) {
|
||||||
|
|
|
||||||
175
src/server/routes/adhoc-download.ts
Normal file
175
src/server/routes/adhoc-download.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue