mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
548 lines
16 KiB
Markdown
548 lines
16 KiB
Markdown
# 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
|
|
```
|