From 52a6bce8153d07535edeb416c5d22a99d10f3eb6 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sat, 14 Feb 2026 12:29:04 +0000 Subject: [PATCH] feat: add downloadAndUploadImage utility for YouTube thumbnails Co-Authored-By: Claude Opus 4.6 --- src/lib/utils/media-download.ts | 76 +++++++++++++++++++++ tests/unit/media-download.unit.spec.ts | 95 ++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 src/lib/utils/media-download.ts create mode 100644 tests/unit/media-download.unit.spec.ts diff --git a/src/lib/utils/media-download.ts b/src/lib/utils/media-download.ts new file mode 100644 index 0000000..df61d89 --- /dev/null +++ b/src/lib/utils/media-download.ts @@ -0,0 +1,76 @@ +import type { Payload } from 'payload' + +interface DownloadOptions { + url: string + filename: string + alt?: string + tenantId?: number +} + +/** + * Downloads an image from a URL and creates a Payload Media document. + * + * - Checks for existing media with same filename first (deduplication) + * - Returns the Media document ID on success, null on failure + * - Never throws — errors are logged and null is returned + */ +export async function downloadAndUploadImage( + payload: Payload, + options: DownloadOptions, +): Promise { + const { url, filename, alt, tenantId } = options + + try { + // Check for existing media with same filename + const existing = await payload.find({ + collection: 'media', + where: { + filename: { equals: filename }, + ...(tenantId ? { tenant: { equals: tenantId } } : {}), + }, + limit: 1, + depth: 0, + }) + + if (existing.docs[0]) { + return existing.docs[0].id as number + } + + // Download the image with timeout + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 10_000) + + const response = await fetch(url, { signal: controller.signal }) + clearTimeout(timeout) + + if (!response.ok) { + console.log(`[media-download] HTTP ${response.status} for ${url}`) + return null + } + + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + const contentType = response.headers.get('content-type') || 'image/jpeg' + + // Create Payload Media document + const media = await payload.create({ + collection: 'media', + data: { + alt: alt || filename.replace(/[-_]/g, ' ').replace(/\.\w+$/, ''), + ...(tenantId ? { tenant: tenantId } : {}), + }, + file: { + name: filename, + data: buffer, + mimetype: contentType, + size: buffer.length, + }, + }) + + console.log(`[media-download] Created media ${media.id} from ${url}`) + return media.id as number + } catch (error) { + console.log(`[media-download] Failed to download ${url}:`, (error as Error).message) + return null + } +} diff --git a/tests/unit/media-download.unit.spec.ts b/tests/unit/media-download.unit.spec.ts new file mode 100644 index 0000000..a3f2e61 --- /dev/null +++ b/tests/unit/media-download.unit.spec.ts @@ -0,0 +1,95 @@ +// tests/unit/media-download.unit.spec.ts +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { downloadAndUploadImage } from '../../src/lib/utils/media-download' + +// Mock fetch globally +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +// Mock payload instance +const mockPayload = { + find: vi.fn(), + create: vi.fn(), +} + +describe('downloadAndUploadImage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should download image and create media document', async () => { + // Mock successful fetch + const mockBuffer = Buffer.from('fake-image-data') + mockFetch.mockResolvedValue({ + ok: true, + headers: { get: (h: string) => (h === 'content-type' ? 'image/jpeg' : '1024') }, + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }) + + // Mock no existing duplicate + mockPayload.find.mockResolvedValue({ docs: [], totalDocs: 0 }) + + // Mock media creation + mockPayload.create.mockResolvedValue({ id: 42 }) + + const result = await downloadAndUploadImage(mockPayload as any, { + url: 'https://img.youtube.com/vi/abc123/hqdefault.jpg', + filename: 'abc123_thumbnail.jpg', + alt: 'Video Thumbnail', + }) + + expect(result).toBe(42) + expect(mockFetch).toHaveBeenCalledWith( + 'https://img.youtube.com/vi/abc123/hqdefault.jpg', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + expect(mockPayload.create).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'media', + file: expect.objectContaining({ + name: 'abc123_thumbnail.jpg', + mimetype: 'image/jpeg', + }), + }), + ) + }) + + it('should return existing media ID if duplicate found', async () => { + mockPayload.find.mockResolvedValue({ + docs: [{ id: 99 }], + totalDocs: 1, + }) + + const result = await downloadAndUploadImage(mockPayload as any, { + url: 'https://img.youtube.com/vi/abc123/hqdefault.jpg', + filename: 'abc123_thumbnail.jpg', + }) + + expect(result).toBe(99) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('should return null on fetch error', async () => { + mockPayload.find.mockResolvedValue({ docs: [], totalDocs: 0 }) + mockFetch.mockRejectedValue(new Error('Network error')) + + const result = await downloadAndUploadImage(mockPayload as any, { + url: 'https://img.youtube.com/vi/bad/hqdefault.jpg', + filename: 'bad_thumbnail.jpg', + }) + + expect(result).toBeNull() + }) + + it('should return null on non-ok response', async () => { + mockPayload.find.mockResolvedValue({ docs: [], totalDocs: 0 }) + mockFetch.mockResolvedValue({ ok: false, status: 404 }) + + const result = await downloadAndUploadImage(mockPayload as any, { + url: 'https://img.youtube.com/vi/notfound/hqdefault.jpg', + filename: 'notfound_thumbnail.jpg', + }) + + expect(result).toBeNull() + }) +})