mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
- VOD Historie in Channelscreen implementiert - VOD-Wiedergabe + Stop-Verhalten verifiziert
168 lines
5.3 KiB
TypeScript
168 lines
5.3 KiB
TypeScript
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<string>
|
|
) {}
|
|
|
|
private async authHeaders(): Promise<Record<string, string>> {
|
|
const token = await this.getToken()
|
|
return {
|
|
'Client-Id': this.clientId,
|
|
Authorization: `Bearer ${token}`
|
|
}
|
|
}
|
|
|
|
private async get<T>(path: string, params?: Record<string, string>): Promise<T> {
|
|
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<T>
|
|
}
|
|
|
|
async getOwnUser(): Promise<HelixUser> {
|
|
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<string> {
|
|
if (this.cachedUserId) return this.cachedUserId
|
|
const user = await this.getOwnUser()
|
|
return user.id
|
|
}
|
|
|
|
private async getAllFollowed(userId: string): Promise<HelixFollowedChannel[]> {
|
|
const all: HelixFollowedChannel[] = []
|
|
let cursor: string | undefined
|
|
do {
|
|
const params: Record<string, string> = { user_id: userId, first: '100' }
|
|
if (cursor) params.after = cursor
|
|
const page = await this.get<HelixPaginatedResponse<HelixFollowedChannel>>(
|
|
'/channels/followed',
|
|
params
|
|
)
|
|
all.push(...page.data)
|
|
cursor = page.pagination?.cursor
|
|
} while (cursor && all.length < 500)
|
|
return all
|
|
}
|
|
|
|
private async getLiveStreams(userIds: string[]): Promise<Map<string, HelixStream>> {
|
|
const map = new Map<string, HelixStream>()
|
|
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<Map<string, string>> {
|
|
const map = new Map<string, string>()
|
|
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<VodInfo[]> {
|
|
const data = await this.get<HelixPaginatedResponse<HelixVideo>>('/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<FollowedChannelInfo[]> {
|
|
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)
|
|
})
|
|
}
|
|
}
|