Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/google

src/youtube.ts

498 lines · 13.1 KB · TypeScript
import { createAuthenticatedFetch } from 'kody:runtime'
import { GoogleApiError, requestGoogle } from './core.ts'
import { resolveGoogleAccount } from './accounts.ts'
import type { JsonObject } from './types.ts'

/** Multiple of 256 KiB per YouTube; larger chunks = fewer requests. */
const DEFAULT_CHUNK_SIZE = 32 * 256 * 1024 // 8 MiB

export type YouTubeChannelsParams = {
	account: string
	part?: string
	mine?: boolean
	id?: string
	forUsername?: string
	maxResults?: number
	pageToken?: string
	fields?: string
}

export type YouTubeSearchParams = {
	account: string
	query?: string
	q?: string
	part?: string
	type?: string
	channelId?: string
	maxResults?: number
	pageToken?: string
	order?: string
	publishedAfter?: string
	publishedBefore?: string
	fields?: string
}

export type YouTubeVideosParams = {
	account: string
	id?: string
	chart?: string
	part?: string
	maxResults?: number
	pageToken?: string
	fields?: string
}

export type YouTubeCommentThreadsParams = {
	account: string
	videoId?: string
	channelId?: string
	allThreadsRelatedToChannelId?: string
	part?: string
	maxResults?: number
	pageToken?: string
	order?: string
	fields?: string
}

export type YouTubePrivacyStatus = 'public' | 'unlisted' | 'private'

export type YouTubeUploadVideoParams = {
	account: string
	/** Fetchable video URL (for example Cloudflare R2 object API URL). */
	videoUrl: string
	/** Optional headers for `videoUrl` fetch (supports Kody `{{secret:…}}` placeholders). */
	videoFetchHeaders?: Record<string, string>
	title: string
	description?: string
	tags?: string[]
	categoryId?: string
	privacyStatus?: YouTubePrivacyStatus
	/** RFC3339 UTC instant; requires privacyStatus private until publish. */
	publishAt?: string
	selfDeclaredMadeForKids?: boolean
	chunkSizeBytes?: number
	dryRun?: boolean
}

export type YouTubeUploadVideoResult = {
	id: string
	title: string
	privacyStatus: YouTubePrivacyStatus
	publishAt?: string
	bytes: number
	dryRun?: boolean
}

export type YouTubeUpdateVideoParams = {
	account: string
	id: string
	title?: string
	description?: string
	tags?: string[]
	categoryId?: string
	privacyStatus?: YouTubePrivacyStatus
	publishAt?: string
	dryRun?: boolean
}

export async function listChannels(params: YouTubeChannelsParams): Promise<JsonObject> {
	const {
		part = 'snippet,contentDetails,statistics',
		mine = true,
		id,
		forUsername,
		maxResults = 5,
		pageToken,
		fields,
	} = params
	return (await requestGoogle({
		...params,
		api: 'google',
		path: '/youtube/v3/channels',
		query: {
			part,
			mine: id || forUsername ? undefined : mine,
			id,
			forUsername,
			maxResults,
			pageToken,
			fields,
		},
	})) as JsonObject
}

export async function search(params: YouTubeSearchParams): Promise<JsonObject> {
	const {
		query,
		q = query,
		part = 'snippet',
		type,
		channelId,
		maxResults = 10,
		pageToken,
		order,
		publishedAfter,
		publishedBefore,
		fields,
	} = params
	return (await requestGoogle({
		...params,
		api: 'google',
		path: '/youtube/v3/search',
		query: {
			q,
			part,
			type,
			channelId,
			maxResults,
			pageToken,
			order,
			publishedAfter,
			publishedBefore,
			fields,
		},
	})) as JsonObject
}

export async function listVideos(params: YouTubeVideosParams): Promise<JsonObject> {
	const {
		id,
		chart,
		part = 'snippet,contentDetails,statistics,status',
		maxResults = 10,
		pageToken,
		fields,
	} = params
	return (await requestGoogle({
		...params,
		api: 'google',
		path: '/youtube/v3/videos',
		query: { id, chart, part, maxResults, pageToken, fields },
	})) as JsonObject
}

export async function listCommentThreads(
	params: YouTubeCommentThreadsParams,
): Promise<JsonObject> {
	const {
		videoId,
		channelId,
		allThreadsRelatedToChannelId,
		part = 'snippet,replies',
		maxResults = 10,
		pageToken,
		order = 'time',
		fields,
	} = params
	return (await requestGoogle({
		...params,
		api: 'google',
		path: '/youtube/v3/commentThreads',
		query: {
			videoId,
			channelId,
			allThreadsRelatedToChannelId,
			part,
			maxResults,
			pageToken,
			order,
			fields,
		},
	})) as JsonObject
}

function parseRangeTotalBytes(contentRange: string | null): number | null {
	if (!contentRange) return null
	const m = /\/(\d+)\s*$/.exec(contentRange.trim())
	if (!m) return null
	const total = Number(m[1])
	return Number.isFinite(total) && total > 0 ? total : null
}

function parseRangeMaxBytes(rangeHeader: string | null): number | null {
	if (!rangeHeader) return null
	const m = /^bytes=(\d+)-(\d+)$/i.exec(rangeHeader.trim())
	if (!m) return null
	return Number(m[2])
}

async function probeVideoBytes(
	videoUrl: string,
	videoFetchHeaders?: Record<string, string>,
): Promise<number> {
	const headers = new Headers(videoFetchHeaders || {})
	headers.set('Range', 'bytes=0-0')
	const response = await fetch(videoUrl, { method: 'GET', headers })
	if (!(response.ok || response.status === 206)) {
		const text = await response.text()
		throw new Error(
			`Could not probe video URL (${response.status}): ${text.slice(0, 400)}`,
		)
	}
	const fromRange = parseRangeTotalBytes(response.headers.get('content-range'))
	if (fromRange) return fromRange
	const length = Number(response.headers.get('content-length'))
	if (Number.isFinite(length) && length > 0) {
		// Some origins ignore Range and return the whole object; prefer Content-Length only when
		// we actually received a tiny probe body (or a full length without range).
		const body = await response.arrayBuffer()
		if (response.status === 200 && body.byteLength === length) return length
		if (response.status === 206) return length
	}
	throw new Error('Video URL probe did not return a usable Content-Range / Content-Length')
}

async function fetchVideoRange(
	videoUrl: string,
	start: number,
	endInclusive: number,
	videoFetchHeaders?: Record<string, string>,
): Promise<ArrayBuffer> {
	const headers = new Headers(videoFetchHeaders || {})
	headers.set('Range', `bytes=${start}-${endInclusive}`)
	const response = await fetch(videoUrl, { method: 'GET', headers })
	if (!(response.ok || response.status === 206)) {
		const text = await response.text()
		throw new Error(
			`Video range fetch failed ${response.status} for bytes ${start}-${endInclusive}: ${text.slice(0, 400)}`,
		)
	}
	const body = await response.arrayBuffer()
	const expected = endInclusive - start + 1
	if (body.byteLength !== expected) {
		throw new Error(
			`Video range short read at ${start}-${endInclusive}: got ${body.byteLength}, expected ${expected}`,
		)
	}
	return body
}

async function googleAuthFetch(
	accountInput: string,
): Promise<{ account: ReturnType<typeof resolveGoogleAccount>; authFetch: typeof fetch }> {
	const account = resolveGoogleAccount(accountInput)
	try {
		const authFetch = await createAuthenticatedFetch(account.integrationName)
		return { account, authFetch }
	} catch (error) {
		const causeMessage = error instanceof Error ? error.message : String(error)
		throw new GoogleApiError(
			`Could not authenticate Google account "${account.account}" (${account.integrationName})` +
				(causeMessage ? `: ${causeMessage}` : '.'),
			{
				account: account.account,
				integrationName: account.integrationName,
				causeMessage,
			},
		)
	}
}

/**
 * Upload a video to YouTube via the resumable upload protocol.
 * Fetches media from `videoUrl` in Range chunks (works with Cloudflare R2 object API URLs).
 *
 * @example
 * import { uploadVideo } from 'kody:@kentcdodds/google/youtube'
 * const result = await uploadVideo({
 *   account: 'youtube-plus',
 *   videoUrl: 'https://api.cloudflare.com/client/v4/accounts/…/r2/buckets/kcd-promo-videos/objects/ep/promo/bluesky.mp4',
 *   videoFetchHeaders: { Authorization: 'Bearer {{secret:cloudflareApiToken}}' },
 *   title: 'Promo title',
 *   privacyStatus: 'private',
 * })
 */
export async function uploadVideo(
	params: YouTubeUploadVideoParams,
): Promise<YouTubeUploadVideoResult> {
	const title = params.title?.trim()
	if (!title) throw new Error('uploadVideo requires title')
	if (!params.videoUrl?.trim()) throw new Error('uploadVideo requires videoUrl')

	const privacyStatus = params.privacyStatus ?? 'private'
	const publishAt = params.publishAt?.trim() || undefined
	if (publishAt && privacyStatus !== 'private') {
		throw new Error('publishAt requires privacyStatus "private" until YouTube publishes the video')
	}

	const bytes = await probeVideoBytes(params.videoUrl, params.videoFetchHeaders)
	if (params.dryRun) {
		return {
			id: 'dry-run',
			title,
			privacyStatus,
			publishAt,
			bytes,
			dryRun: true,
		}
	}

	const { account, authFetch } = await googleAuthFetch(params.account)
	const requestBody = {
		snippet: {
			title,
			description: params.description ?? '',
			tags: params.tags?.length ? params.tags : undefined,
			categoryId: params.categoryId ?? '28',
		},
		status: {
			privacyStatus,
			...(publishAt ? { publishAt } : {}),
			selfDeclaredMadeForKids: params.selfDeclaredMadeForKids ?? false,
		},
	}

	const sessionUrl =
		'https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status'
	const body = JSON.stringify(requestBody)
	const sessionResponse = await authFetch(sessionUrl, {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json; charset=UTF-8',
			'Content-Length': String(new TextEncoder().encode(body).byteLength),
			'X-Upload-Content-Length': String(bytes),
			'X-Upload-Content-Type': 'video/*',
		},
		body,
	})
	if (!sessionResponse.ok) {
		const text = await sessionResponse.text()
		throw new GoogleApiError(
			`Resumable session failed ${sessionResponse.status}: ${text.slice(0, 600)}`,
			{
				status: sessionResponse.status,
				statusText: sessionResponse.statusText,
				account: account.account,
				integrationName: account.integrationName,
				url: sessionUrl,
			},
		)
	}
	const uploadUrl = sessionResponse.headers.get('Location')
	if (!uploadUrl) {
		throw new Error('Resumable session response missing Location header')
	}

	const chunkSize = params.chunkSizeBytes ?? DEFAULT_CHUNK_SIZE
	let offset = 0
	while (offset < bytes) {
		const end = Math.min(offset + chunkSize, bytes) - 1
		const chunk = await fetchVideoRange(
			params.videoUrl,
			offset,
			end,
			params.videoFetchHeaders,
		)
		const put = await authFetch(uploadUrl, {
			method: 'PUT',
			headers: {
				'Content-Type': 'video/*',
				'Content-Length': String(chunk.byteLength),
				'Content-Range': `bytes ${offset}-${end}/${bytes}`,
			},
			body: chunk,
		})

		if (put.status === 308) {
			const maxIdx = parseRangeMaxBytes(put.headers.get('Range'))
			offset = maxIdx === null ? end + 1 : maxIdx + 1
			continue
		}

		if (put.status === 200 || put.status === 201) {
			const data = (await put.json()) as { id?: string; snippet?: { title?: string } }
			if (!data.id) {
				throw new Error('YouTube upload finished without a video id')
			}
			return {
				id: data.id,
				title: data.snippet?.title ?? title,
				privacyStatus,
				publishAt,
				bytes,
			}
		}

		const text = await put.text()
		throw new GoogleApiError(
			`Chunk upload failed ${put.status} at bytes ${offset}-${end}: ${text.slice(0, 600)}`,
			{
				status: put.status,
				statusText: put.statusText,
				account: account.account,
				integrationName: account.integrationName,
				url: uploadUrl,
			},
		)
	}

	throw new Error('Upload ended without 201 response')
}

/**
 * PATCH snippet and/or status for an existing YouTube video (schedule, privacy, title, etc.).
 */
export async function updateVideo(params: YouTubeUpdateVideoParams): Promise<JsonObject> {
	const id = params.id?.trim()
	if (!id) throw new Error('updateVideo requires id')

	const hasSnippet =
		params.title !== undefined ||
		params.description !== undefined ||
		params.tags !== undefined ||
		params.categoryId !== undefined
	const hasStatus = params.privacyStatus !== undefined || params.publishAt !== undefined
	if (!hasSnippet && !hasStatus) {
		throw new Error('updateVideo requires at least one snippet or status field')
	}

	const parts: string[] = []
	const body: Record<string, unknown> = { id }
	if (hasSnippet) {
		parts.push('snippet')
		body.snippet = {
			...(params.title !== undefined ? { title: params.title } : {}),
			...(params.description !== undefined ? { description: params.description } : {}),
			...(params.tags !== undefined ? { tags: params.tags } : {}),
			...(params.categoryId !== undefined ? { categoryId: params.categoryId } : {}),
		}
	}
	if (hasStatus) {
		parts.push('status')
		body.status = {
			...(params.privacyStatus !== undefined ? { privacyStatus: params.privacyStatus } : {}),
			...(params.publishAt !== undefined ? { publishAt: params.publishAt } : {}),
		}
	}

	if (params.dryRun) {
		return { dryRun: true, id, part: parts.join(','), body }
	}

	return (await requestGoogle({
		account: params.account,
		api: 'google',
		method: 'PUT',
		path: '/youtube/v3/videos',
		query: { part: parts.join(',') },
		body,
	})) as JsonObject
}

/**
 * Return the YouTube Data API helper namespace for channels, search, videos, comments, upload, and update.
 * @example
 * import youtube from 'kody:@kentcdodds/google/youtube'
 * const channels = await youtube().listChannels({ account: 'youtube-brand' })
 * // => { items: [{ id: 'UC...', snippet: { title: '...' } }] }
 */
export default function youtube() {
	return {
		listChannels,
		search,
		listVideos,
		listCommentThreads,
		uploadVideo,
		updateVideo,
	}
}