feat: add downloadAndUploadImage utility for YouTube thumbnails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-02-14 12:29:04 +00:00
parent 0691031c36
commit 52a6bce815
2 changed files with 171 additions and 0 deletions

View file

@ -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<number | null> {
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
}
}

View file

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