Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions app/[locale]/videos/[slug]/page-jsonld.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
110 changes: 110 additions & 0 deletions src/data-layer/fetchers/fetchVideoThumbnails.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> {
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<string, string> = {}
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
}
1 change: 1 addition & 0 deletions src/data-layer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ export const getDeveloperToolsData = () => get<DeveloperToolsDataEnvelope>(KEYS.
export const getAccountHolders = () => get<MetricReturnData>(KEYS.ACCOUNT_HOLDERS)
export const getTranslationGlossary = () => get<GlossaryEntry[]>(KEYS.TRANSLATION_GLOSSARY)
export const getGitHubContributors = () => get<GitHubContributorsData>(KEYS.GITHUB_CONTRIBUTORS)
export const getVideoThumbnails = () => get<Record<string, string>>(KEYS.VIDEO_THUMBNAILS)
60 changes: 60 additions & 0 deletions src/data-layer/mocks/fetch-video-thumbnails.json
Original file line number Diff line number Diff line change
@@ -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"
}
8 changes: 6 additions & 2 deletions src/data-layer/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
// Skip empty or invalid URLs
if (!sourceUrl || !isValidImageUrl(sourceUrl)) {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions src/data-layer/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -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[] = [
Expand Down
6 changes: 6 additions & 0 deletions src/lib/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 14 additions & 12 deletions src/lib/utils/videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, VideoData>()
Expand All @@ -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.
Expand Down Expand Up @@ -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<string, string> | null
): VideoCardData {
const { slug, frontmatter: fm } = data
return {
slug,
Expand All @@ -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] || "",
}
}

Expand Down Expand Up @@ -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
Expand Down
Loading