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:
jlightner 2026-04-04 05:12:11 +00:00
parent 373a2ee649
commit 22077e0eb1
4 changed files with 450 additions and 15 deletions

View file

@ -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;
});
});

View file

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

View file

@ -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 ──

View file

@ -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', {