export type LiveShowModerationMode = 'off' | 'manual' | 'trusted_only'; export type LiveShowPlaybackMode = 'newest_first' | 'balanced' | 'curated'; export type LiveShowPaceMode = 'auto' | 'fixed'; export type LiveShowLayoutMode = 'single' | 'split' | 'grid_burst'; export type LiveShowEffectPreset = | 'film_cut' | 'shutter_flash' | 'polaroid_toss' | 'parallax_glide' | 'light_effects'; export type LiveShowBackgroundMode = 'blur_last' | 'gradient' | 'solid' | 'brand'; export type LiveShowSettings = { retention_window_hours: number; moderation_mode: LiveShowModerationMode; playback_mode: LiveShowPlaybackMode; pace_mode: LiveShowPaceMode; fixed_interval_seconds: number; layout_mode: LiveShowLayoutMode; effect_preset: LiveShowEffectPreset; effect_intensity: number; background_mode: LiveShowBackgroundMode; }; export type LiveShowCursor = { approved_at: string | null; id: number; }; export type LiveShowEvent = { id: number; slug?: string | null; name: string; default_locale?: string | null; }; export type LiveShowPhoto = { id: number; full_url: string; thumb_url: string | null; approved_at: string | null; width?: number | null; height?: number | null; is_featured: boolean; live_priority: number; }; export type LiveShowState = { event: LiveShowEvent; settings: LiveShowSettings; settings_version: string; photos: LiveShowPhoto[]; cursor: LiveShowCursor | null; }; export type LiveShowUpdates = { settings: LiveShowSettings | null; settings_version: string; photos: LiveShowPhoto[]; cursor: LiveShowCursor | null; }; export type LiveShowErrorCode = 'not_found' | 'invalid_cursor' | 'rate_limited' | 'unknown'; export class LiveShowError extends Error { readonly code: LiveShowErrorCode; readonly status?: number; constructor(code: LiveShowErrorCode, message: string, status?: number) { super(message); this.name = 'LiveShowError'; this.code = code; this.status = status; } } export const DEFAULT_LIVE_SHOW_SETTINGS: LiveShowSettings = { retention_window_hours: 12, moderation_mode: 'manual', playback_mode: 'newest_first', pace_mode: 'auto', fixed_interval_seconds: 8, layout_mode: 'single', effect_preset: 'film_cut', effect_intensity: 70, background_mode: 'blur_last', }; const DEFAULT_EVENT_NAME = 'Fotospiel Live Show'; function coerceLocalized(value: unknown, fallback: string): string { if (typeof value === 'string' && value.trim() !== '') { return value; } if (value && typeof value === 'object') { const obj = value as Record; const preferredKeys = ['de', 'en']; for (const key of preferredKeys) { const candidate = obj[key]; if (typeof candidate === 'string' && candidate.trim() !== '') { return candidate; } } const firstString = Object.values(obj).find((candidate) => typeof candidate === 'string' && candidate.trim() !== ''); if (typeof firstString === 'string') { return firstString; } } return fallback; } function toNumber(value: unknown, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } export function normalizeLiveShowSettings(raw?: Partial | null): LiveShowSettings { const merged = { ...DEFAULT_LIVE_SHOW_SETTINGS, ...(raw ?? {}), }; return { retention_window_hours: toNumber(merged.retention_window_hours, DEFAULT_LIVE_SHOW_SETTINGS.retention_window_hours), moderation_mode: merged.moderation_mode, playback_mode: merged.playback_mode, pace_mode: merged.pace_mode, fixed_interval_seconds: toNumber(merged.fixed_interval_seconds, DEFAULT_LIVE_SHOW_SETTINGS.fixed_interval_seconds), layout_mode: merged.layout_mode, effect_preset: merged.effect_preset, effect_intensity: toNumber(merged.effect_intensity, DEFAULT_LIVE_SHOW_SETTINGS.effect_intensity), background_mode: merged.background_mode, }; } function normalizeLiveShowEvent(raw: Record): LiveShowEvent { return { id: Number(raw.id ?? 0), slug: typeof raw.slug === 'string' ? raw.slug : null, name: coerceLocalized(raw.name, DEFAULT_EVENT_NAME), default_locale: typeof raw.default_locale === 'string' ? raw.default_locale : null, }; } function normalizeLiveShowPhoto(raw: Record): LiveShowPhoto { return { id: Number(raw.id ?? 0), full_url: String(raw.full_url ?? ''), thumb_url: typeof raw.thumb_url === 'string' ? raw.thumb_url : null, approved_at: typeof raw.approved_at === 'string' ? raw.approved_at : null, width: typeof raw.width === 'number' ? raw.width : null, height: typeof raw.height === 'number' ? raw.height : null, is_featured: Boolean(raw.is_featured), live_priority: Number(raw.live_priority ?? 0), }; } function normalizeCursor(raw: Record | null): LiveShowCursor | null { if (!raw) { return null; } return { approved_at: typeof raw.approved_at === 'string' ? raw.approved_at : null, id: Number(raw.id ?? 0), }; } function resolveErrorCode(status: number, payload: Record | null): LiveShowErrorCode { const error = typeof payload?.error === 'string' ? payload.error : null; if (error === 'live_show_not_found' || status === 404) { return 'not_found'; } if (error === 'invalid_cursor' || status === 422) { return 'invalid_cursor'; } if (status === 429) { return 'rate_limited'; } return 'unknown'; } async function handleResponse(response: Response): Promise { if (response.status === 204) { return {} as T; } const data = await response.json().catch(() => null); if (!response.ok) { const payload = data as Record | null; const code = resolveErrorCode(response.status, payload); const message = typeof payload?.message === 'string' ? payload.message : typeof payload?.error === 'string' ? payload.error : 'Live show request failed'; throw new LiveShowError(code, message, response.status); } return data as T; } function buildParams(options: { cursor?: LiveShowCursor | null; settingsVersion?: string; limit?: number; } = {}): URLSearchParams { const params = new URLSearchParams(); if (options.limit) { params.set('limit', String(options.limit)); } if (options.settingsVersion) { params.set('settings_version', options.settingsVersion); } if (options.cursor?.approved_at) { params.set('after_approved_at', options.cursor.approved_at); params.set('after_id', String(options.cursor.id)); } return params; } export function buildLiveShowStreamUrl( token: string, options: { cursor?: LiveShowCursor | null; settingsVersion?: string; limit?: number; } = {} ): string { const params = buildParams(options); const base = `/api/v1/live-show/${encodeURIComponent(token)}/stream`; const query = params.toString(); return query ? `${base}?${query}` : base; } export async function fetchLiveShowState(token: string, limit = 50): Promise { const params = new URLSearchParams(); if (limit) { params.set('limit', String(limit)); } const response = await fetch( `/api/v1/live-show/${encodeURIComponent(token)}${params.toString() ? `?${params.toString()}` : ''}`, { headers: { Accept: 'application/json' }, credentials: 'omit', } ); const data = await handleResponse>(response); const rawEvent = (data.event as Record) ?? {}; return { event: normalizeLiveShowEvent(rawEvent), settings: normalizeLiveShowSettings(data.settings as Partial | null), settings_version: String(data.settings_version ?? ''), photos: Array.isArray(data.photos) ? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record)) : [], cursor: normalizeCursor((data.cursor as Record) ?? null), }; } export async function fetchLiveShowUpdates( token: string, options: { cursor?: LiveShowCursor | null; settingsVersion?: string; limit?: number; } = {} ): Promise { const params = buildParams(options); const response = await fetch( `/api/v1/live-show/${encodeURIComponent(token)}/updates${params.toString() ? `?${params.toString()}` : ''}`, { headers: { Accept: 'application/json' }, credentials: 'omit', } ); const data = await handleResponse>(response); return { settings: data.settings ? normalizeLiveShowSettings(data.settings as Partial) : null, settings_version: String(data.settings_version ?? ''), photos: Array.isArray(data.photos) ? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record)) : [], cursor: normalizeCursor((data.cursor as Record) ?? null), }; }