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:
jlightner 2026-04-04 05:07:24 +00:00
parent 8150b1f6cf
commit 373a2ee649
6 changed files with 1516 additions and 0 deletions

View 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;

File diff suppressed because it is too large Load diff

View file

@ -92,6 +92,13 @@
"when": 1775520000000,
"tag": "0012_adhoc_nullable_channel",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1775279021003,
"tag": "0013_flat_lady_deathstrike",
"breakpoints": true
}
]
}

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

View file

@ -22,6 +22,7 @@ import { platformSettingsRoutes } from './routes/platform-settings';
import { scanRoutes } from './routes/scan';
import { collectRoutes } from './routes/collect';
import { playlistRoutes } from './routes/playlist';
import { adhocDownloadRoutes } from './routes/adhoc-download';
import { websocketRoutes } from './routes/websocket';
import type { SchedulerService } from '../services/scheduler';
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(collectRoutes);
await server.register(playlistRoutes);
await server.register(adhocDownloadRoutes);
// Register WebSocket route (before static file serving so /ws is handled)
if (opts.eventBus) {

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