import type { FollowedChannelInfo, HelixFollowedChannel, HelixPaginatedResponse, HelixStream, HelixUser, HelixVideo, VodInfo } from './types' function parseDuration(d: string): number { let s = 0 const h = d.match(/(\d+)h/) const m = d.match(/(\d+)m/) const sec = d.match(/(\d+)s/) if (h) s += parseInt(h[1]) * 3600 if (m) s += parseInt(m[1]) * 60 if (sec) s += parseInt(sec[1]) return s } const BASE = 'https://api.twitch.tv/helix' export class HelixClient { private cachedUserId: string | null = null constructor( private readonly clientId: string, private readonly getToken: () => Promise ) {} private async authHeaders(): Promise> { const token = await this.getToken() return { 'Client-Id': this.clientId, Authorization: `Bearer ${token}` } } private async get(path: string, params?: Record): Promise { const url = new URL(`${BASE}${path}`) if (params) { for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v) } const res = await fetch(url.toString(), { headers: await this.authHeaders() }) if (!res.ok) { const body = await res.text().catch(() => '') throw new Error(`Helix ${path} → ${res.status}: ${body}`) } return res.json() as Promise } async getOwnUser(): Promise { const data = await this.get<{ data: HelixUser[] }>('/users') const user = data.data[0] if (!user) throw new Error('Keine Nutzer-Daten von /users erhalten') this.cachedUserId = user.id return user } private async getOwnUserId(): Promise { if (this.cachedUserId) return this.cachedUserId const user = await this.getOwnUser() return user.id } private async getAllFollowed(userId: string): Promise { const all: HelixFollowedChannel[] = [] let cursor: string | undefined do { const params: Record = { user_id: userId, first: '100' } if (cursor) params.after = cursor const page = await this.get>( '/channels/followed', params ) all.push(...page.data) cursor = page.pagination?.cursor } while (cursor && all.length < 500) return all } private async getLiveStreams(userIds: string[]): Promise> { const map = new Map() for (let i = 0; i < userIds.length; i += 100) { const chunk = userIds.slice(i, i + 100) const url = new URL(`${BASE}/streams`) for (const id of chunk) url.searchParams.append('user_id', id) url.searchParams.set('first', '100') const res = await fetch(url.toString(), { headers: await this.authHeaders() }) if (!res.ok) continue const data = (await res.json()) as { data: HelixStream[] } for (const stream of data.data) map.set(stream.user_id, stream) } return map } private async getProfileImages(userIds: string[]): Promise> { const map = new Map() for (let i = 0; i < userIds.length; i += 100) { const chunk = userIds.slice(i, i + 100) const url = new URL(`${BASE}/users`) for (const id of chunk) url.searchParams.append('id', id) const res = await fetch(url.toString(), { headers: await this.authHeaders() }) if (!res.ok) continue const data = (await res.json()) as { data: HelixUser[] } for (const user of data.data) map.set(user.id, user.profile_image_url) } return map } async getVideos(broadcasterId: string, limit = 20): Promise { const data = await this.get>('/videos', { user_id: broadcasterId, type: 'archive', first: String(limit) }) return data.data.map((v) => ({ id: v.id, title: v.title, createdAt: v.created_at, durationSeconds: parseDuration(v.duration), viewCount: v.view_count, thumbnailUrl: v.thumbnail_url .replace('%{width}', '440') .replace('%{height}', '248') })) } async getFollowedWithLiveStatus(): Promise { const userId = await this.getOwnUserId() const followed = await this.getAllFollowed(userId) if (followed.length === 0) return [] const ids = followed.map((f) => f.broadcaster_id) const [liveMap, avatarMap] = await Promise.all([ this.getLiveStreams(ids), this.getProfileImages(ids) ]) const channels: FollowedChannelInfo[] = followed.map((f) => { const stream = liveMap.get(f.broadcaster_id) return { broadcasterId: f.broadcaster_id, broadcasterLogin: f.broadcaster_login, broadcasterName: f.broadcaster_name, profileImageUrl: avatarMap.get(f.broadcaster_id) ?? '', isLive: !!stream, streamTitle: stream?.title, gameName: stream?.game_name, viewerCount: stream?.viewer_count, thumbnailUrl: stream ? stream.thumbnail_url .replace('{width}', '440') .replace('{height}', '248') : undefined, startedAt: stream?.started_at } }) // Live-Kanäle zuerst, dann alphabetisch return channels.sort((a, b) => { if (a.isLive !== b.isLive) return a.isLive ? -1 : 1 return a.broadcasterName.localeCompare(b.broadcasterName) }) } }