# YouTube Thumbnail Download — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Automatically download YouTube video thumbnails and channel images as Payload Media documents so they're available offline and optimized by Payload's Sharp pipeline. **Architecture:** Inline download during sync flow (no separate queue). A reusable `downloadAndUploadImage()` utility fetches images from URLs and creates Payload Media documents. Integration hooks on YouTubeContent and YouTubeChannels trigger downloads. A bulk endpoint backfills existing records. **Tech Stack:** Node.js fetch API, Payload CMS Local API (`payload.create`), Sharp (via Payload's Media collection) --- ### Task 1: Create `downloadAndUploadImage` utility **Files:** - Create: `src/lib/utils/media-download.ts` - Test: `tests/unit/media-download.test.ts` **Step 1: Write the test file** ```typescript // tests/unit/media-download.test.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() }) }) ``` **Step 2: Run test to verify it fails** Run: `pnpm vitest run tests/unit/media-download.test.ts` Expected: FAIL — module `../../src/lib/utils/media-download` not found **Step 3: Write the implementation** ```typescript // src/lib/utils/media-download.ts 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 } } ``` **Step 4: Run test to verify it passes** Run: `pnpm vitest run tests/unit/media-download.test.ts` Expected: All 4 tests PASS **Step 5: Commit** ```bash git add src/lib/utils/media-download.ts tests/unit/media-download.test.ts git commit -m "feat: add downloadAndUploadImage utility for YouTube thumbnails" ``` --- ### Task 2: Add `afterChange` hook to YouTubeContent for auto-download **Files:** - Create: `src/hooks/youtubeContent/downloadThumbnail.ts` - Modify: `src/collections/YouTubeContent.ts:40-41` — add hook to afterChange array **Step 1: Write the hook** ```typescript // src/hooks/youtubeContent/downloadThumbnail.ts import type { CollectionAfterChangeHook } from 'payload' import { downloadAndUploadImage } from '../../lib/utils/media-download' import { getYouTubeThumbnail } from '../../lib/utils/youtube' /** * After a YouTubeContent document is created or updated, * automatically download the YouTube thumbnail if the thumbnail field is empty * and a YouTube videoId is present. */ export const downloadThumbnail: CollectionAfterChangeHook = async ({ doc, req, operation, }) => { // Skip if thumbnail already exists (manual upload or previous download) if (doc.thumbnail) return doc // Get the YouTube video ID const videoId = doc.youtube?.videoId if (!videoId) return doc const thumbnailUrl = getYouTubeThumbnail(videoId, 'hq') const filename = `yt-thumb-${videoId}.jpg` // Determine tenant from doc or channel const tenantId = typeof doc.tenant === 'object' ? doc.tenant?.id : doc.tenant const mediaId = await downloadAndUploadImage(req.payload, { url: thumbnailUrl, filename, alt: doc.title ? `Thumbnail: ${typeof doc.title === 'string' ? doc.title : doc.title?.de || doc.title?.en || videoId}` : `YouTube Thumbnail ${videoId}`, tenantId: tenantId || undefined, }) if (mediaId) { // Update the document with the downloaded thumbnail // Use depth: 0 to avoid triggering this hook recursively await req.payload.update({ collection: 'youtube-content', id: doc.id, data: { thumbnail: mediaId }, depth: 0, }) console.log(`[yt-thumbnail] Auto-downloaded thumbnail for video ${videoId} (media: ${mediaId})`) } return doc } ``` **Step 2: Register the hook in YouTubeContent collection** In `src/collections/YouTubeContent.ts`, add the import at the top: ```typescript import { downloadThumbnail } from '../hooks/youtubeContent/downloadThumbnail' ``` Then modify the hooks section (line ~40): ```typescript hooks: { afterChange: [createTasksOnStatusChange, downloadThumbnail], // ... rest unchanged }, ``` **Step 3: Verify the build compiles** Run: `pnpm tsc --noEmit 2>&1 | head -20` Expected: No new type errors from these files **Step 4: Commit** ```bash git add src/hooks/youtubeContent/downloadThumbnail.ts src/collections/YouTubeContent.ts git commit -m "feat: auto-download YouTube thumbnails on content create/update" ``` --- ### Task 3: Add `afterChange` hook to YouTubeChannels for channel images **Files:** - Create: `src/hooks/youtubeChannels/downloadChannelImage.ts` - Modify: `src/collections/YouTubeChannels.ts` — add hook **Step 1: Check current YouTubeChannels hooks** Read: `src/collections/YouTubeChannels.ts` — find the hooks section and the field for channel profile image URL from YouTube API. The YouTube Data API returns channel thumbnail URLs in `snippet.thumbnails`. We need to find if/where this data is stored in the collection (likely `youtube.thumbnailUrl` or similar field that holds the raw API URL). **Step 2: Write the hook** ```typescript // src/hooks/youtubeChannels/downloadChannelImage.ts import type { CollectionAfterChangeHook } from 'payload' import { downloadAndUploadImage } from '../../lib/utils/media-download' /** * After a YouTubeChannels document is created or updated, * automatically download the channel profile image if the branding.logo field is empty * and a YouTube channel thumbnail URL is available. */ export const downloadChannelImage: CollectionAfterChangeHook = async ({ doc, req, }) => { // Skip if logo already exists if (doc.branding?.logo) return doc // YouTube channels store their profile image URL — check for it // The URL typically comes from YouTube API: snippet.thumbnails.high.url const channelId = doc.youtubeChannelId if (!channelId) return doc // YouTube channel profile images follow this pattern: // https://yt3.ggpht.com/CHANNEL_IMAGE_PATH // We need the URL to be stored somewhere — check channelThumbnailUrl or similar const thumbnailUrl = doc.channelThumbnailUrl || doc.youtube?.thumbnailUrl if (!thumbnailUrl) return doc const filename = `yt-channel-${channelId}.jpg` const tenantId = typeof doc.tenant === 'object' ? doc.tenant?.id : doc.tenant const mediaId = await downloadAndUploadImage(req.payload, { url: thumbnailUrl, filename, alt: doc.name ? `Kanal: ${doc.name}` : `YouTube Channel ${channelId}`, tenantId: tenantId || undefined, }) if (mediaId) { await req.payload.update({ collection: 'youtube-channels', id: doc.id, data: { branding: { ...doc.branding, logo: mediaId } }, depth: 0, }) console.log(`[yt-thumbnail] Auto-downloaded channel image for ${channelId} (media: ${mediaId})`) } return doc } ``` **Step 3: Register the hook** Import and add to `YouTubeChannels.ts` hooks section. **Step 4: Verify build compiles** Run: `pnpm tsc --noEmit 2>&1 | head -20` **Step 5: Commit** ```bash git add src/hooks/youtubeChannels/downloadChannelImage.ts src/collections/YouTubeChannels.ts git commit -m "feat: auto-download YouTube channel images on create/update" ``` --- ### Task 4: Create bulk-import endpoint **Files:** - Create: `src/app/(payload)/api/youtube/thumbnails/bulk/route.ts` **Step 1: Write the endpoint** ```typescript // src/app/(payload)/api/youtube/thumbnails/bulk/route.ts import { NextResponse, type NextRequest } from 'next/server' import { getPayload } from 'payload' import config from '@payload-config' import { downloadAndUploadImage } from '@/lib/utils/media-download' import { getYouTubeThumbnail } from '@/lib/utils/youtube' export async function POST(req: NextRequest) { const payload = await getPayload({ config }) // Auth check — require super admin const { user } = await payload.auth({ headers: req.headers }) if (!user?.isSuperAdmin) { return NextResponse.json({ error: 'Super-Admin erforderlich' }, { status: 403 }) } const dryRun = req.nextUrl.searchParams.get('dryRun') === 'true' // Find all YouTubeContent without thumbnail that have a videoId const contentWithoutThumbnail = await payload.find({ collection: 'youtube-content', where: { thumbnail: { exists: false }, 'youtube.videoId': { exists: true }, }, limit: 500, depth: 0, }) const results = { processed: 0, downloaded: 0, skipped: 0, errors: 0, dryRun } for (const doc of contentWithoutThumbnail.docs) { results.processed++ const videoId = doc.youtube?.videoId if (!videoId) { results.skipped++ continue } if (dryRun) { results.downloaded++ continue } try { const thumbnailUrl = getYouTubeThumbnail(videoId, 'hq') const filename = `yt-thumb-${videoId}.jpg` const tenantId = typeof doc.tenant === 'object' ? doc.tenant?.id : doc.tenant const mediaId = await downloadAndUploadImage(payload, { url: thumbnailUrl, filename, alt: `Thumbnail: ${typeof doc.title === 'string' ? doc.title : doc.title?.de || videoId}`, tenantId: tenantId || undefined, }) if (mediaId) { await payload.update({ collection: 'youtube-content', id: doc.id, data: { thumbnail: mediaId }, depth: 0, }) results.downloaded++ } else { results.errors++ } } catch { results.errors++ } // Rate-limit delay (500ms between downloads) await new Promise((resolve) => setTimeout(resolve, 500)) } return NextResponse.json(results) } ``` **Step 2: Test endpoint manually** ```bash # Dry run first curl -X POST "https://pl.porwoll.tech/api/youtube/thumbnails/bulk?dryRun=true" \ -H "Cookie: payload-token=..." # Actual run curl -X POST "https://pl.porwoll.tech/api/youtube/thumbnails/bulk" \ -H "Cookie: payload-token=..." ``` **Step 3: Commit** ```bash git add "src/app/(payload)/api/youtube/thumbnails/bulk/route.ts" git commit -m "feat: add bulk YouTube thumbnail download endpoint" ``` --- ### Task 5: Update documentation **Files:** - Modify: `CLAUDE.md` — add thumbnail API endpoint to URLs section - Modify: `docs/PROJECT_STATUS.md` — mark YouTube thumbnails as completed **Step 1: Add to CLAUDE.md URLs section** Add after the Meta OAuth line: ```markdown - **YouTube Thumbnail Bulk:** https://pl.porwoll.tech/api/youtube/thumbnails/bulk (POST, Super-Admin) ``` **Step 2: Update PROJECT_STATUS.md** - Mark "YouTube Thumbnail-Download für Offline-Anzeige" as ✅ Erledigt - Add changelog entry for 14.02.2026 **Step 3: Commit** ```bash git add CLAUDE.md docs/PROJECT_STATUS.md git commit -m "docs: add YouTube thumbnail download to documentation" ``` --- ### Task 6: Final verification **Step 1: Run full test suite** Run: `pnpm vitest run` Expected: All tests pass, including new media-download tests **Step 2: Type-check** Run: `pnpm tsc --noEmit` Expected: No errors **Step 3: Lint** Run: `pnpm lint` Expected: No new errors **Step 4: Push to develop** ```bash git push origin develop ```