Twitch-VOD-Manager/src/main/domain/top-clips-crawler.ts
xRangerDE dc2b609132 feat(discovery): Helix top-clips-crawler module + rangeLastDays helper (9 tests)
Pillar 7 (Auto-Discovery) addition. Pure module — fetchImpl injizierbar fuer
Tests. Helix /clips endpoint, supports broadcaster_id + first (clamped 1-100) +
started_at/ended_at. Returns sorted-desc by view_count. snake_case Helix-
Felder werden auf camelCase im Public-Interface gemappt.

219 unit tests. IPC wiring + Auto-Discovery-Scheduler kommt post-5.1.0-alpha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:00:38 +02:00

136 lines
3.9 KiB
TypeScript

// 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<TopClip[]> {
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(),
};
}