1- import { readdir , readFile } from "fs/promises"
2- import { join } from "path"
3-
41import matter from "gray-matter"
52
63import type { VideoFrontmatter } from "@/lib/interfaces"
74
8- import { CONTENT_DIR } from "@/lib/constants"
9-
105import { uploadToS3 } from "../s3"
116
7+ import { fetchRetry } from "./fetchRetry"
8+
9+ const GITHUB_API_BASE =
10+ "https://api.github.com/repos/ethereum/ethereum-org-website"
11+ const RAW_BASE =
12+ "https://raw.githubusercontent.com/ethereum/ethereum-org-website/master"
13+ const VIDEOS_PATH_PREFIX = "public/content/videos/"
14+ const VIDEO_INDEX_SUFFIX = "/index.md"
15+
1216const THUMBNAIL_PREFIX = "videos/thumbnails"
1317
18+ interface GitTreeItem {
19+ path : string
20+ type : "blob" | "tree"
21+ }
22+
1423/**
1524 * Derive an S3 key extension from a URL path. Falls back to "jpg".
1625 */
@@ -29,10 +38,75 @@ function youtubeThumbnailUrl(youtubeId: string, quality: "sd" | "hq"): string {
2938 return `https://img.youtube.com/vi/${ youtubeId } /${ quality } default.jpg`
3039}
3140
41+ /**
42+ * Fetch the repo tree and return the list of video slugs (top-level entries
43+ * under public/content/videos/ that have an index.md).
44+ */
45+ async function discoverVideoSlugs ( token : string ) : Promise < string [ ] > {
46+ const url = `${ GITHUB_API_BASE } /git/trees/master?recursive=1`
47+ const response = await fetchRetry ( url , {
48+ headers : {
49+ Authorization : `Bearer ${ token } ` ,
50+ Accept : "application/vnd.github.v3+json" ,
51+ } ,
52+ } )
53+
54+ if ( ! response . ok ) {
55+ throw new Error ( `Failed to fetch repo tree: ${ response . status } ` )
56+ }
57+
58+ const data = await response . json ( )
59+ const tree : GitTreeItem [ ] = data . tree
60+
61+ const slugs : string [ ] = [ ]
62+ for ( const item of tree ) {
63+ if ( item . type !== "blob" ) continue
64+ if ( ! item . path . startsWith ( VIDEOS_PATH_PREFIX ) ) continue
65+ if ( ! item . path . endsWith ( VIDEO_INDEX_SUFFIX ) ) continue
66+
67+ const inner = item . path . slice (
68+ VIDEOS_PATH_PREFIX . length ,
69+ - VIDEO_INDEX_SUFFIX . length
70+ )
71+ // Skip nested paths (none expected, kept defensive)
72+ if ( ! inner . includes ( "/" ) ) slugs . push ( inner )
73+ }
74+ return slugs
75+ }
76+
77+ /**
78+ * Fetch a single video's frontmatter from raw GitHub content.
79+ * Returns null on fetch failure so the caller can skip and continue.
80+ */
81+ async function fetchFrontmatter (
82+ slug : string ,
83+ token : string
84+ ) : Promise < VideoFrontmatter | null > {
85+ const url = `${ RAW_BASE } /${ VIDEOS_PATH_PREFIX } ${ slug } ${ VIDEO_INDEX_SUFFIX } `
86+ const response = await fetchRetry ( url , {
87+ headers : { Authorization : `Bearer ${ token } ` } ,
88+ } )
89+
90+ if ( ! response . ok ) {
91+ console . warn (
92+ `[VideoThumbnails] Failed to fetch markdown for ${ slug } : ${ response . status } `
93+ )
94+ return null
95+ }
96+
97+ const text = await response . text ( )
98+ const { data } = matter ( text )
99+ return data as VideoFrontmatter
100+ }
101+
32102/**
33103 * Fetch video thumbnails from YouTube (or custom URLs) and upload to S3.
34104 * Keyed by video slug so each video maps to exactly one S3 object.
35105 *
106+ * Reads video frontmatter (youtubeId, customThumbnailUrl) from GitHub via
107+ * the API rather than the local filesystem, so the task runs cleanly on
108+ * Trigger.dev where the app's content tree isn't bundled.
109+ *
36110 * For each video:
37111 * 1. If customThumbnailUrl exists, upload that
38112 * 2. Otherwise try sddefault.jpg first (best quality)
@@ -41,19 +115,21 @@ function youtubeThumbnailUrl(youtubeId: string, quality: "sd" | "hq"): string {
41115 * Returns a map of video slug -> S3 thumbnail URL.
42116 */
43117export async function fetchVideoThumbnails ( ) : Promise < Record < string , string > > {
118+ const token = process . env . GITHUB_TOKEN_READ_ONLY
119+ if ( ! token ) {
120+ throw new Error ( "GitHub token not set (GITHUB_TOKEN_READ_ONLY)" )
121+ }
122+
44123 console . log ( "Starting video thumbnail sync to S3" )
45124
46- const videosDir = join ( process . cwd ( ) , CONTENT_DIR , "videos" )
47- const entries = await readdir ( videosDir , { withFileTypes : true } )
48- const slugs = entries . filter ( ( e ) => e . isDirectory ( ) ) . map ( ( e ) => e . name )
125+ const slugs = await discoverVideoSlugs ( token )
126+ console . log ( `Found ${ slugs . length } videos in repo tree` )
49127
50128 const results = await Promise . all (
51129 slugs . map ( async ( slug ) => {
52130 try {
53- const mdPath = join ( videosDir , slug , "index.md" )
54- const raw = await readFile ( mdPath , "utf-8" )
55- const { data } = matter ( raw )
56- const fm = data as VideoFrontmatter
131+ const fm = await fetchFrontmatter ( slug , token )
132+ if ( ! fm ) return null
57133
58134 // YouTube thumbs are always jpg; custom URLs derive ext from the path.
59135 const ext = fm . customThumbnailUrl
0 commit comments