diff --git a/bun.lock b/bun.lock index 969b6b5..0eda079 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "videonotifier", diff --git a/src/utils/youtube/fetchLatestUploads.ts b/src/utils/youtube/fetchLatestUploads.ts index 954ffd9..2d7e582 100644 --- a/src/utils/youtube/fetchLatestUploads.ts +++ b/src/utils/youtube/fetchLatestUploads.ts @@ -15,6 +15,57 @@ import getSinglePlaylistAndReturnVideoData, { PlaylistType, } from "./getSinglePlaylistAndReturnVideoData"; +/** + * Parse a full ISO 8601 duration string into total seconds. + * Supports formats like "PT1H2M3S", "P1DT2H3M4S", "PT30S", etc. + * Note: YouTube typically returns PT-prefixed durations, but very long + * streams/videos could include a day component (P1DT...). + */ +function parseISO8601Duration(duration: string): number { + const match = duration.match( + /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/, + ); + if (!match) return 0; + const days = parseInt(match[1] || "0", 10); + const hours = parseInt(match[2] || "0", 10); + const minutes = parseInt(match[3] || "0", 10); + const seconds = parseInt(match[4] || "0", 10); + return days * 86400 + hours * 3600 + minutes * 60 + seconds; +} + +/** + * Fetch the duration (in seconds) of a video using the YouTube Videos API. + * Returns 0 if the duration cannot be determined. + */ +async function fetchVideoDuration(videoId: string): Promise { + const res = await fetch( + `https://youtube.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoId}&key=${env.youtubeApiKey}`, + ); + + if (!res.ok) { + console.error( + "Error fetching video duration:", + res.statusText, + ); + return 0; + } + + // TODO: Type the response from YouTube API for better type safety + const data = await res.json(); + if (!data.items || data.items.length === 0) return 0; + + const durationFirstItem = data.items[0]; + if (!durationFirstItem.contentDetails || !durationFirstItem.contentDetails.duration) { + console.error("Duration not found in video details for video ID:", videoId,); + return 0; + } + + const duration = durationFirstItem?.contentDetails?.duration; + if (typeof duration !== "string") return 0; + + return parseISO8601Duration(duration); +} + export const updates = new Map< string, { @@ -99,12 +150,32 @@ export default async function fetchLatestUploads() { "Requires update?", requiresUpdate, ); - const [longVideoId, shortVideoId, streamVideoId] = - await Promise.all([ - getSinglePlaylistAndReturnVideoData( - channelId, - PlaylistType.Video, - ), + // Use duration-based detection to reduce API quota usage + // and avoid UULF which is currently lagging. + // YouTube Shorts are currently limited to 3 minutes (180s). + // Videos at exactly 180s could still be shorts, so we use + // a strict greater-than check. + const durationSeconds = await fetchVideoDuration(videoId); + const SHORTS_DURATION = 180; + + let contentType: PlaylistType | null = null; + + if (durationSeconds > SHORTS_DURATION) { + // Over the shorts limit: cannot be a short, check only if it's a stream + const streamVideoId = await getSinglePlaylistAndReturnVideoData( + channelId, + PlaylistType.Stream, + ); + + if (videoId === streamVideoId.videoId) { + contentType = PlaylistType.Stream; + } else { + // Not a stream and over 3 min; must be a regular video + contentType = PlaylistType.Video; + } + } else { + // Under 3 minutes: could be a short or a video, check UUSH and UULV + const [shortVideoId, streamVideoId] = await Promise.all([ getSinglePlaylistAndReturnVideoData( channelId, PlaylistType.Short, @@ -115,42 +186,24 @@ export default async function fetchLatestUploads() { ), ]); - if (!longVideoId && !shortVideoId && !streamVideoId) { - console.error( - "No video IDs found for channel in fetchLatestUploads", - ); - continue; - } - - let contentType: PlaylistType | null = null; - - if (videoId == longVideoId.videoId) { - contentType = PlaylistType.Video; - } else if (videoId == shortVideoId.videoId) { - contentType = PlaylistType.Short; - } else if (videoId == streamVideoId.videoId) { - contentType = PlaylistType.Stream; - } else { - console.error( - "Video ID does not match any fetched video IDs for channel", - channelId, - ); + if (videoId === shortVideoId.videoId) { + contentType = PlaylistType.Short; + } else if (videoId === streamVideoId.videoId) { + contentType = PlaylistType.Stream; + } else { + // Not in shorts or streams playlist → regular video + contentType = PlaylistType.Video; + } } - const videoIdMap = { - [PlaylistType.Video]: longVideoId, - [PlaylistType.Short]: shortVideoId, - [PlaylistType.Stream]: streamVideoId, - }; - - console.log("Determined content type:", contentType); + console.log("Determined content type:", contentType, `(duration: ${durationSeconds}s)`); if (contentType) { console.log( `Updating ${contentType} video ID for channel`, channelId, "to", - videoIdMap[contentType as keyof typeof videoIdMap], + videoId, ); } else { console.error(