# 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<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
  }
}
```

**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
```
