Skip to content

src/youtube-write-input.ts

261 lines · 7.5 KB · TypeScript
Raw
import {
	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,
	}
}