From 22077e0eb123226af2f018db133225a9b6cbfada Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 05:12:11 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Add=20POST=20/api/v1/download/url/confi?= =?UTF-8?q?rm=20endpoint=20for=20ad-hoc=20downloa=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "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 --- src/__tests__/adhoc-download-api.test.ts | 214 +++++++++++++++++++++++ src/server/routes/adhoc-download.ts | 189 ++++++++++++++++++++ src/services/download.ts | 39 +++-- src/services/queue.ts | 23 ++- 4 files changed, 450 insertions(+), 15 deletions(-) diff --git a/src/__tests__/adhoc-download-api.test.ts b/src/__tests__/adhoc-download-api.test.ts index 26e5c40..78aa077 100644 --- a/src/__tests__/adhoc-download-api.test.ts +++ b/src/__tests__/adhoc-download-api.test.ts @@ -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; + let apiKey: string; + let tmpDir: string; + let queueService: import('../services/queue').QueueService; + let mockDownloadService: { downloadItem: ReturnType }; + + 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; + }); +}); diff --git a/src/server/routes/adhoc-download.ts b/src/server/routes/adhoc-download.ts index 874b152..6d8940c 100644 --- a/src/server/routes/adhoc-download.ts +++ b/src/server/routes/adhoc-download.ts @@ -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, 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 { // ── POST /api/v1/download/url/preview ── @@ -172,4 +186,179 @@ export async function adhocDownloadRoutes(fastify: FastifyInstance): Promise( + '/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', + }); + } + }, + ); } diff --git a/src/services/download.ts b/src/services/download.ts index 5698c1c..8494ac0 100644 --- a/src/services/download.ts +++ b/src/services/download.ts @@ -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 { - 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 ── diff --git a/src/services/queue.ts b/src/services/queue.ts index a1cabd0..936dc83 100644 --- a/src/services/queue.ts +++ b/src/services/queue.ts @@ -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', {