feat: add YouTube API helper functions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
1b3c4c929f
commit
94f68e9cae
@@ -0,0 +1,70 @@
|
|||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(kind, message, status = null) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.kind = kind;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chunk(items, size) {
|
||||||
|
const batches = [];
|
||||||
|
for (let i = 0; i < items.length; i += size) batches.push(items.slice(i, i + size));
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIsoDuration(iso) {
|
||||||
|
const match = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(iso ?? '');
|
||||||
|
if (!match) return null;
|
||||||
|
const [, days = 0, hours = 0, minutes = 0, seconds = 0] = match;
|
||||||
|
return Number(days) * 86400 + Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const THUMBNAIL_ORDER = ['maxres', 'standard', 'high', 'medium', 'default'];
|
||||||
|
|
||||||
|
export function pickThumbnail(thumbnails) {
|
||||||
|
for (const size of THUMBNAIL_ORDER) {
|
||||||
|
const url = thumbnails?.[size]?.url;
|
||||||
|
if (url) return url;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUOTA_REASONS = new Set(['quotaExceeded', 'dailyLimitExceeded']);
|
||||||
|
const KEY_REASONS = new Set([
|
||||||
|
'keyInvalid',
|
||||||
|
'API_KEY_INVALID',
|
||||||
|
'accessNotConfigured',
|
||||||
|
'ipRefererBlocked',
|
||||||
|
'API_KEY_HTTP_REFERRER_BLOCKED',
|
||||||
|
'API_KEY_SERVICE_BLOCKED',
|
||||||
|
'SERVICE_DISABLED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function errorReasons(body) {
|
||||||
|
const error = body?.error;
|
||||||
|
return [...(error?.errors ?? []), ...(error?.details ?? [])].map((entry) => entry.reason).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyError(status, body) {
|
||||||
|
const reasons = errorReasons(body);
|
||||||
|
if (reasons.some((reason) => QUOTA_REASONS.has(reason))) return 'quota';
|
||||||
|
if (reasons.some((reason) => KEY_REASONS.has(reason))) return 'invalid-key';
|
||||||
|
if (status >= 500 || status === 429) return 'server';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Data API quota resets at midnight Pacific Time.
|
||||||
|
export function nextQuotaReset(now = new Date()) {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/Los_Angeles',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric',
|
||||||
|
second: 'numeric',
|
||||||
|
}).formatToParts(now);
|
||||||
|
const part = (type) => Number(parts.find((p) => p.type === type).value);
|
||||||
|
const sinceMidnightMs =
|
||||||
|
((part('hour') * 60 + part('minute')) * 60 + part('second')) * 1000 + now.getMilliseconds();
|
||||||
|
return now.getTime() - sinceMidnightMs + 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
chunk,
|
||||||
|
classifyError,
|
||||||
|
errorReasons,
|
||||||
|
nextQuotaReset,
|
||||||
|
parseIsoDuration,
|
||||||
|
pickThumbnail,
|
||||||
|
} from '../src/lib/api.js';
|
||||||
|
|
||||||
|
test('chunk splits into batches and keeps the remainder', () => {
|
||||||
|
assert.deepEqual(chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]);
|
||||||
|
assert.deepEqual(chunk([], 50), []);
|
||||||
|
assert.equal(chunk(Array.from({ length: 120 }, (_, i) => i), 50).map((b) => b.length).join(), '50,50,20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseIsoDuration converts ISO 8601 durations to seconds', () => {
|
||||||
|
assert.equal(parseIsoDuration('PT1H2M3S'), 3723);
|
||||||
|
assert.equal(parseIsoDuration('PT45S'), 45);
|
||||||
|
assert.equal(parseIsoDuration('PT10M'), 600);
|
||||||
|
assert.equal(parseIsoDuration('P1DT1H'), 90000);
|
||||||
|
assert.equal(parseIsoDuration('P0D'), 0);
|
||||||
|
assert.equal(parseIsoDuration('PT0S'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseIsoDuration returns null for garbage', () => {
|
||||||
|
assert.equal(parseIsoDuration('garbage'), null);
|
||||||
|
assert.equal(parseIsoDuration(undefined), null);
|
||||||
|
assert.equal(parseIsoDuration(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickThumbnail prefers the largest available size', () => {
|
||||||
|
const all = {
|
||||||
|
default: { url: 'd' },
|
||||||
|
medium: { url: 'm' },
|
||||||
|
high: { url: 'h' },
|
||||||
|
standard: { url: 's' },
|
||||||
|
maxres: { url: 'x' },
|
||||||
|
};
|
||||||
|
assert.equal(pickThumbnail(all), 'x');
|
||||||
|
delete all.maxres;
|
||||||
|
assert.equal(pickThumbnail(all), 's');
|
||||||
|
assert.equal(pickThumbnail({ default: { url: 'd' }, medium: { url: 'm' } }), 'm');
|
||||||
|
assert.equal(pickThumbnail({}), null);
|
||||||
|
assert.equal(pickThumbnail(undefined), null);
|
||||||
|
assert.equal(pickThumbnail(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('errorReasons collects reasons from errors[] and details[]', () => {
|
||||||
|
const body = { error: { errors: [{ reason: 'badRequest' }], details: [{ reason: 'API_KEY_INVALID' }] } };
|
||||||
|
assert.deepEqual(errorReasons(body), ['badRequest', 'API_KEY_INVALID']);
|
||||||
|
assert.deepEqual(errorReasons(null), []);
|
||||||
|
assert.deepEqual(errorReasons({}), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyError recognises quota, key and server problems', () => {
|
||||||
|
assert.equal(classifyError(403, { error: { errors: [{ reason: 'quotaExceeded' }] } }), 'quota');
|
||||||
|
assert.equal(classifyError(403, { error: { errors: [{ reason: 'dailyLimitExceeded' }] } }), 'quota');
|
||||||
|
assert.equal(
|
||||||
|
classifyError(400, { error: { errors: [{ reason: 'badRequest' }], details: [{ reason: 'API_KEY_INVALID' }] } }),
|
||||||
|
'invalid-key',
|
||||||
|
);
|
||||||
|
assert.equal(classifyError(403, { error: { errors: [{ reason: 'accessNotConfigured' }] } }), 'invalid-key');
|
||||||
|
assert.equal(classifyError(500, null), 'server');
|
||||||
|
assert.equal(classifyError(503, {}), 'server');
|
||||||
|
assert.equal(classifyError(429, { error: { errors: [{ reason: 'rateLimitExceeded' }] } }), 'server');
|
||||||
|
assert.equal(classifyError(404, null), 'other');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ApiError carries its kind and status', () => {
|
||||||
|
const err = new ApiError('quota', 'out of quota', 403);
|
||||||
|
assert.ok(err instanceof Error);
|
||||||
|
assert.equal(err.name, 'ApiError');
|
||||||
|
assert.equal(err.kind, 'quota');
|
||||||
|
assert.equal(err.status, 403);
|
||||||
|
assert.equal(err.message, 'out of quota');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextQuotaReset points at the next Pacific midnight (summer time)', () => {
|
||||||
|
const noon = new Date('2026-10-25T20:00:00Z'); // 13:00 PDT
|
||||||
|
assert.equal(new Date(nextQuotaReset(noon)).toISOString(), '2026-10-26T07:00:00.000Z');
|
||||||
|
const justBefore = new Date('2026-10-26T06:59:59Z'); // 23:59:59 PDT
|
||||||
|
assert.equal(new Date(nextQuotaReset(justBefore)).toISOString(), '2026-10-26T07:00:00.000Z');
|
||||||
|
const exactly = new Date('2026-10-26T07:00:00Z'); // 00:00:00 PDT
|
||||||
|
assert.equal(new Date(nextQuotaReset(exactly)).toISOString(), '2026-10-27T07:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextQuotaReset points at the next Pacific midnight (winter time)', () => {
|
||||||
|
const noon = new Date('2026-12-15T12:00:00Z'); // 04:00 PST
|
||||||
|
assert.equal(new Date(nextQuotaReset(noon)).toISOString(), '2026-12-16T08:00:00.000Z');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user