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 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): 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 | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined const out: Record = {} for (const [k, v] of Object.entries(value as Record)) { 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 { try { return parse(schema as never, input ?? {}) as Record } 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, } }