diff --git a/app/[locale]/videos/[slug]/page-jsonld.tsx b/app/[locale]/videos/[slug]/page-jsonld.tsx index 49c7a2511af..9dd6b72f614 100644 --- a/app/[locale]/videos/[slug]/page-jsonld.tsx +++ b/app/[locale]/videos/[slug]/page-jsonld.tsx @@ -7,7 +7,6 @@ import PageJsonLD from "@/components/PageJsonLD" import { stripMarkdown } from "@/lib/utils/md" import { toIsoDuration } from "@/lib/utils/time" import { normalizeUrlForJsonLd } from "@/lib/utils/url" -import { getDefaultThumbnailUrl } from "@/lib/utils/videos" import { BASE_GRAPH_NODES, REFERENCE } from "@/lib/jsonld/constants" import { resolveAuthorsFromFrontmatter } from "@/lib/jsonld/utils" @@ -92,7 +91,7 @@ export default async function VideoPageJsonLD({ isPartOf: { "@id": videoGalleryUrl }, thumbnailUrl: frontmatter.customThumbnailUrl || - getDefaultThumbnailUrl(frontmatter.youtubeId), + `https://img.youtube.com/vi/${frontmatter.youtubeId}/hqdefault.jpg`, embedUrl: `https://www.youtube.com/embed/${frontmatter.youtubeId}`, contentUrl: `https://www.youtube.com/watch?v=${frontmatter.youtubeId}`, educationalLevel: frontmatter.educationLevel, diff --git a/next.config.js b/next.config.js index 0450d19bee4..a3dbda3f666 100644 --- a/next.config.js +++ b/next.config.js @@ -153,7 +153,6 @@ module.exports = (phase) => { { protocol: "https", hostname: "cdn.charmverse.io" }, { protocol: "https", hostname: "ethwingman.com" }, { protocol: "https", hostname: "eth-mcp.dev" }, - { protocol: "https", hostname: "img.youtube.com", pathname: "/vi/**" }, ], }, async headers() { diff --git a/src/data-layer/fetchers/fetchVideoThumbnails.ts b/src/data-layer/fetchers/fetchVideoThumbnails.ts new file mode 100644 index 00000000000..588927f94a8 --- /dev/null +++ b/src/data-layer/fetchers/fetchVideoThumbnails.ts @@ -0,0 +1,110 @@ +import { readdir, readFile } from "fs/promises" +import { join } from "path" + +import matter from "gray-matter" + +import type { VideoFrontmatter } from "@/lib/interfaces" + +import { CONTENT_DIR } from "@/lib/constants" + +import { uploadToS3 } from "../s3" + +const THUMBNAIL_PREFIX = "videos/thumbnails" + +/** + * Derive an S3 key extension from a URL path. Falls back to "jpg". + */ +function extFromUrl(url: string): string { + const last = url.split("?")[0].split(".").pop()?.toLowerCase() + const valid = ["jpg", "jpeg", "png", "webp", "gif", "avif", "svg"] + return last && valid.includes(last) ? last : "jpg" +} + +/** + * YouTube thumbnail URL for a given video ID and quality level. + * - sddefault (640x480): best quality, but returns 404 for some videos + * - hqdefault (480x360): always exists + */ +function youtubeThumbnailUrl(youtubeId: string, quality: "sd" | "hq"): string { + return `https://img.youtube.com/vi/${youtubeId}/${quality}default.jpg` +} + +/** + * Fetch video thumbnails from YouTube (or custom URLs) and upload to S3. + * Keyed by video slug so each video maps to exactly one S3 object. + * + * For each video: + * 1. If customThumbnailUrl exists, upload that + * 2. Otherwise try sddefault.jpg first (best quality) + * 3. Fall back to hqdefault.jpg if sd fails + * + * Returns a map of video slug -> S3 thumbnail URL. + */ +export async function fetchVideoThumbnails(): Promise> { + console.log("Starting video thumbnail sync to S3") + + const videosDir = join(process.cwd(), CONTENT_DIR, "videos") + const entries = await readdir(videosDir, { withFileTypes: true }) + const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name) + + const results = await Promise.all( + slugs.map(async (slug) => { + try { + const mdPath = join(videosDir, slug, "index.md") + const raw = await readFile(mdPath, "utf-8") + const { data } = matter(raw) + const fm = data as VideoFrontmatter + + // YouTube thumbs are always jpg; custom URLs derive ext from the path. + const ext = fm.customThumbnailUrl + ? extFromUrl(fm.customThumbnailUrl) + : "jpg" + const filename = `${slug}.${ext}` + + let s3Url: string | null = null + + if (fm.customThumbnailUrl) { + s3Url = await uploadToS3( + fm.customThumbnailUrl, + THUMBNAIL_PREFIX, + filename + ) + } else if (fm.youtubeId) { + // Try best quality first, fall back to guaranteed quality + s3Url = await uploadToS3( + youtubeThumbnailUrl(fm.youtubeId, "sd"), + THUMBNAIL_PREFIX, + filename + ) + if (!s3Url) { + s3Url = await uploadToS3( + youtubeThumbnailUrl(fm.youtubeId, "hq"), + THUMBNAIL_PREFIX, + filename + ) + } + } + + if (s3Url) return [slug, s3Url] as const + console.warn(`[VideoThumbnails] No thumbnail uploaded for ${slug}`) + return null + } catch (error) { + console.warn(`[VideoThumbnails] Skipping ${slug}:`, error) + return null + } + }) + ) + + const thumbnailMap: Record = {} + for (const result of results) { + if (result) { + thumbnailMap[result[0]] = result[1] + } + } + + console.log( + `Video thumbnail sync complete: ${Object.keys(thumbnailMap).length}/${slugs.length} uploaded` + ) + + return thumbnailMap +} diff --git a/src/data-layer/index.ts b/src/data-layer/index.ts index e24e4da021f..b7f77f02f18 100644 --- a/src/data-layer/index.ts +++ b/src/data-layer/index.ts @@ -52,3 +52,4 @@ export const getDeveloperToolsData = () => get(KEYS. export const getAccountHolders = () => get(KEYS.ACCOUNT_HOLDERS) export const getTranslationGlossary = () => get(KEYS.TRANSLATION_GLOSSARY) export const getGitHubContributors = () => get(KEYS.GITHUB_CONTRIBUTORS) +export const getVideoThumbnails = () => get>(KEYS.VIDEO_THUMBNAILS) diff --git a/src/data-layer/mocks/fetch-video-thumbnails.json b/src/data-layer/mocks/fetch-video-thumbnails.json new file mode 100644 index 00000000000..548ef0a419b --- /dev/null +++ b/src/data-layer/mocks/fetch-video-thumbnails.json @@ -0,0 +1,60 @@ +{ + "ai-agents-interview-luna": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ai-agents-interview-luna.jpg", + "atoms-institutions-blockchains-josh-stark": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/atoms-institutions-blockchains-josh-stark.jpg", + "blobspace-101-dencun": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/blobspace-101-dencun.jpg", + "blockchain-101-visual-demo": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/blockchain-101-visual-demo.jpg", + "blockchain-eth-build": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/blockchain-eth-build.jpg", + "crypto-apps-desocial-linda-xie": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/crypto-apps-desocial-linda-xie.jpg", + "crypto-security-passwords": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/crypto-security-passwords.jpg", + "danny-ryan-leading-cryptos-biggest-upgrade": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/danny-ryan-leading-cryptos-biggest-upgrade.jpg", + "dao-build-next-great-city": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/dao-build-next-great-city.jpg", + "dao-hack-ethereum-classic": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/dao-hack-ethereum-classic.jpg", + "decentralized-identity-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/decentralized-identity-explained.jpg", + "decentralized-social-media": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/decentralized-social-media.jpg", + "defi-future-of-finance": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/defi-future-of-finance.jpg", + "defi-history-inception-to-2021": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/defi-history-inception-to-2021.jpg", + "desci-movement-juan-benet": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/desci-movement-juan-benet.jpg", + "devcon-mumbai-coming-2026": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/devcon-mumbai-coming-2026.jpg", + "devconnect-argentina-2025-recap": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/devconnect-argentina-2025-recap.jpg", + "devconnect-buenos-aires-promo": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/devconnect-buenos-aires-promo.jpg", + "eigenlayer-permissionless-features": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/eigenlayer-permissionless-features.jpg", + "eip-4844-dencun-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/eip-4844-dencun-explained.jpg", + "ethereum-basics-intro": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-basics-intro.jpg", + "ethereum-core-governance-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-core-governance-explained.jpg", + "ethereum-evolution-glamsterdam": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-evolution-glamsterdam.jpg", + "ethereum-in-30-minutes-vitalik-buterin": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-in-30-minutes-vitalik-buterin.jpg", + "ethereum-institutional-privacy-panel": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-institutional-privacy-panel.jpg", + "ethereum-localism-global-protocols-local-power": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-localism-global-protocols-local-power.jpg", + "ethereum-staking-withdrawals": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-staking-withdrawals.jpg", + "ethereum-things-i-like-mariano-conti": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereum-things-i-like-mariano-conti.jpg", + "ethereums-quantum-plan-justin-drake": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/ethereums-quantum-plan-justin-drake.jpg", + "fusaka-upgrade-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/fusaka-upgrade-explained.jpg", + "hash-function-eth-build": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/hash-function-eth-build.jpg", + "how-to-be-cypherpunk-juan-benet": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/how-to-be-cypherpunk-juan-benet.jpg", + "how-to-make-a-guerilla-l2": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/how-to-make-a-guerilla-l2.jpg", + "key-pair-eth-build": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/key-pair-eth-build.jpg", + "layer-2-scaling-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/layer-2-scaling-explained.jpg", + "learn-nfts-and-defi": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/learn-nfts-and-defi.jpg", + "next-10-years-of-ethereum": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/next-10-years-of-ethereum.jpg", + "pectra-upgrade-overview": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/pectra-upgrade-overview.jpg", + "pectra-what-stakers-need-to-know": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/pectra-what-stakers-need-to-know.jpg", + "pos-reorgs-attack-defense": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/pos-reorgs-attack-defense.jpg", + "post-quantum-security-ethereum-roadmap": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/post-quantum-security-ethereum-roadmap.jpg", + "pow-vs-pos": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/pow-vs-pos.jpg", + "privacy-is-existential": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/privacy-is-existential.jpg", + "proof-of-authority-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/proof-of-authority-explained.jpg", + "proof-of-work-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/proof-of-work-explained.jpg", + "proposer-builder-separation": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/proposer-builder-separation.jpg", + "real-state-of-l2s-bartek-kiepuszewski": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/real-state-of-l2s-bartek-kiepuszewski.jpg", + "regenerative-finance-refi": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/regenerative-finance-refi.jpg", + "restaking-explained": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/restaking-explained.jpg", + "rollups-scaling-strategy": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/rollups-scaling-strategy.jpg", + "security-through-obscurity-microdots": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/security-through-obscurity-microdots.jpg", + "smart-contracts-code-is-law": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/smart-contracts-code-is-law.jpg", + "stani-kulechov-building-aave": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/stani-kulechov-building-aave.jpg", + "surveillance-silence-reclaiming-privacy": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/surveillance-silence-reclaiming-privacy.jpg", + "transactions-eth-build": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/transactions-eth-build.jpg", + "understanding-consensus-mechanisms": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/understanding-consensus-mechanisms.jpg", + "what-is-a-dapp": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/what-is-a-dapp.jpg", + "zero-knowledge-proofs-5-levels": "https://s3-dcl1.ethquokkaops.io/digital-studio-img/digital-studio-img/videos/thumbnails/zero-knowledge-proofs-5-levels.jpg" +} \ No newline at end of file diff --git a/src/data-layer/s3.ts b/src/data-layer/s3.ts index 9bd05fcc9b9..bac828c3a56 100644 --- a/src/data-layer/s3.ts +++ b/src/data-layer/s3.ts @@ -131,11 +131,13 @@ function buildS3Url(bucket: string, key: string): string { * * @param sourceUrl - The external image URL to fetch and upload * @param prefix - S3 key prefix (e.g., 'apps/logos', 'events') + * @param customFilename - Optional full filename (with extension) to use instead of the URL-derived hash. * @returns S3 URL if successful, null on failure */ export async function uploadToS3( sourceUrl: string, - prefix: string + prefix: string, + customFilename?: string ): Promise { // Skip empty or invalid URLs if (!sourceUrl || !isValidImageUrl(sourceUrl)) { @@ -191,7 +193,9 @@ export async function uploadToS3( return null } - const key = generateKey(prefix, sourceUrl, ext) + const key = customFilename + ? `${prefix}/${customFilename}` + : generateKey(prefix, sourceUrl, ext) // Check if already exists in S3 try { diff --git a/src/data-layer/tasks.ts b/src/data-layer/tasks.ts index 860c7ad06e8..ed8f7c9fed1 100644 --- a/src/data-layer/tasks.ts +++ b/src/data-layer/tasks.ts @@ -35,6 +35,7 @@ import { fetchStablecoinsData } from "./fetchers/fetchStablecoinsData" import { fetchTotalEthStaked } from "./fetchers/fetchTotalEthStaked" import { fetchTotalValueLocked } from "./fetchers/fetchTotalValueLocked" import { fetchTranslationGlossary } from "./fetchers/fetchTranslationGlossary" +import { fetchVideoThumbnails } from "./fetchers/fetchVideoThumbnails" import { set } from "./storage" export const KEYS = { @@ -64,6 +65,7 @@ export const KEYS = { STABLECOINS_DATA: "fetch-stablecoins-data", ACCOUNT_HOLDERS: "fetch-account-holders", TRANSLATION_GLOSSARY: "fetch-translation-glossary", + VIDEO_THUMBNAILS: "fetch-video-thumbnails", } as const // Task definition: storage key + fetch function @@ -89,6 +91,7 @@ const DAILY: TaskDef[] = [ [KEYS.DEVELOPER_TOOLS, fetchDeveloperTools], [KEYS.TRANSLATION_GLOSSARY, fetchTranslationGlossary], [KEYS.BEACONCHAIN, fetchBeaconChain], + [KEYS.VIDEO_THUMBNAILS, fetchVideoThumbnails], ] const HOURLY: TaskDef[] = [ diff --git a/src/lib/data/index.ts b/src/lib/data/index.ts index 4ca5dc85f12..42a0af4f863 100644 --- a/src/lib/data/index.ts +++ b/src/lib/data/index.ts @@ -199,6 +199,12 @@ export const getGitHubContributors = createCachedGetter( CACHE_REVALIDATE_DAY ) +export const getVideoThumbnails = createCachedGetter( + dataLayer.getVideoThumbnails, + ["video-thumbnails"], + CACHE_REVALIDATE_DAY +) + /** * Static-cached version of getGitHubContributors — no revalidation. * Use this in static pages (e.g., md content pages via [...slug]) to avoid diff --git a/src/lib/utils/videos.ts b/src/lib/utils/videos.ts index 387e05f36f8..3c2582f3370 100644 --- a/src/lib/utils/videos.ts +++ b/src/lib/utils/videos.ts @@ -8,6 +8,8 @@ import type { VideoFrontmatter } from "@/lib/interfaces" import { CONTENT_DIR, DEFAULT_LOCALE } from "@/lib/constants" +import { getVideoThumbnails } from "@/lib/data" + // Build-time caches to avoid redundant filesystem reads during static generation. // These are module-scoped Maps that persist for the duration of the build process. const videoDataCache = new Map() @@ -28,14 +30,6 @@ function videoPath(slug: string, locale: string): string { ) } -/** - * Default YouTube thumbnail URL derived from a video ID. - * Returns hqdefault (480x360) which is guaranteed to exist. - */ -export function getDefaultThumbnailUrl(youtubeId: string): string { - return `https://img.youtube.com/vi/${youtubeId}/hqdefault.jpg` -} - /** * Read and parse a video's index.md file for a given slug and locale. * Returns the full frontmatter and markdown body content. @@ -99,8 +93,13 @@ export async function getVideoData( /** * Convert VideoData to a flat VideoCardData suitable for client components. + * Thumbnails are served from S3 (populated by the fetchVideoThumbnails task, + * which handles both YouTube and customThumbnailUrl sources). */ -function toVideoCardData(data: VideoData): VideoCardData { +function toVideoCardData( + data: VideoData, + thumbnailMap: Record | null +): VideoCardData { const { slug, frontmatter: fm } = data return { slug, @@ -109,7 +108,7 @@ function toVideoCardData(data: VideoData): VideoCardData { uploadDate: fm.uploadDate, duration: fm.duration, topic: fm.topic, - thumbnailUrl: fm.customThumbnailUrl || getDefaultThumbnailUrl(fm.youtubeId), + thumbnailUrl: thumbnailMap?.[slug] || "", } } @@ -137,13 +136,16 @@ export async function getVideos( const cached = videosCache.get(locale) if (cached) return cached - const slugs = await getVideoSlugs() + const [slugs, thumbnailMap] = await Promise.all([ + getVideoSlugs(), + getVideoThumbnails().catch(() => null), + ]) const results = await Promise.all( slugs.map(async (slug) => { try { const data = await getVideoData(slug, locale) - return toVideoCardData(data) + return toVideoCardData(data, thumbnailMap) } catch { console.warn(`Skipping video ${slug}: missing index.md`) return null