feat: Add POST /api/v1/download/url/confirm endpoint for ad-hoc downloa…
- "src/server/routes/adhoc-download.ts" - "src/services/download.ts" - "src/services/queue.ts" - "src/__tests__/adhoc-download-api.test.ts" GSD-Task: S01/T03
This commit is contained in:
parent
373a2ee649
commit
22077e0eb1
4 changed files with 450 additions and 15 deletions
|
|
@ -281,3 +281,217 @@ describe('Adhoc Download API - URL Preview', () => {
|
|||
expect(res.json().message).toContain('Could not resolve metadata');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Confirm Endpoint Tests ──
|
||||
|
||||
/**
|
||||
* Integration tests for the ad-hoc URL confirm endpoint.
|
||||
*/
|
||||
describe('Adhoc Download API - URL Confirm', () => {
|
||||
let server: FastifyInstance;
|
||||
let db: LibSQLDatabase<typeof schema>;
|
||||
let apiKey: string;
|
||||
let tmpDir: string;
|
||||
let queueService: import('../services/queue').QueueService;
|
||||
let mockDownloadService: { downloadItem: ReturnType<typeof vi.fn> };
|
||||
|
||||
const validPayload = {
|
||||
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
title: 'Rick Astley - Never Gonna Give You Up',
|
||||
platform: 'youtube',
|
||||
platformContentId: 'dQw4w9WgXcQ',
|
||||
contentType: 'video',
|
||||
channelName: 'Rick Astley',
|
||||
duration: 212,
|
||||
thumbnailUrl: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'tubearr-adhoc-confirm-'));
|
||||
const dbPath = join(tmpDir, 'test.db');
|
||||
db = await initDatabaseAsync(dbPath);
|
||||
await runMigrations(dbPath);
|
||||
server = await buildServer({ db });
|
||||
|
||||
// Create mock download service and queue service
|
||||
mockDownloadService = {
|
||||
downloadItem: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const { QueueService } = await import('../services/queue');
|
||||
queueService = new QueueService(
|
||||
db,
|
||||
mockDownloadService as any,
|
||||
2,
|
||||
);
|
||||
// Stop auto-processing so tests stay deterministic
|
||||
queueService.stop();
|
||||
|
||||
(server as any).queueService = queueService;
|
||||
|
||||
// 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 () => {
|
||||
queueService?.stop();
|
||||
await server.close();
|
||||
closeDatabase();
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Happy Path ──
|
||||
|
||||
it('should create content item and enqueue download', async () => {
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/download/url/confirm',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
payload: validPayload,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.contentItemId).toBeTypeOf('number');
|
||||
expect(body.queueItemId).toBeTypeOf('number');
|
||||
expect(body.status).toBe('queued');
|
||||
});
|
||||
|
||||
it('should return 409 when same content is already queued', async () => {
|
||||
// Use a unique platformContentId
|
||||
const payload = { ...validPayload, platformContentId: 'unique-dedup-test' };
|
||||
|
||||
// First call succeeds
|
||||
const first = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/download/url/confirm',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
payload,
|
||||
});
|
||||
expect(first.statusCode).toBe(201);
|
||||
|
||||
// Second call should conflict
|
||||
const second = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/download/url/confirm',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
payload,
|
||||
});
|
||||
expect(second.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it('should accept download without optional fields', async () => {
|
||||
const minimal = {
|
||||
url: 'https://www.youtube.com/watch?v=minimal123',
|
||||
title: 'Minimal Test',
|
||||
platform: 'youtube',
|
||||
platformContentId: 'minimal123',
|
||||
contentType: 'video',
|
||||
};
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/download/url/confirm',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
payload: minimal,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().status).toBe('queued');
|
||||
});
|
||||
|
||||
it('should accept SoundCloud audio download', async () => {
|
||||
const payload = {
|
||||
url: 'https://soundcloud.com/artist/track',
|
||||
title: 'SC Track',
|
||||
platform: 'soundcloud',
|
||||
platformContentId: 'sc-track-123',
|
||||
contentType: 'audio',
|
||||
channelName: 'Artist',
|
||||
};
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/download/url/confirm',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
// ── Validation Errors ──
|
||||
|
||||
it('should reject invalid URL', async () => {
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/download/url/confirm',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
payload: { ...validPayload, url: 'not-a-url' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().message).toContain('valid HTTP or HTTPS URL');
|
||||
});
|
||||
|
||||
it('should reject invalid platform', async () => {
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/download/url/confirm',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
payload: { ...validPayload, platform: 'vimeo', platformContentId: 'plat-err-1' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().message).toContain('Invalid platform');
|
||||
});
|
||||
|
||||
it('should reject invalid contentType', async () => {
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/download/url/confirm',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
payload: { ...validPayload, contentType: 'podcast', platformContentId: 'ct-err-1' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().message).toContain('Invalid contentType');
|
||||
});
|
||||
|
||||
it('should reject missing required fields', async () => {
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/download/url/confirm',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
payload: { url: 'https://example.com/video' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
// ── Service Unavailable ──
|
||||
|
||||
it('should return 503 when queue service is not available', async () => {
|
||||
// Temporarily remove queue service
|
||||
const saved = (server as any).queueService;
|
||||
(server as any).queueService = null;
|
||||
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/download/url/confirm',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
payload: { ...validPayload, platformContentId: 'svc-unavail-1' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(503);
|
||||
expect(res.json().message).toContain('not available');
|
||||
|
||||
// Restore
|
||||
(server as any).queueService = saved;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { type FastifyInstance } from 'fastify';
|
||||
import { execYtDlp, parseSingleJson, YtDlpError } from '../../sources/yt-dlp';
|
||||
import { createContentItem, getContentByPlatformContentId } from '../../db/repositories/content-repository';
|
||||
import type { Platform, ContentType } from '../../types/index';
|
||||
|
||||
// ── Types ──
|
||||
|
|
@ -8,6 +9,18 @@ interface PreviewRequestBody {
|
|||
url: string;
|
||||
}
|
||||
|
||||
interface ConfirmRequestBody {
|
||||
url: string;
|
||||
title: string;
|
||||
platform: string;
|
||||
platformContentId: string;
|
||||
contentType: string;
|
||||
channelName?: string;
|
||||
duration?: number | null;
|
||||
thumbnailUrl?: string | null;
|
||||
formatProfileId?: number;
|
||||
}
|
||||
|
||||
export interface UrlPreviewResponse {
|
||||
title: string;
|
||||
thumbnail: string | null;
|
||||
|
|
@ -81,6 +94,7 @@ function mapToPreview(info: Record<string, unknown>, originalUrl: string): UrlPr
|
|||
*
|
||||
* Registers:
|
||||
* POST /api/v1/download/url/preview — resolve metadata for a URL via yt-dlp
|
||||
* POST /api/v1/download/url/confirm — create content item and enqueue download
|
||||
*/
|
||||
export async function adhocDownloadRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
// ── POST /api/v1/download/url/preview ──
|
||||
|
|
@ -172,4 +186,179 @@ export async function adhocDownloadRoutes(fastify: FastifyInstance): Promise<voi
|
|||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── POST /api/v1/download/url/confirm ──
|
||||
|
||||
fastify.post<{ Body: ConfirmRequestBody }>(
|
||||
'/api/v1/download/url/confirm',
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['url', 'title', 'platform', 'platformContentId', 'contentType'],
|
||||
properties: {
|
||||
url: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
platform: { type: 'string' },
|
||||
platformContentId: { type: 'string' },
|
||||
contentType: { type: 'string' },
|
||||
channelName: { type: 'string' },
|
||||
duration: { type: ['number', 'null'] },
|
||||
thumbnailUrl: { type: ['string', 'null'] },
|
||||
formatProfileId: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const {
|
||||
url,
|
||||
title,
|
||||
platform,
|
||||
platformContentId,
|
||||
contentType,
|
||||
channelName,
|
||||
duration,
|
||||
thumbnailUrl,
|
||||
formatProfileId,
|
||||
} = 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',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate platform
|
||||
const validPlatforms = ['youtube', 'soundcloud', 'generic'];
|
||||
if (!validPlatforms.includes(platform)) {
|
||||
return reply.status(400).send({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message: `Invalid platform: ${platform}. Must be one of: ${validPlatforms.join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate contentType
|
||||
const validContentTypes = ['video', 'audio', 'livestream'];
|
||||
if (!validContentTypes.includes(contentType)) {
|
||||
return reply.status(400).send({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message: `Invalid contentType: ${contentType}. Must be one of: ${validContentTypes.join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check queue service is available
|
||||
if (!fastify.queueService) {
|
||||
return reply.status(503).send({
|
||||
statusCode: 503,
|
||||
error: 'Service Unavailable',
|
||||
message: 'Download queue is not available. Server may still be starting.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for existing ad-hoc content item with same platformContentId
|
||||
const existing = await getContentByPlatformContentId(
|
||||
fastify.db,
|
||||
null,
|
||||
platformContentId,
|
||||
);
|
||||
|
||||
let contentItemId: number;
|
||||
|
||||
if (existing) {
|
||||
contentItemId = existing.id;
|
||||
|
||||
// If it's already queued or downloading, return conflict
|
||||
if (existing.status === 'queued' || existing.status === 'downloading') {
|
||||
return reply.status(409).send({
|
||||
statusCode: 409,
|
||||
error: 'Conflict',
|
||||
message: `This content is already ${existing.status}`,
|
||||
contentItemId: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
// If already downloaded, return conflict with info
|
||||
if (existing.status === 'downloaded') {
|
||||
return reply.status(409).send({
|
||||
statusCode: 409,
|
||||
error: 'Conflict',
|
||||
message: 'This content has already been downloaded',
|
||||
contentItemId: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Existing item in failed/monitored/ignored state — re-enqueue it
|
||||
request.log.info(
|
||||
{ contentItemId, status: existing.status },
|
||||
'Re-enqueuing existing ad-hoc content item',
|
||||
);
|
||||
} else {
|
||||
// Create new ad-hoc content item (channelId = null)
|
||||
const created = await createContentItem(fastify.db, {
|
||||
channelId: null,
|
||||
title,
|
||||
platformContentId,
|
||||
url,
|
||||
contentType: contentType as ContentType,
|
||||
duration: duration ?? null,
|
||||
thumbnailUrl: thumbnailUrl ?? null,
|
||||
status: 'monitored',
|
||||
monitored: true,
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
// Shouldn't happen since we checked above, but handle the edge case
|
||||
return reply.status(409).send({
|
||||
statusCode: 409,
|
||||
error: 'Conflict',
|
||||
message: 'Content item already exists',
|
||||
});
|
||||
}
|
||||
|
||||
contentItemId = created.id;
|
||||
request.log.info(
|
||||
{ contentItemId, platform, platformContentId },
|
||||
'Created ad-hoc content item',
|
||||
);
|
||||
}
|
||||
|
||||
// Enqueue for download
|
||||
const queueItem = await fastify.queueService.enqueue(contentItemId);
|
||||
|
||||
request.log.info(
|
||||
{ contentItemId, queueItemId: queueItem.id },
|
||||
'Ad-hoc download enqueued',
|
||||
);
|
||||
|
||||
return reply.status(201).send({
|
||||
contentItemId,
|
||||
queueItemId: queueItem.id,
|
||||
status: 'queued',
|
||||
});
|
||||
} catch (err) {
|
||||
// Handle double-enqueue from QueueService
|
||||
if (err instanceof Error && err.message.includes('already in the queue')) {
|
||||
return reply.status(409).send({
|
||||
statusCode: 409,
|
||||
error: 'Conflict',
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
request.log.error({ err, url }, 'Failed to confirm ad-hoc download');
|
||||
return reply.status(500).send({
|
||||
statusCode: 500,
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to enqueue download',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,14 +49,20 @@ export class DownloadService {
|
|||
*
|
||||
* Status transitions: monitored → downloading → downloaded | failed
|
||||
*
|
||||
* For ad-hoc downloads (no channel), pass channel as null and provide
|
||||
* adhocOverrides with at least platform. channelName defaults to 'Ad-hoc'.
|
||||
*
|
||||
* @throws YtDlpError on download failure (after updating status to 'failed')
|
||||
*/
|
||||
async downloadItem(
|
||||
contentItem: ContentItem,
|
||||
channel: Channel,
|
||||
formatProfile?: FormatProfile
|
||||
channel: Channel | null,
|
||||
formatProfile?: FormatProfile,
|
||||
adhocOverrides?: { platform?: Platform; channelName?: string }
|
||||
): Promise<ContentItem> {
|
||||
const logPrefix = `[download] item=${contentItem.id} channel="${channel.name}"`;
|
||||
const platform = channel?.platform ?? adhocOverrides?.platform ?? 'generic';
|
||||
const channelName = channel?.name ?? adhocOverrides?.channelName ?? 'Ad-hoc';
|
||||
const logPrefix = `[download] item=${contentItem.id} channel="${channelName}"`;
|
||||
|
||||
// Mark as downloading
|
||||
console.log(`${logPrefix} status=downloading`);
|
||||
|
|
@ -64,12 +70,12 @@ export class DownloadService {
|
|||
|
||||
try {
|
||||
// Acquire rate limiter for platform
|
||||
await this.rateLimiter.acquire(channel.platform as Platform);
|
||||
await this.rateLimiter.acquire(platform as Platform);
|
||||
|
||||
// Build yt-dlp args
|
||||
const outputTemplate = this.fileOrganizer.buildOutputPath(
|
||||
channel.platform,
|
||||
channel.name,
|
||||
platform,
|
||||
channelName,
|
||||
contentItem.title,
|
||||
this.guessExtension(contentItem.contentType, formatProfile)
|
||||
);
|
||||
|
|
@ -123,7 +129,7 @@ export class DownloadService {
|
|||
downloadedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
this.rateLimiter.reportSuccess(channel.platform as Platform);
|
||||
this.rateLimiter.reportSuccess(platform as Platform);
|
||||
|
||||
// Emit download:complete event
|
||||
this.eventBus?.emitDownload('download:complete', { contentItemId: contentItem.id });
|
||||
|
|
@ -135,7 +141,7 @@ export class DownloadService {
|
|||
return updated!;
|
||||
} catch (err: unknown) {
|
||||
// Report error to rate limiter
|
||||
this.rateLimiter.reportError(channel.platform as Platform);
|
||||
this.rateLimiter.reportError(platform as Platform);
|
||||
|
||||
// Classify the error for better retry decisions
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -255,7 +261,7 @@ export class DownloadService {
|
|||
*/
|
||||
private buildYtDlpArgs(
|
||||
contentItem: ContentItem,
|
||||
channel: Channel,
|
||||
channel: Channel | null,
|
||||
formatProfile: FormatProfile | undefined,
|
||||
outputTemplate: string
|
||||
): string[] {
|
||||
|
|
@ -293,9 +299,10 @@ export class DownloadService {
|
|||
args.push('--no-playlist');
|
||||
args.push('--print', 'after_move:filepath');
|
||||
|
||||
// Cookie support
|
||||
// Cookie support — use channel platform if available, fallback to contentItem URL inference
|
||||
const cookiePlatform = channel?.platform ?? this.inferPlatformFromUrl(contentItem.url);
|
||||
const cookiePath = this.cookieManager.getCookieFilePath(
|
||||
channel.platform as Platform
|
||||
cookiePlatform as Platform
|
||||
);
|
||||
if (cookiePath) {
|
||||
args.push('--cookies', cookiePath);
|
||||
|
|
@ -434,6 +441,16 @@ export class DownloadService {
|
|||
}
|
||||
return contentType === 'audio' ? 'mp3' : 'mp4';
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer a platform string from a URL for cookie lookup.
|
||||
* Used for ad-hoc downloads where no channel is available.
|
||||
*/
|
||||
private inferPlatformFromUrl(url: string): string {
|
||||
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
|
||||
if (url.includes('soundcloud.com')) return 'soundcloud';
|
||||
return 'generic';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
|
|
|||
|
|
@ -281,6 +281,15 @@ export class QueueService {
|
|||
this.processNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer platform from a URL for ad-hoc downloads.
|
||||
*/
|
||||
private inferPlatformFromUrl(url: string): string {
|
||||
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'youtube';
|
||||
if (url.includes('soundcloud.com')) return 'soundcloud';
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
// ── Internal ──
|
||||
|
||||
/**
|
||||
|
|
@ -320,11 +329,17 @@ export class QueueService {
|
|||
formatProfile = await getDefaultFormatProfile(this.db) ?? undefined;
|
||||
}
|
||||
|
||||
// Execute download — T03 will extend downloadItem to handle null channel for ad-hoc downloads
|
||||
if (!channel) {
|
||||
throw new Error(`Ad-hoc download support not yet implemented for content item ${contentItem.id}`);
|
||||
// Execute download — ad-hoc items (no channel) pass null with platform/channelName overrides
|
||||
if (channel) {
|
||||
await this.downloadService.downloadItem(contentItem, channel, formatProfile);
|
||||
} else {
|
||||
// Ad-hoc download: infer platform from URL, use stored title metadata
|
||||
const platform = this.inferPlatformFromUrl(contentItem.url);
|
||||
await this.downloadService.downloadItem(contentItem, null, formatProfile, {
|
||||
platform: platform as import('../types/index').Platform,
|
||||
channelName: 'Ad-hoc',
|
||||
});
|
||||
}
|
||||
await this.downloadService.downloadItem(contentItem, channel, formatProfile);
|
||||
|
||||
// Success — mark completed
|
||||
await updateQueueItemStatus(this.db, queueItem.id, 'completed', {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue