// Twitch Helix Top-Clips Crawler. Pure: fetch wird via injizierter fetchImpl // aufgerufen (Tests koennen mocken). Helix-Endpunkt: // GET https://api.twitch.tv/helix/clips?broadcaster_id=X&first=N // // Auth: Client-Credentials (app-token) reicht — kein User-Token noetig. // Spaeter koennen wir aus token-store den default-Twitch-User-Token nehmen. const HELIX_CLIPS_URL = 'https://api.twitch.tv/helix/clips'; export interface TopClip { id: string; url: string; embedUrl: string; broadcasterId: string; broadcasterName: string; creatorId: string; creatorName: string; videoId: string; gameId: string; language: string; title: string; viewCount: number; createdAt: string; // ISO timestamp thumbnailUrl: string; duration: number; // seconds vodOffsetSeconds: number | null; } interface HelixClipRow { id: string; url: string; embed_url: string; broadcaster_id: string; broadcaster_name: string; creator_id: string; creator_name: string; video_id: string; game_id: string; language: string; title: string; view_count: number; created_at: string; thumbnail_url: string; duration: number; vod_offset?: number | null; } interface HelixClipsResponse { data?: HelixClipRow[]; pagination?: { cursor?: string }; } export interface FetchTopClipsOptions { clientId: string; accessToken: string; broadcasterId: string; startedAt?: string; // ISO RFC3339 endedAt?: string; first?: number; // 1-100, default 20 fetchImpl?: typeof fetch; } function rowToClip(row: HelixClipRow): TopClip { return { id: row.id, url: row.url, embedUrl: row.embed_url, broadcasterId: row.broadcaster_id, broadcasterName: row.broadcaster_name, creatorId: row.creator_id, creatorName: row.creator_name, videoId: row.video_id, gameId: row.game_id, language: row.language, title: row.title, viewCount: row.view_count, createdAt: row.created_at, thumbnailUrl: row.thumbnail_url, duration: row.duration, vodOffsetSeconds: row.vod_offset ?? null, }; } export async function fetchTopClips(opts: FetchTopClipsOptions): Promise { const fetchFn = opts.fetchImpl ?? fetch; const first = Math.min(100, Math.max(1, opts.first ?? 20)); const params = new URLSearchParams({ broadcaster_id: opts.broadcasterId, first: String(first), }); if (opts.startedAt) params.set('started_at', opts.startedAt); if (opts.endedAt) params.set('ended_at', opts.endedAt); const res = await fetchFn(`${HELIX_CLIPS_URL}?${params.toString()}`, { headers: { 'Authorization': `Bearer ${opts.accessToken}`, 'Client-Id': opts.clientId, }, }); const text = await res.text(); if (!res.ok) { throw new Error(`top-clips-crawler: helix ${res.status}: ${text}`); } let parsed: HelixClipsResponse; try { parsed = JSON.parse(text) as HelixClipsResponse; } catch (e) { throw new Error(`top-clips-crawler: parse failed: ${e instanceof Error ? e.message : String(e)}`); } const rows = parsed.data ?? []; // Helix returns clips already sorted by view_count desc, but we re-sort // defensively in case that order ever changes. return rows.map(rowToClip).sort((a, b) => b.viewCount - a.viewCount); } export interface DateRange { startedAt: string; endedAt: string; } /** * Convenience: ISO range fuer "letzte N Tage" ab jetzt. Twitch erwartet * RFC3339 Format (`2026-05-11T00:00:00Z`). */ export function rangeLastDays(days: number, now: Date = new Date()): DateRange { const end = new Date(now.getTime()); const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); return { startedAt: start.toISOString(), endedAt: end.toISOString(), }; }