303 lines
8.8 KiB
TypeScript
303 lines
8.8 KiB
TypeScript
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<string, unknown>;
|
|
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<LiveShowSettings> | 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<string, unknown>): 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<string, unknown>): 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<string, unknown> | 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<string, unknown> | 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<T>(response: Response): Promise<T> {
|
|
if (response.status === 204) {
|
|
return {} as T;
|
|
}
|
|
|
|
const data = await response.json().catch(() => null);
|
|
|
|
if (!response.ok) {
|
|
const payload = data as Record<string, unknown> | 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<LiveShowState> {
|
|
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<Record<string, unknown>>(response);
|
|
const rawEvent = (data.event as Record<string, unknown>) ?? {};
|
|
|
|
return {
|
|
event: normalizeLiveShowEvent(rawEvent),
|
|
settings: normalizeLiveShowSettings(data.settings as Partial<LiveShowSettings> | null),
|
|
settings_version: String(data.settings_version ?? ''),
|
|
photos: Array.isArray(data.photos)
|
|
? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record<string, unknown>))
|
|
: [],
|
|
cursor: normalizeCursor((data.cursor as Record<string, unknown>) ?? null),
|
|
};
|
|
}
|
|
|
|
export async function fetchLiveShowUpdates(
|
|
token: string,
|
|
options: {
|
|
cursor?: LiveShowCursor | null;
|
|
settingsVersion?: string;
|
|
limit?: number;
|
|
} = {}
|
|
): Promise<LiveShowUpdates> {
|
|
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<Record<string, unknown>>(response);
|
|
|
|
return {
|
|
settings: data.settings ? normalizeLiveShowSettings(data.settings as Partial<LiveShowSettings>) : null,
|
|
settings_version: String(data.settings_version ?? ''),
|
|
photos: Array.isArray(data.photos)
|
|
? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record<string, unknown>))
|
|
: [],
|
|
cursor: normalizeCursor((data.cursor as Record<string, unknown>) ?? null),
|
|
};
|
|
}
|