← Public packages
@kentcdodds/google
Google helpers for Gmail, Calendar, Drive, People, YouTube (including upload), and Analytics.
src/youtube-write-input.ts
261 lines · 7.5 KB · TypeScriptimport {
ValidationError,
any,
array,
boolean,
object,
optional,
parse,
string,
} from 'remix/data-schema'
import { formatSchemaValidationError } from './schema-input-errors.ts'
type YouTubePrivacyStatus = 'public' | 'unlisted' | 'private'
/** Normalized upload params (mirrors YouTubeUploadVideoParams). */
export type ParsedYouTubeUploadVideoParams = {
account: string
videoUrl: string
videoFetchHeaders?: Record<string, string>
title: string
description?: string
tags?: string[]
categoryId?: string
privacyStatus?: YouTubePrivacyStatus
publishAt?: string
selfDeclaredMadeForKids?: boolean
chunkSizeBytes?: number
dryRun?: boolean
}
/** Normalized update params (mirrors YouTubeUpdateVideoParams). */
export type ParsedYouTubeUpdateVideoParams = {
account: string
id: string
title?: string
description?: string
tags?: string[]
categoryId?: string
privacyStatus?: YouTubePrivacyStatus
publishAt?: string
dryRun?: boolean
}
const nonEmptyString = string().refine((value) => value.trim().length > 0, 'Expected non-empty string')
function firstNonEmpty(...values: Array<unknown>): string | undefined {
for (const value of values) {
if (typeof value === 'string' && value.trim().length > 0) return value.trim()
}
return undefined
}
const UPLOAD_KNOWN_KEYS = [
'account',
'videoUrl',
'video_url',
'url',
'mediaUrl',
'sourceUrl',
'videoFetchHeaders',
'video_fetch_headers',
'headers',
'title',
'name',
'description',
'tags',
'categoryId',
'category_id',
'privacyStatus',
'privacy_status',
'privacy',
'publishAt',
'publish_at',
'selfDeclaredMadeForKids',
'chunkSizeBytes',
'dryRun',
] as const
const UPDATE_KNOWN_KEYS = [
'account',
'id',
'videoId',
'video_id',
'title',
'name',
'description',
'tags',
'categoryId',
'category_id',
'privacyStatus',
'privacy_status',
'privacy',
'publishAt',
'publish_at',
'dryRun',
] as const
const UploadSchema = object(
{
account: optional(nonEmptyString),
videoUrl: optional(nonEmptyString),
video_url: optional(nonEmptyString),
url: optional(nonEmptyString),
mediaUrl: optional(nonEmptyString),
sourceUrl: optional(nonEmptyString),
videoFetchHeaders: optional(any()),
video_fetch_headers: optional(any()),
headers: optional(any()),
title: optional(nonEmptyString),
name: optional(nonEmptyString),
description: optional(string()),
tags: optional(array(string())),
categoryId: optional(string()),
category_id: optional(string()),
privacyStatus: optional(string()),
privacy_status: optional(string()),
privacy: optional(string()),
publishAt: optional(string()),
publish_at: optional(string()),
selfDeclaredMadeForKids: optional(boolean()),
chunkSizeBytes: optional(any()),
dryRun: optional(boolean()),
},
{ unknownKeys: 'error' },
)
const UpdateSchema = object(
{
account: optional(nonEmptyString),
id: optional(nonEmptyString),
videoId: optional(nonEmptyString),
video_id: optional(nonEmptyString),
title: optional(string()),
name: optional(string()),
description: optional(string()),
tags: optional(array(string())),
categoryId: optional(string()),
category_id: optional(string()),
privacyStatus: optional(string()),
privacy_status: optional(string()),
privacy: optional(string()),
publishAt: optional(string()),
publish_at: optional(string()),
dryRun: optional(boolean()),
},
{ unknownKeys: 'error' },
)
function asHeaders(value: unknown): Record<string, string> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (typeof v === 'string') out[k] = v
}
return out
}
function asPrivacy(value: unknown): YouTubePrivacyStatus | undefined {
const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''
if (raw === 'public' || raw === 'unlisted' || raw === 'private') return raw
return undefined
}
function wrapParse(
surface: string,
knownKeys: readonly string[],
hint: string,
schema: unknown,
input: unknown,
): Record<string, unknown> {
try {
return parse(schema as never, input ?? {}) as Record<string, unknown>
} catch (error) {
if (error instanceof ValidationError) {
throw formatSchemaValidationError(error, { surface, knownKeys, hint, input })
}
throw error
}
}
export function parseYouTubeUploadVideoInput(input: unknown): ParsedYouTubeUploadVideoParams {
const raw = wrapParse(
'youtube-upload-video',
UPLOAD_KNOWN_KEYS,
`Pass account, videoUrl (aliases: video_url, url, mediaUrl, sourceUrl), title (alias: name). Unknown keys are rejected.`,
UploadSchema,
input,
)
const account = firstNonEmpty(raw.account)
const videoUrl = firstNonEmpty(raw.videoUrl, raw.video_url, raw.url, raw.mediaUrl, raw.sourceUrl)
const title = firstNonEmpty(raw.title, raw.name)
if (!account) throw new Error('youtube-upload-video requires account.')
if (!videoUrl) {
throw new Error(
'youtube-upload-video requires videoUrl (aliases: video_url, url, mediaUrl, sourceUrl).',
)
}
if (!title) throw new Error('youtube-upload-video requires title (alias: name).')
const privacyRaw = firstNonEmpty(raw.privacyStatus, raw.privacy_status, raw.privacy)
const privacyStatus = privacyRaw ? asPrivacy(privacyRaw) : undefined
if (privacyRaw && !privacyStatus) {
throw new Error('privacyStatus must be public, unlisted, or private (aliases: privacy_status, privacy).')
}
const chunkRaw = raw.chunkSizeBytes
const chunkSizeBytes =
typeof chunkRaw === 'number' && Number.isFinite(chunkRaw)
? chunkRaw
: typeof chunkRaw === 'string' && chunkRaw.trim() && Number.isFinite(Number(chunkRaw))
? Number(chunkRaw)
: undefined
return {
account,
videoUrl,
videoFetchHeaders: asHeaders(raw.videoFetchHeaders ?? raw.video_fetch_headers ?? raw.headers),
title,
description: typeof raw.description === 'string' ? raw.description : undefined,
tags: Array.isArray(raw.tags) ? (raw.tags as string[]) : undefined,
categoryId: firstNonEmpty(raw.categoryId, raw.category_id),
privacyStatus,
publishAt: firstNonEmpty(raw.publishAt, raw.publish_at),
selfDeclaredMadeForKids:
typeof raw.selfDeclaredMadeForKids === 'boolean' ? raw.selfDeclaredMadeForKids : undefined,
chunkSizeBytes,
dryRun: raw.dryRun === true,
}
}
export function parseYouTubeUpdateVideoInput(input: unknown): ParsedYouTubeUpdateVideoParams {
const raw = wrapParse(
'youtube-update-video',
UPDATE_KNOWN_KEYS,
`Pass account, id (aliases: videoId, video_id), plus at least one snippet/status field. Unknown keys are rejected.`,
UpdateSchema,
input,
)
const account = firstNonEmpty(raw.account)
const id = firstNonEmpty(raw.id, raw.videoId, raw.video_id)
if (!account) throw new Error('youtube-update-video requires account.')
if (!id) throw new Error('youtube-update-video requires id (aliases: videoId, video_id).')
const privacyRaw = firstNonEmpty(raw.privacyStatus, raw.privacy_status, raw.privacy)
const privacyStatus = privacyRaw ? asPrivacy(privacyRaw) : undefined
if (privacyRaw && !privacyStatus) {
throw new Error('privacyStatus must be public, unlisted, or private (aliases: privacy_status, privacy).')
}
return {
account,
id,
title: firstNonEmpty(raw.title, raw.name),
description: typeof raw.description === 'string' ? raw.description : undefined,
tags: Array.isArray(raw.tags) ? (raw.tags as string[]) : undefined,
categoryId: firstNonEmpty(raw.categoryId, raw.category_id),
privacyStatus,
publishAt: firstNonEmpty(raw.publishAt, raw.publish_at),
dryRun: raw.dryRun === true,
}
}