Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.

102 changes: 68 additions & 34 deletions src/utils/youtube/fetchLatestUploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,41 @@ import getSinglePlaylistAndReturnVideoData, {
PlaylistType,
} from "./getSinglePlaylistAndReturnVideoData";

/**
* Parse an ISO 8601 duration string (e.g. "PT1H2M3S") into total seconds.
*/
function parseISO8601Duration(duration: string): number {
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
if (!match) return 0;
const hours = parseInt(match[1] || "0", 10);
const minutes = parseInt(match[2] || "0", 10);
const seconds = parseInt(match[3] || "0", 10);
return hours * 3600 + minutes * 60 + seconds;
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseISO8601Duration only matches PT... and will ignore the day component if YouTube ever returns durations like P1DT2H3M (regex will match from the PT... substring and undercount). Consider parsing the full ISO-8601 duration (e.g., support an optional P(\d+)D part and anchor the regex) so duration-based classification stays correct for very long videos/streams.

Suggested change
* Parse an ISO 8601 duration string (e.g. "PT1H2M3S") into total seconds.
*/
function parseISO8601Duration(duration: string): number {
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
if (!match) return 0;
const hours = parseInt(match[1] || "0", 10);
const minutes = parseInt(match[2] || "0", 10);
const seconds = parseInt(match[3] || "0", 10);
return hours * 3600 + minutes * 60 + seconds;
* Parse an ISO 8601 duration string (e.g. "PT1H2M3S" or "P1DT1H2M3S") into total seconds.
*/
function parseISO8601Duration(duration: string): number {
// Support an optional day component and require the full string to match.
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;

Copilot uses AI. Check for mistakes.
}

/**
* 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;
}

const data = await res.json();
if (!data.items || data.items.length === 0) return 0;

return parseISO8601Duration(data.items[0].contentDetails.duration);
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchVideoDuration assumes data.items[0].contentDetails.duration is always present. If the Videos API returns an item without contentDetails (or duration), this will throw at runtime. Add shape checks/optional chaining and fall back to 0 (or treat as “unknown”) when duration isn’t available.

Suggested change
return parseISO8601Duration(data.items[0].contentDetails.duration);
const firstItem = data.items[0];
const durationStr = firstItem?.contentDetails?.duration;
if (typeof durationStr !== "string") {
return 0;
}
return parseISO8601Duration(durationStr);

Copilot uses AI. Check for mistakes.
}

export const updates = new Map<
string,
{
Expand Down Expand Up @@ -99,12 +134,29 @@ 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
const durationSeconds = await fetchVideoDuration(videoId);
const THREE_MINUTES = 180;

let contentType: PlaylistType | null = null;

if (durationSeconds >= THREE_MINUTES) {
// Over 3 minutes: 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;
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The duration threshold hard-codes 180 seconds and treats >= 180 as “cannot be a short”. If that assumption is ever wrong (e.g., policy changes or edge cases at exactly 180s), shorts could be misclassified as regular videos and routed to the wrong subscription flags. Consider avoiding duration-based gating for Shorts classification (or at minimum make the threshold configurable and document the assumption).

Copilot uses AI. Check for mistakes.
} 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 +167,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
Loading