feat: fetch videos and channels from the YouTube Data API
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
94f68e9cae
commit
97faeedca3
+103
@@ -68,3 +68,106 @@ export function nextQuotaReset(now = new Date()) {
|
||||
((part('hour') * 60 + part('minute')) * 60 + part('second')) * 1000 + now.getMilliseconds();
|
||||
return now.getTime() - sinceMidnightMs + 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
const API_BASE = 'https://www.googleapis.com/youtube/v3';
|
||||
const BATCH_SIZE = 50;
|
||||
const RETRIES = 3;
|
||||
const BASE_DELAY_MS = 500;
|
||||
|
||||
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function requestOnce(path, params, { apiKey, fetchImpl = fetch }) {
|
||||
const url = `${API_BASE}/${path}?${new URLSearchParams({ ...params, key: apiKey })}`;
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(url);
|
||||
} catch (err) {
|
||||
throw new ApiError('network', err.message);
|
||||
}
|
||||
if (response.ok) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new ApiError('server', 'Invalid JSON in API response', response.status);
|
||||
}
|
||||
}
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
// error responses are not always JSON
|
||||
}
|
||||
throw new ApiError(classifyError(response.status, body), body?.error?.message ?? `HTTP ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
async function request(path, params, opts) {
|
||||
const sleep = opts.sleep ?? defaultSleep;
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
return await requestOnce(path, params, opts);
|
||||
} catch (err) {
|
||||
const retryable = err instanceof ApiError && (err.kind === 'network' || err.kind === 'server');
|
||||
if (!retryable || attempt >= RETRIES) throw err;
|
||||
await sleep(BASE_DELAY_MS * 2 ** attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeVideo(item) {
|
||||
const { snippet = {}, statistics = {}, contentDetails = {} } = item;
|
||||
return {
|
||||
videoId: item.id,
|
||||
title: snippet.title ?? '',
|
||||
thumbnailUrl: pickThumbnail(snippet.thumbnails),
|
||||
channelId: snippet.channelId,
|
||||
channelTitle: snippet.channelTitle ?? '',
|
||||
views: Number(statistics.viewCount ?? 0),
|
||||
publishedAt: snippet.publishedAt ?? null,
|
||||
durationSec: parseIsoDuration(contentDetails.duration),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeChannel(item) {
|
||||
const customUrl = item.snippet?.customUrl;
|
||||
const stats = item.statistics ?? {};
|
||||
return {
|
||||
channelId: item.id,
|
||||
title: item.snippet?.title ?? '',
|
||||
url: customUrl?.startsWith('@')
|
||||
? `https://www.youtube.com/${customUrl}`
|
||||
: `https://www.youtube.com/channel/${item.id}`,
|
||||
subscribers: stats.hiddenSubscriberCount || stats.subscriberCount === undefined ? null : Number(stats.subscriberCount),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchVideos(ids, opts) {
|
||||
const videos = [];
|
||||
const found = new Set();
|
||||
for (const batch of chunk(ids, BATCH_SIZE)) {
|
||||
const data = await request(
|
||||
'videos',
|
||||
{ part: 'snippet,statistics,contentDetails', id: batch.join(',') },
|
||||
opts,
|
||||
);
|
||||
for (const item of data.items ?? []) {
|
||||
videos.push(normalizeVideo(item));
|
||||
found.add(item.id);
|
||||
}
|
||||
}
|
||||
return { videos, missingIds: ids.filter((id) => !found.has(id)) };
|
||||
}
|
||||
|
||||
// maxResults is sent here only: videos.list rejects it together with `id`,
|
||||
// while channels.list would otherwise default to 5 results.
|
||||
export async function fetchChannels(ids, opts) {
|
||||
const channels = [];
|
||||
for (const batch of chunk(ids, BATCH_SIZE)) {
|
||||
const data = await request(
|
||||
'channels',
|
||||
{ part: 'statistics,snippet', id: batch.join(','), maxResults: String(BATCH_SIZE) },
|
||||
opts,
|
||||
);
|
||||
for (const item of data.items ?? []) channels.push(normalizeChannel(item));
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user