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,
}
}