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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

121 changes: 87 additions & 34 deletions src/utils/youtube/fetchLatestUploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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,
{
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down