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,
|
||||
"tag": "0012_adhoc_nullable_channel",
|
||||
"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 { 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) {
|
||||
|
|
|
|||
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