import { createGoogleAuthenticatedFetch } from './auth-fetch.ts' import { GoogleApiError, requestGoogle } from './core.ts' import { resolveGoogleAccount } from './accounts.ts' import type { JsonObject } from './types.ts' import { parseYouTubeUpdateVideoInput, parseYouTubeUploadVideoInput, } from './youtube-write-input.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 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 { 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 { 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 { 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 { 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, ): Promise { 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') } /** Per-upload cache when an origin ignores Range and returns the whole object. */ type VideoRangeCache = { full: ArrayBuffer | null } /** * Return the requested byte slice from a fetched body. * Exact Range responses pass through; Range-ignored full objects are sliced. */ export function sliceVideoRangeBody( body: ArrayBuffer, start: number, endInclusive: number, ): ArrayBuffer { const expected = endInclusive - start + 1 if (body.byteLength === expected) return body // Origin ignored Range and returned a larger object that still covers this slice. if (start >= 0 && endInclusive >= start && body.byteLength > endInclusive) { return body.slice(start, endInclusive + 1) } throw new Error( `Video range short read at ${start}-${endInclusive}: got ${body.byteLength}, expected ${expected}`, ) } async function fetchVideoRange( videoUrl: string, start: number, endInclusive: number, videoFetchHeaders?: Record, cache?: VideoRangeCache, ): Promise { if (cache?.full) { return sliceVideoRangeBody(cache.full, start, endInclusive) } 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) return body // Some R2 / object API URLs ignore Range and return the whole object (HTTP 200). // Cache once and slice so later YouTube resumable chunks do not re-download. if (body.byteLength > expected && body.byteLength > endInclusive) { if (cache) cache.full = body return sliceVideoRangeBody(body, start, endInclusive) } throw new Error( `Video range short read at ${start}-${endInclusive}: got ${body.byteLength}, expected ${expected}`, ) } async function googleAuthFetch( accountInput: string, ): Promise<{ account: ReturnType; authFetch: typeof fetch }> { const account = resolveGoogleAccount(accountInput) try { const authFetch = await createGoogleAuthenticatedFetch(account) 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. * Maps `videoUrl`/`title` aliases; rejects unknown keys. * 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 | Record, ): Promise { const input = parseYouTubeUploadVideoInput(params) const title = input.title?.trim() if (!title) throw new Error('uploadVideo requires title') if (!input.videoUrl?.trim()) throw new Error('uploadVideo requires videoUrl') const privacyStatus = input.privacyStatus ?? 'private' const publishAt = input.publishAt?.trim() || undefined if (publishAt && privacyStatus !== 'private') { throw new Error('publishAt requires privacyStatus "private" until YouTube publishes the video') } const bytes = await probeVideoBytes(input.videoUrl, input.videoFetchHeaders) if (input.dryRun) { return { id: 'dry-run', title, privacyStatus, publishAt, bytes, dryRun: true, } } const { account, authFetch } = await googleAuthFetch(input.account) const requestBody = { snippet: { title, description: input.description ?? '', tags: input.tags?.length ? input.tags : undefined, categoryId: input.categoryId ?? '28', }, status: { privacyStatus, ...(publishAt ? { publishAt } : {}), selfDeclaredMadeForKids: input.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 = input.chunkSizeBytes ?? DEFAULT_CHUNK_SIZE const rangeCache: VideoRangeCache = { full: null } let offset = 0 while (offset < bytes) { const end = Math.min(offset + chunkSize, bytes) - 1 const chunk = await fetchVideoRange( input.videoUrl, offset, end, input.videoFetchHeaders, rangeCache, ) 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.). * Maps `id`/`privacyStatus` aliases; rejects unknown keys. */ export async function updateVideo( params: YouTubeUpdateVideoParams | Record, ): Promise { const input = parseYouTubeUpdateVideoInput(params) const id = input.id?.trim() if (!id) throw new Error('updateVideo requires id') const hasSnippet = input.title !== undefined || input.description !== undefined || input.tags !== undefined || input.categoryId !== undefined const hasStatus = input.privacyStatus !== undefined || input.publishAt !== undefined if (!hasSnippet && !hasStatus) { throw new Error('updateVideo requires at least one snippet or status field') } const parts: string[] = [] const body: Record = { id } if (hasSnippet) { parts.push('snippet') body.snippet = { ...(input.title !== undefined ? { title: input.title } : {}), ...(input.description !== undefined ? { description: input.description } : {}), ...(input.tags !== undefined ? { tags: input.tags } : {}), ...(input.categoryId !== undefined ? { categoryId: input.categoryId } : {}), } } if (hasStatus) { parts.push('status') body.status = { ...(input.privacyStatus !== undefined ? { privacyStatus: input.privacyStatus } : {}), ...(input.publishAt !== undefined ? { publishAt: input.publishAt } : {}), } } if (input.dryRun) { return { dryRun: true, id, part: parts.join(','), body } } return (await requestGoogle({ account: input.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, } }