From 94f68e9caeefed7c7572efcd12ad727892e38751 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Sun, 25 Oct 2026 10:50:03 +0300 Subject: [PATCH] feat: add YouTube API helper functions Co-Authored-By: Claude Sonnet 5.5 --- src/lib/api.js | 70 ++++++++++++++++++++++++++++++ test/api-helpers.test.js | 93 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/lib/api.js create mode 100644 test/api-helpers.test.js diff --git a/src/lib/api.js b/src/lib/api.js new file mode 100644 index 0000000..91b7c25 --- /dev/null +++ b/src/lib/api.js @@ -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; +} diff --git a/test/api-helpers.test.js b/test/api-helpers.test.js new file mode 100644 index 0000000..24971ad --- /dev/null +++ b/test/api-helpers.test.js @@ -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'); +});