UNPKG

@ascorbic/youtube-loader

Version:

Astro loader for YouTube videos with build-time and live loading capabilities

1,107 lines (1,101 loc) 34.6 kB
// src/schema.ts import { z } from "astro/zod"; var YouTubeThumbnailSchema = z.object({ url: z.string(), width: z.number().optional(), height: z.number().optional() }); var YouTubeThumbnailsSchema = z.object({ default: YouTubeThumbnailSchema.optional(), medium: YouTubeThumbnailSchema.optional(), high: YouTubeThumbnailSchema.optional(), standard: YouTubeThumbnailSchema.optional(), maxres: YouTubeThumbnailSchema.optional() }); var YouTubeVideoSnippetSchema = z.object({ publishedAt: z.coerce.date(), channelId: z.string(), title: z.string(), description: z.string(), thumbnails: YouTubeThumbnailsSchema, channelTitle: z.string(), tags: z.array(z.string()).optional(), categoryId: z.string().optional(), liveBroadcastContent: z.string().optional(), defaultLanguage: z.string().optional(), localized: z.object({ title: z.string(), description: z.string() }).optional(), defaultAudioLanguage: z.string().optional() }); var YouTubeVideoContentDetailsSchema = z.object({ duration: z.string(), dimension: z.string().optional(), definition: z.string().optional(), caption: z.string().optional(), licensedContent: z.boolean().optional(), regionRestriction: z.object({ allowed: z.array(z.string()).optional(), blocked: z.array(z.string()).optional() }).optional(), projection: z.string().optional() }); var YouTubeVideoStatisticsSchema = z.object({ viewCount: z.string().optional(), likeCount: z.string().optional(), dislikeCount: z.string().optional(), favoriteCount: z.string().optional(), commentCount: z.string().optional() }); var YouTubeVideoStatusSchema = z.object({ uploadStatus: z.string().optional(), failureReason: z.string().optional(), rejectionReason: z.string().optional(), privacyStatus: z.string().optional(), publishAt: z.coerce.date().optional(), license: z.string().optional(), embeddable: z.boolean().optional(), publicStatsViewable: z.boolean().optional(), madeForKids: z.boolean().optional(), selfDeclaredMadeForKids: z.boolean().optional() }); var YouTubeVideoSchema = z.object({ kind: z.literal("youtube#video"), etag: z.string(), id: z.string(), snippet: YouTubeVideoSnippetSchema.optional(), contentDetails: YouTubeVideoContentDetailsSchema.optional(), statistics: YouTubeVideoStatisticsSchema.optional(), status: YouTubeVideoStatusSchema.optional() }); var YouTubeVideoListResponseSchema = z.object({ kind: z.literal("youtube#videoListResponse"), etag: z.string(), nextPageToken: z.string().optional(), prevPageToken: z.string().optional(), pageInfo: z.object({ totalResults: z.number(), resultsPerPage: z.number() }), items: z.array(YouTubeVideoSchema) }); var YouTubeSearchResultSchema = z.object({ kind: z.literal("youtube#searchResult"), etag: z.string(), id: z.object({ kind: z.string(), videoId: z.string().optional(), channelId: z.string().optional(), playlistId: z.string().optional() }), snippet: YouTubeVideoSnippetSchema }); var YouTubeSearchListResponseSchema = z.object({ kind: z.literal("youtube#searchListResponse"), etag: z.string(), nextPageToken: z.string().optional(), prevPageToken: z.string().optional(), regionCode: z.string().optional(), pageInfo: z.object({ totalResults: z.number(), resultsPerPage: z.number() }), items: z.array(YouTubeSearchResultSchema) }); var YouTubeChannelSchema = z.object({ kind: z.literal("youtube#channel"), etag: z.string(), id: z.string(), snippet: z.object({ title: z.string(), description: z.string(), publishedAt: z.coerce.date(), thumbnails: YouTubeThumbnailsSchema, country: z.string().optional(), defaultLanguage: z.string().optional() }).optional(), statistics: z.object({ viewCount: z.string().optional(), subscriberCount: z.string().optional(), hiddenSubscriberCount: z.boolean().optional(), videoCount: z.string().optional() }).optional() }); var YouTubePlaylistSnippetSchema = z.object({ publishedAt: z.coerce.date(), channelId: z.string(), title: z.string(), description: z.string(), thumbnails: YouTubeThumbnailsSchema, channelTitle: z.string(), defaultLanguage: z.string().optional(), localized: z.object({ title: z.string(), description: z.string() }).optional() }); var YouTubePlaylistStatusSchema = z.object({ privacyStatus: z.string().optional() }); var YouTubePlaylistContentDetailsSchema = z.object({ itemCount: z.number().optional() }); var YouTubePlaylistSchema = z.object({ kind: z.literal("youtube#playlist"), etag: z.string(), id: z.string(), snippet: YouTubePlaylistSnippetSchema.optional(), status: YouTubePlaylistStatusSchema.optional(), contentDetails: YouTubePlaylistContentDetailsSchema.optional() }); var YouTubePlaylistListResponseSchema = z.object({ kind: z.literal("youtube#playlistListResponse"), etag: z.string(), nextPageToken: z.string().optional(), prevPageToken: z.string().optional(), pageInfo: z.object({ totalResults: z.number(), resultsPerPage: z.number() }), items: z.array(YouTubePlaylistSchema) }); var YouTubePlaylistItemSnippetSchema = z.object({ publishedAt: z.coerce.date(), channelId: z.string(), title: z.string(), description: z.string(), thumbnails: YouTubeThumbnailsSchema, channelTitle: z.string(), playlistId: z.string(), position: z.number(), resourceId: z.object({ kind: z.string(), videoId: z.string().optional() }), videoOwnerChannelTitle: z.string().optional(), videoOwnerChannelId: z.string().optional() }); var YouTubePlaylistItemContentDetailsSchema = z.object({ videoId: z.string(), startAt: z.string().optional(), endAt: z.string().optional(), note: z.string().optional(), videoPublishedAt: z.coerce.date().optional() }); var YouTubePlaylistItemStatusSchema = z.object({ privacyStatus: z.string().optional() }); var YouTubePlaylistItemSchema = z.object({ kind: z.literal("youtube#playlistItem"), etag: z.string(), id: z.string(), snippet: YouTubePlaylistItemSnippetSchema.optional(), contentDetails: YouTubePlaylistItemContentDetailsSchema.optional(), status: YouTubePlaylistItemStatusSchema.optional() }); var YouTubePlaylistItemListResponseSchema = z.object({ kind: z.literal("youtube#playlistItemListResponse"), etag: z.string(), nextPageToken: z.string().optional(), prevPageToken: z.string().optional(), pageInfo: z.object({ totalResults: z.number(), resultsPerPage: z.number() }), items: z.array(YouTubePlaylistItemSchema) }); var VideoSchema = z.object({ id: z.string(), title: z.string(), description: z.string(), url: z.string(), publishedAt: z.coerce.date(), duration: z.string().optional(), channelId: z.string(), channelTitle: z.string(), thumbnails: YouTubeThumbnailsSchema, tags: z.array(z.string()).optional(), categoryId: z.string().optional(), viewCount: z.string().optional(), likeCount: z.string().optional(), commentCount: z.string().optional(), liveBroadcastContent: z.string().optional(), defaultLanguage: z.string().optional() }); var VideoWithFullDetailsSchema = VideoSchema.extend({ duration: z.string(), viewCount: z.string(), likeCount: z.string(), commentCount: z.string() }); // src/youtube-api-util.ts import { z as z2 } from "astro/zod"; import { getConditionalHeaders, storeConditionalHeaders } from "@ascorbic/loader-utils"; // src/youtube-errors.ts var YouTubeError = class extends Error { constructor(message, url, options) { const fullMessage = url ? `${message} (URL: ${url})${options?.cause instanceof Error ? ` - ${options.cause.message}` : ""}` : message; super(fullMessage, options); this.url = url; this.name = this.constructor.name; } }; var YouTubeAPIError = class extends YouTubeError { constructor(message, url, statusCode, apiError, apiErrorData, options) { super(message, url, options); this.statusCode = statusCode; this.apiError = apiError; this.apiErrorData = apiErrorData; } /** * Check if this is a quota exceeded error */ get isQuotaExceeded() { return this.statusCode === 403 && (this.apiError?.includes("quota") || this.apiErrorData?.error?.errors?.some((e) => e.reason === "quotaExceeded")); } /** * Check if this is an invalid API key error */ get isInvalidAPIKey() { return this.statusCode === 403 && (this.apiError?.includes("API key") || this.apiErrorData?.error?.errors?.some((e) => e.reason === "keyInvalid")); } /** * Check if this is a video not found error */ get isVideoNotFound() { return this.statusCode === 404 || this.apiErrorData?.error?.errors?.some((e) => e.reason === "videoNotFound"); } }; var YouTubeValidationError = class extends YouTubeError { constructor(message, url, details, options) { super(message, url, options); this.details = details; } }; var YouTubeConfigurationError = class extends YouTubeError { constructor(message, options) { super(message, void 0, options); } }; // src/youtube-api-util.ts var YOUTUBE_API_BASE_URL = "https://www.googleapis.com/youtube/v3"; async function makeYouTubeAPIRequest(endpoint, params, options, schema) { const url = new URL(`${YOUTUBE_API_BASE_URL}/${endpoint}`); Object.entries(params).forEach(([key, value]) => { if (value !== void 0) { url.searchParams.set(key, String(value)); } }); const requestOptions = { ...options.requestOptions }; const headers = new Headers(requestOptions.headers); headers.set("X-goog-api-key", options.apiKey); if (options.meta) { const conditionalHeaders = getConditionalHeaders({ init: headers, meta: options.meta }); requestOptions.headers = conditionalHeaders; } else { requestOptions.headers = headers; } const res = await fetch(url, requestOptions); if (res.status === 304 && options.meta) { options.logger?.info(`YouTube data not modified, skipping`); return { data: null, wasModified: false }; } if (!res.ok) { const errorText = await res.text(); let errorData = null; try { errorData = JSON.parse(errorText); } catch { } throw new YouTubeAPIError( `YouTube API request failed: ${res.status} ${res.statusText}`, url.toString(), res.status, errorData?.error?.message || res.statusText, errorData ); } const responseText = await res.text(); if (!responseText) { throw new YouTubeValidationError( "YouTube API response is empty", url.toString() ); } try { const jsonData = JSON.parse(responseText); const validatedData = schema.parse(jsonData); if (options.meta) { storeConditionalHeaders({ headers: res.headers, meta: options.meta }); } return { data: validatedData, wasModified: true }; } catch (error) { throw new YouTubeValidationError( "Failed to parse YouTube API response", url.toString(), error instanceof Error ? error.message : String(error), { cause: error } ); } } async function fetchYouTubeVideos({ videoIds, part = ["snippet", "contentDetails", "statistics"], videoCategoryId, fetchFullDetails = true, ...options }) { const effectiveParts = fetchFullDetails ? part : ["snippet"]; const params = { part: effectiveParts.join(","), id: videoIds.join(","), maxResults: 50, // YouTube API max for videos endpoint videoCategoryId }; return makeYouTubeAPIRequest( "videos", params, options, YouTubeVideoListResponseSchema ); } async function searchYouTubeVideos({ q, channelId, channelType, maxResults = 25, order = "relevance", publishedAfter, publishedBefore, regionCode, type = "video", part = ["snippet"], videoCategoryId, videoDuration, ...options }) { const params = { part: part.join(","), type, maxResults, order, regionCode, channelType, videoCategoryId, videoDuration }; if (q) params.q = q; if (channelId) params.channelId = channelId; if (publishedAfter) params.publishedAfter = publishedAfter.toISOString(); if (publishedBefore) params.publishedBefore = publishedBefore.toISOString(); return makeYouTubeAPIRequest( "search", params, options, YouTubeSearchListResponseSchema ); } async function getChannelIdFromHandle(handle, options) { options.logger?.info(`Fetching channel ID for handle: ${handle}`); const params = { part: "id", forHandle: handle }; const result = await makeYouTubeAPIRequest( "channels", params, options, z2.object({ items: z2.array(z2.object({ id: z2.string() })) }) ); if (!result.wasModified || !result.data?.items?.length) { throw new YouTubeError(`Could not find channel with handle: ${handle}`); } const channel = result.data.items[0]; if (!channel?.id) { throw new YouTubeError(`Could not find channel with handle: ${handle}`); } return channel.id; } async function fetchChannelVideos({ channelId, channelHandle, maxResults = 25, order = "date", publishedAfter, publishedBefore, part, fetchFullDetails = true, ...options }) { if (!channelId && !channelHandle) { throw new YouTubeError( "Either channelId or channelHandle must be provided" ); } let resolvedChannelId = channelId; if (channelHandle && !channelId) { resolvedChannelId = await getChannelIdFromHandle(channelHandle, options); } const searchOptions = { channelId: resolvedChannelId, maxResults, order, publishedAfter, publishedBefore, type: "video", part, fetchFullDetails, apiKey: options.apiKey, requestOptions: options.requestOptions, meta: options.meta, logger: options.logger }; return searchYouTubeVideos(searchOptions); } function transformYouTubeVideoToVideo(ytVideo, fetchFullDetails = false) { if (!ytVideo.snippet) { throw new YouTubeValidationError( "YouTube video missing snippet data", ytVideo.id ); } const baseVideo = { id: ytVideo.id, title: ytVideo.snippet.title, description: ytVideo.snippet.description, url: `https://www.youtube.com/watch?v=${ytVideo.id}`, publishedAt: ytVideo.snippet.publishedAt, channelId: ytVideo.snippet.channelId, channelTitle: ytVideo.snippet.channelTitle, thumbnails: ytVideo.snippet.thumbnails, tags: ytVideo.snippet.tags, categoryId: ytVideo.snippet.categoryId, liveBroadcastContent: ytVideo.snippet.liveBroadcastContent, defaultLanguage: ytVideo.snippet.defaultLanguage }; if (fetchFullDetails) { return VideoWithFullDetailsSchema.parse({ ...baseVideo, duration: ytVideo.contentDetails?.duration || "PT0S", viewCount: ytVideo.statistics?.viewCount || "0", likeCount: ytVideo.statistics?.likeCount || "0", commentCount: ytVideo.statistics?.commentCount || "0" }); } else { return VideoSchema.parse({ ...baseVideo, duration: ytVideo.contentDetails?.duration || "PT0S", viewCount: ytVideo.statistics?.viewCount, likeCount: ytVideo.statistics?.likeCount, commentCount: ytVideo.statistics?.commentCount }); } } function transformYouTubeVideosToVideos(ytVideos, fetchFullDetails) { return ytVideos.map( (video) => transformYouTubeVideoToVideo(video, fetchFullDetails) ); } async function fetchYouTubePlaylist({ playlistId, part = ["snippet", "contentDetails"], ...options }) { const params = { part: part.join(","), id: playlistId }; return makeYouTubeAPIRequest( "playlists", params, options, YouTubePlaylistListResponseSchema ); } var fetchYouTubePlaylistItems = ({ playlistId, maxResults = 50, part = ["snippet", "contentDetails"], fetchFullDetails = true, ...options }) => makeYouTubeAPIRequest( "playlistItems", { part: part.join(","), playlistId, maxResults }, options, YouTubePlaylistItemListResponseSchema ); // src/youtube-loader.ts function youTubeLoader(options) { const { type, apiKey, maxResults = 25, parts, requestOptions = {}, fetchFullDetails = true } = options; if (!apiKey) { throw new YouTubeConfigurationError("YouTube API key is required"); } if (type === "videos" && (!options.videoIds || options.videoIds.length === 0)) { throw new YouTubeConfigurationError( "Video IDs are required when type is 'videos'" ); } if (type === "channel" && !options.channelId && !options.channelHandle) { throw new YouTubeConfigurationError( "Channel ID or handle is required when type is 'channel'" ); } if (type === "search" && !options.query) { throw new YouTubeConfigurationError( "Search query is required when type is 'search'" ); } if (type === "playlist" && !options.playlistId) { throw new YouTubeConfigurationError( "Playlist ID is required when type is 'playlist'" ); } return { name: "youtube-loader", load: async ({ store, logger, parseData, meta }) => { logger.info(`Loading YouTube ${type} content`); const apiOptions = { apiKey, requestOptions, meta, logger, fetchFullDetails }; let videos = []; try { if (options.type === "videos") { logger.info(`Fetching ${options.videoIds.length} YouTube videos`); const { data, wasModified } = await fetchYouTubeVideos({ ...apiOptions, videoIds: options.videoIds, part: parts }); if (!wasModified) { return; } videos = transformYouTubeVideosToVideos(data.items, fetchFullDetails); } else if (options.type === "channel") { logger.info( `Fetching videos from YouTube channel: ${options.channelId || options.channelHandle}` ); const { data, wasModified } = await fetchChannelVideos({ ...apiOptions, channelId: options.channelId, channelHandle: options.channelHandle, maxResults, order: options.order || "date", publishedAfter: options.publishedAfter, publishedBefore: options.publishedBefore, part: parts }); if (!wasModified) { return; } if (data.items.length > 0 && fetchFullDetails) { const videoIds = data.items.filter( (item) => item.id.kind === "youtube#video" && item.id.videoId ).map((item) => item.id.videoId); if (videoIds.length > 0) { const { data: videoData } = await fetchYouTubeVideos({ ...apiOptions, videoIds, part: parts }); videos = transformYouTubeVideosToVideos( videoData.items, fetchFullDetails ); } } else if (data.items.length > 0) { videos = transformYouTubeVideosToVideos( data.items.map((item) => ({ kind: "youtube#video", etag: item.etag, id: item.id.videoId, snippet: item.snippet })), fetchFullDetails ); } } else if (options.type === "search") { logger.info(`Searching YouTube videos: "${options.query}"`); const { data, wasModified } = await searchYouTubeVideos({ ...apiOptions, q: options.query, maxResults, order: options.order || "date", publishedAfter: options.publishedAfter, publishedBefore: options.publishedBefore, regionCode: options.regionCode, type: "video" }); if (!wasModified) { return; } if (data.items.length > 0 && fetchFullDetails) { const videoIds = data.items.filter( (item) => item.id.kind === "youtube#video" && item.id.videoId ).map((item) => item.id.videoId); if (videoIds.length > 0) { const { data: videoData } = await fetchYouTubeVideos({ ...apiOptions, videoIds, part: parts }); videos = transformYouTubeVideosToVideos( videoData.items, fetchFullDetails ); } } else if (data.items.length > 0) { videos = transformYouTubeVideosToVideos( data.items.map((item) => ({ kind: "youtube#video", etag: item.etag, id: item.id.videoId, snippet: item.snippet })), fetchFullDetails ); } } else if (options.type === "playlist") { logger.info( `Fetching videos from YouTube playlist: ${options.playlistId}` ); const { data, wasModified } = await fetchYouTubePlaylistItems({ ...apiOptions, playlistId: options.playlistId, maxResults }); if (!wasModified) { return; } if (data.items.length > 0 && fetchFullDetails) { const videoIds = data.items.filter( (item) => item.snippet?.resourceId?.kind === "youtube#video" && item.snippet.resourceId.videoId ).map((item) => item.snippet.resourceId.videoId); if (videoIds.length > 0) { const { data: videoData } = await fetchYouTubeVideos({ ...apiOptions, videoIds, part: parts }); videos = transformYouTubeVideosToVideos(videoData.items, fetchFullDetails); } } else if (data.items.length > 0) { videos = transformYouTubeVideosToVideos( data.items.map((item) => ({ kind: "youtube#video", etag: item.etag, id: item.snippet.resourceId.videoId, snippet: item.snippet })), fetchFullDetails ); } } store.clear(); let processedCount = 0; for (const video of videos) { const data = await parseData({ id: video.id, data: video }); store.set({ id: video.id, data, rendered: { html: video.description || "" } }); processedCount++; } logger.info(`Loaded ${processedCount} YouTube videos`); } catch (error) { logger.error(`Failed to load YouTube content: ${error}`); throw error; } }, schema: fetchFullDetails ? VideoWithFullDetailsSchema : VideoSchema }; } // src/live-youtube-loader.ts function liveYouTubeLoader(options) { const { type, apiKey, defaultMaxResults = 25, defaultOrder = "date", defaultRegionCode, parts, requestOptions = {}, fetchFullDetails = true } = options; if (!apiKey) { throw new YouTubeConfigurationError("YouTube API key is required"); } if (type === "videos" && !options.videoIds?.length) { throw new YouTubeConfigurationError( "Video IDs are required when type is 'videos'" ); } if (type === "channel" && !options.channelId && !options.channelHandle) { throw new YouTubeConfigurationError( "Channel ID or handle is required when type is 'channel'" ); } if (type === "search" && !options.query) { throw new YouTubeConfigurationError( "Search query is required when type is 'search'" ); } if (type === "playlist" && !options.playlistId) { throw new YouTubeConfigurationError( "Playlist ID is required when type is 'playlist'" ); } return { name: "live-youtube-loader", loadCollection: async ({ filter }) => { try { const apiOptions = { apiKey, requestOptions, fetchFullDetails }; let videos = []; let lastModified; if (options.type === "videos") { const { data } = await fetchYouTubeVideos({ ...apiOptions, videoIds: options.videoIds, part: parts }); videos = transformYouTubeVideosToVideos(data.items, fetchFullDetails); lastModified = videos.length > 0 ? videos[0]?.publishedAt : void 0; } else if (options.type === "channel") { const channelFilter = filter; const effectiveChannelId = channelFilter?.channelId || options.channelId; if (!effectiveChannelId && !options.channelHandle) { throw new YouTubeConfigurationError( "Channel ID or handle is required for channel videos" ); } const { data } = await fetchChannelVideos({ ...apiOptions, channelId: effectiveChannelId, channelHandle: !effectiveChannelId ? options.channelHandle : void 0, maxResults: filter?.limit || defaultMaxResults, order: channelFilter?.order || defaultOrder, publishedAfter: channelFilter?.publishedAfter, publishedBefore: channelFilter?.publishedBefore, videoCategoryId: channelFilter?.categoryId, videoDuration: channelFilter?.duration }); if (data.items.length > 0 && fetchFullDetails) { const videoIds = data.items.filter((item) => item.id.videoId).map((item) => item.id.videoId); if (videoIds.length > 0) { const { data: videoData } = await fetchYouTubeVideos({ ...apiOptions, videoIds, part: parts }); videos = transformYouTubeVideosToVideos( videoData.items, fetchFullDetails ); } } else if (data.items.length > 0) { videos = transformYouTubeVideosToVideos( data.items.map((item) => ({ kind: "youtube#video", etag: item.etag, id: item.id.videoId, snippet: item.snippet })), fetchFullDetails ); } } else if (options.type === "search") { const searchFilter = filter; const effectiveQuery = searchFilter?.query || options.query; const { data } = await searchYouTubeVideos({ ...apiOptions, q: effectiveQuery, channelId: searchFilter?.channelId, maxResults: filter?.limit || defaultMaxResults, order: searchFilter?.order || defaultOrder, publishedAfter: searchFilter?.publishedAfter, publishedBefore: searchFilter?.publishedBefore, regionCode: searchFilter?.regionCode || defaultRegionCode, type: "video", videoCategoryId: searchFilter?.categoryId, videoDuration: searchFilter?.duration }); if (data.items.length > 0 && fetchFullDetails) { const videoIds = data.items.filter((item) => item.id.videoId).map((item) => item.id.videoId); if (videoIds.length > 0) { const { data: videoData } = await fetchYouTubeVideos({ ...apiOptions, videoIds, part: parts }); videos = transformYouTubeVideosToVideos( videoData.items, fetchFullDetails ); } } else if (data.items.length > 0) { videos = transformYouTubeVideosToVideos( data.items.map((item) => ({ kind: "youtube#video", etag: item.etag, id: item.id.videoId, snippet: item.snippet })), fetchFullDetails ); } } else if (options.type === "playlist") { const { data } = await fetchYouTubePlaylistItems({ ...apiOptions, playlistId: options.playlistId, maxResults: filter?.limit || defaultMaxResults }); if (data.items.length > 0 && fetchFullDetails) { const videoIds = data.items.filter( (item) => item.snippet?.resourceId?.kind === "youtube#video" && item.snippet.resourceId.videoId ).map((item) => item.snippet.resourceId.videoId); if (videoIds.length > 0) { const { data: videoData } = await fetchYouTubeVideos({ ...apiOptions, videoIds, part: parts }); videos = transformYouTubeVideosToVideos( videoData.items, fetchFullDetails ); } } else if (data.items.length > 0) { videos = transformYouTubeVideosToVideos( data.items.map((item) => ({ kind: "youtube#video", etag: item.etag, id: item.snippet.resourceId.videoId, snippet: item.snippet })), fetchFullDetails ); } } if (filter?.limit && filter.limit < videos.length) { videos = videos.slice(0, filter.limit); } if (!filter?.order) { videos.sort( (a, b) => b.publishedAt.getTime() - a.publishedAt.getTime() ); } return { entries: videos.map((video) => ({ id: video.id, data: video, rendered: { html: video.description || "" }, cacheHint: { lastModified: video.publishedAt } })), cacheHint: { lastModified: lastModified || (videos.length > 0 ? videos[0]?.publishedAt : void 0) } }; } catch (error) { if (error instanceof YouTubeError) { return { error }; } return { error: new YouTubeAPIError( "Failed to load YouTube collection", "", 500, error instanceof Error ? error.message : String(error), void 0, { cause: error } ) }; } }, loadEntry: async ({ filter }) => { try { const apiOptions = { apiKey, requestOptions, fetchFullDetails }; let video; if (filter.id) { try { const { data } = await fetchYouTubeVideos({ ...apiOptions, videoIds: [filter.id], part: parts }); if (data.items.length > 0) { video = transformYouTubeVideosToVideos( data.items, fetchFullDetails )[0]; } } catch (error) { const { data: searchData } = await searchYouTubeVideos({ ...apiOptions, q: filter.id, maxResults: 1 }); const videoId = searchData?.items?.[0]?.id?.videoId; if (videoId) { const { data: videoData } = await fetchYouTubeVideos({ ...apiOptions, videoIds: [videoId], part: parts }); if (videoData?.items?.length > 0) { video = transformYouTubeVideosToVideos( videoData.items, fetchFullDetails )[0]; } } } } if (!video && filter.url) { const videoId = extractVideoIdFromUrl(filter.url); if (videoId) { try { const { data } = await fetchYouTubeVideos({ ...apiOptions, videoIds: [videoId], part: parts }); if (data.items.length > 0) { video = transformYouTubeVideosToVideos( data.items, fetchFullDetails )[0]; } } catch (error) { } } } if (!video) { return void 0; } return { id: video.id, data: video, rendered: { html: video.description || "" } }; } catch (error) { if (error instanceof YouTubeError) { return { error }; } return { error: new YouTubeAPIError( "Failed to load YouTube entry", "", 500, error instanceof Error ? error.message : String(error), void 0, { cause: error } ) }; } } }; } function extractVideoIdFromUrl(url) { try { const urlObj = new URL(url); const hostname = urlObj.hostname; const pathname = urlObj.pathname; const searchParams = urlObj.searchParams; if (hostname.includes("youtube.com")) { if (pathname.startsWith("/watch")) { return searchParams.get("v"); } else if (pathname.startsWith("/embed/")) { return pathname.substring(7); } else if (pathname.startsWith("/shorts/")) { return pathname.substring(8); } } else if (hostname.includes("youtu.be")) { return pathname.substring(1); } return null; } catch { return null; } } export { VideoSchema, YouTubeAPIError, YouTubeChannelSchema, YouTubeConfigurationError, YouTubeError, YouTubePlaylistContentDetailsSchema, YouTubePlaylistItemContentDetailsSchema, YouTubePlaylistItemListResponseSchema, YouTubePlaylistItemSchema, YouTubePlaylistItemSnippetSchema, YouTubePlaylistItemStatusSchema, YouTubePlaylistListResponseSchema, YouTubePlaylistSchema, YouTubePlaylistSnippetSchema, YouTubePlaylistStatusSchema, YouTubeSearchListResponseSchema, YouTubeSearchResultSchema, YouTubeThumbnailSchema, YouTubeThumbnailsSchema, YouTubeValidationError, YouTubeVideoContentDetailsSchema, YouTubeVideoListResponseSchema, YouTubeVideoSchema, YouTubeVideoSnippetSchema, YouTubeVideoStatisticsSchema, YouTubeVideoStatusSchema, fetchChannelVideos, fetchYouTubePlaylist, fetchYouTubePlaylistItems, fetchYouTubeVideos, liveYouTubeLoader, searchYouTubeVideos, transformYouTubeVideoToVideo, transformYouTubeVideosToVideos, youTubeLoader };