diff --git a/docs/plans/2026-02-14-youtube-thumbnail-download.md b/docs/plans/2026-02-14-youtube-thumbnail-download.md new file mode 100644 index 0000000..1897294 --- /dev/null +++ b/docs/plans/2026-02-14-youtube-thumbnail-download.md @@ -0,0 +1,548 @@ +# 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 +```