import { LooseObject } from "../../../types/looseTypes" import { StreamerData, StreamData, Social, Badge } from "../../../types/scraping/Streamer" import { Category, Tag } from "../../../types/scraping/Category" import { CategoryData, CategoryMinifiedStream } from "../../../types/scraping/CategoryData" import { SearchResult } from "../../../types/scraping/Search" import { bttvEmotesHandler, ffzEmotesHandler } from "./emoteHandler" const base64 = (data: String) => { return Buffer.from(data).toString('base64url') } /** * Class that interacts with the Twitch api */ export class TwitchAPI { public readonly twitchUrl = 'https://gql.twitch.tv/gql' public headers = { "Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko" } constructor() { } /** * Gets information about a streamer, like socials, about, and more. * @see StreamerData * @param streamerName The username of the streamer * @returns Promise */ public getStreamerInfo = async (streamerName: string) => { const payload = [ { "operationName": "ChannelRoot_AboutPanel", "variables": { "channelLogin": streamerName, "skipSchedule": false }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "6089531acef6c09ece01b440c41978f4c8dc60cb4fa0124c9a9d3f896709b6c6" } } }, { "operationName": "StreamMetadata", "variables": { "channelLogin": streamerName }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "a647c2a13599e5991e175155f798ca7f1ecddde73f7f341f39009c14dbf59962" } } }, { "operationName": "StreamTagsTrackingChannel", "variables": { "channel": streamerName }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "6aa3851aaaf88c320d514eb173563d430b28ed70fdaaf7eeef6ed4b812f48608" } } }, { "operationName": "VideoPreviewOverlay", "variables": { "login": streamerName }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "9515480dee68a77e667cb19de634739d33f243572b007e98e67184b1a5d8369f" } } }, { "operationName": "UseViewCount", "variables": { "channelLogin": streamerName }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "00b11c9c428f79ae228f30080a06ffd8226a1f068d6f52fbc057cbde66e994c2" } } }, ] const res = await fetch(this.twitchUrl, { method: 'POST', body: JSON.stringify(payload), headers: this.headers }) const data = await res.json() const rawStreamerData = data[0].data // get socials const socials: LooseObject[] = [] if (rawStreamerData.user.channel && rawStreamerData.user.channel.socialMedias) { for (let social of rawStreamerData.user.channel.socialMedias) { socials.push({ type: social.name, name: social.title, link: social.url }) } } // check if is liver const rawStreamData = data[1].data.user.stream let parsedStream: StreamData | null; if (!rawStreamData) { parsedStream = null } else { const tags: string[] = [] for (let tagData of data[2].data.user.stream.freeformTags) { tags.push(tagData.name) } const previewUrl = `${process.env.URL}/proxy/img/${base64(data[3].data.user.stream.previewImageURL)}` parsedStream = { title: data[1].data.user.lastBroadcast.title, topic: rawStreamData.game.name, startedAt: new Date(rawStreamData.createdAt).valueOf(), tags, viewers: Number(data[4].data.user.stream.viewersCount), preview: previewUrl } } const abbreviatedFollowers = Intl.NumberFormat('en-US', { notation: "compact", maximumFractionDigits: 1 }).format(rawStreamerData.user.followers.totalCount) const streamerData: StreamerData = { username: rawStreamerData.user.displayName, about: rawStreamerData.user.description, pfp: `${process.env.URL}/proxy/img/${base64(rawStreamerData.user.profileImageURL)}`, followers: rawStreamerData.user.followers.totalCount, socials: socials as Social[], isLive: (!!parsedStream), isPartner: rawStreamerData.user.isPartner, colorHex: '#' + rawStreamerData.user.primaryColorHex, id: Number(rawStreamerData.user.id), stream: parsedStream } return Promise.resolve(streamerData) } /** * Gets the current viewers of a stream * @param streamerName The username of the streamer * @returns Promise */ public getViewers = async (streamerName: string) => { const payload = [ { "operationName": "UseViewCount", "variables": { "channelLogin": streamerName }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "00b11c9c428f79ae228f30080a06ffd8226a1f068d6f52fbc057cbde66e994c2" } } }, ] const res = await fetch(this.twitchUrl, { method: 'POST', body: JSON.stringify(payload), headers: this.headers }) const rawData = await res.json() if (!rawData[0].data.user.stream) return Promise.reject(new Error(`Streamer ${streamerName} is not live`)) return Promise.resolve(rawData[0].data.user.stream.viewersCount) } public isLive = async (streamerName: string) => { const payload = [ { "operationName": "UseViewCount", "variables": { "channelLogin": streamerName }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "00b11c9c428f79ae228f30080a06ffd8226a1f068d6f52fbc057cbde66e994c2" } } }, ] const res = await fetch(this.twitchUrl, { method: 'POST', body: JSON.stringify(payload), headers: this.headers }) const rawData = await res.json() if (!rawData[0].data.user.stream) return Promise.resolve(false) return Promise.resolve(true) } /** * Gets the stream playlist file from twitch * @param streamerName The username of the streamer * @returns Promise */ public getStream = async (streamerName: string) => { const isLive = await this.isLive(streamerName) if (!isLive) return Promise.reject(new Error(`Streamer ${streamerName} is not live`)) // Get token const payload = { "operationName": "PlaybackAccessToken_Template", "query": "query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: \"web\", playerBackend: \"mediaplayer\", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: \"web\", playerBackend: \"mediaplayer\", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}", "variables": { "isLive": true, "login": streamerName, "isVod": false, "vodID": "", "playerType": "site" } } var res = await fetch(this.twitchUrl, { headers: this.headers, body: JSON.stringify(payload), method: 'POST' }) const data = await res.json() const token = data.data.streamPlaybackAccessToken.value const signature = data.data.streamPlaybackAccessToken.signature const playlistUrl = `https://usher.ttvnw.net/api/channel/hls/${streamerName.toLowerCase()}.m3u8` const params = `?sig=${signature}&token=${token}` var res = await fetch(playlistUrl + params, { headers: this.headers }) return await res.text() } /** * Gets the homepage discovery tab of twitch * @param limit Maximum categories to get * @param cursor The current page you're at (for pagination) */ public getDirectory = async (limit: number, cursor?: string) => { const payload: any[] = [ { "operationName": "BrowsePage_AllDirectories", "variables": { "limit": limit, "options": { "recommendationsContext": { "platform": "web" }, "requestID": "JIRA-VXP-2397", "sort": "RELEVANCE", "tags": [] } }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "1d1914ca3cbfaa607ecd5595b2e305e96acf987c8f25328f7713b25f604c4668" } } } ] if (cursor) payload[0].variables.cursor = cursor const res = await fetch(this.twitchUrl, { method: 'POST', body: JSON.stringify(payload), headers: this.headers }) const data = await res.json() const categories = data[0].data.directoriesWithTags.edges let formattedCategories: Category[] = [] for (let category of categories) { let tags: Tag[] = [] for (let tag of category.node.tags) { tags.push(tag.localizedName) } formattedCategories.push({ name: category.node.name, displayName: category.node.displayName, viewers: category.node.viewersCount, tags: tags, createdAt: category.node.originalReleaseDate, cursor: category.cursor, image: `${process.env.URL}/proxy/img/${base64(category.node.avatarURL)}` }) } return formattedCategories } public getDirectoryGame = async (name: string, streamLimit: number, cursor?: string) => { const payload: any[] = [ { "operationName": "DirectoryPage_Game", "variables": { "imageWidth": 50, "name": name, "options": { "sort": "RELEVANCE", "recommendationsContext": { "platform": "web" }, "requestID": "JIRA-VXP-2397", "freeformTags": null, "tags": [] }, "sortTypeIsRecency": false, "limit": streamLimit }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "df4bb6cc45055237bfaf3ead608bbafb79815c7100b6ee126719fac3762ddf8b" } } }, { "operationName": "Directory_DirectoryBanner", "variables": { "name": name }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "2670fbecd8fbea0211c56528d6eff5752ef9d6c73cd5238d395784b46335ded4" } } }, ] if (cursor) payload[0].variables.cursor = cursor const res = await fetch(this.twitchUrl, { method: 'POST', body: JSON.stringify(payload), headers: this.headers }) const data = await res.json() if (!data[0].data.game) return null; let streams = [] if (data[0].data.game.streams) streams = data[0].data.game.streams.edges let formatedStreams: CategoryMinifiedStream[] = [] for (let stream of streams) { let tags = [] for (let tag of stream.node.freeformTags) { tags.push(tag.name) } formatedStreams.push({ title: stream.node.title, viewers: stream.node.viewersCount, preview: `${process.env.URL}/proxy/img/${base64(stream.node.previewImageURL)}`, tags, cursor: stream.cursor, streamer: { name: stream.node.broadcaster.displayName, pfp: `${process.env.URL}/proxy/img/${base64(stream.node.broadcaster.profileImageURL)}`, colorHex: stream.node.broadcaster.primaryColorHex } }) } const rawGameData = data[1].data.game; let tags: Tag[] = [] for (let tag of rawGameData.tags) { tags.push(tag.tagName) } const formatedGameData: CategoryData = { name: rawGameData.name, cover: `${process.env.URL}/proxy/img/${base64(rawGameData.avatarURL)}`, description: rawGameData.description, viewers: rawGameData.viewersCount, followers: rawGameData.followersCount, tags, streams: formatedStreams } return formatedGameData } public getTwitchBadges = async () => { const payload = [ { "operationName": "ChannelPointsPredictionBadges", "variables": {}, "extensions": { "persistedQuery": { "sha256Hash": "36995b30b22c31d1cd0aa329987ac9b5368bb7e6e1ab1df42808bdaa80a6dbf9", "version": 1 } }, } ] const res = await fetch(this.twitchUrl, { method: 'POST', body: JSON.stringify(payload), headers: this.headers }) const data = await res.json() if (!data[0].data.badges) return null; let formatedBadges: Badge[] = [] for (let badge of data[0].data.badges) { let formatedBadge: Badge = { id: Buffer.from(badge.id, 'base64').toString(), setId: badge.setID, title: badge.title, version: badge.version, images: { image1x: `${process.env.URL}/proxy/img/${base64(badge.image1x)}`, image2x: `${process.env.URL}/proxy/img/${base64(badge.image2x)}`, image4x: `${process.env.URL}/proxy/img/${base64(badge.image4x)}`, } } formatedBadges.push(formatedBadge) } return formatedBadges } public getStreamerBadges = async (streamerName: string) => { const payload = { "extensions": { "persistedQuery": { "sha256Hash": "86f43113c04606e6476e39dcd432dee47c994d77a83e54b732e11d4935f0cd08", "version": 1 } }, "operationName": "ChatList_Badges", "variables": { "channelLogin": streamerName } } const res = await fetch(this.twitchUrl, { method: 'POST', body: JSON.stringify(payload), headers: this.headers }) const data = await res.json() if (!data.data.user.broadcastBadges) return null; let formatedBadges: Badge[] = [] for (let badge of data.data.user.broadcastBadges) { let formatedBadge: Badge = { id: Buffer.from(badge.id, 'base64').toString(), setId: badge.setID, title: badge.title, version: badge.version, images: { image1x: `${process.env.URL}/proxy/img/${base64(badge.image1x)}`, image2x: `${process.env.URL}/proxy/img/${base64(badge.image2x)}`, image4x: `${process.env.URL}/proxy/img/${base64(badge.image4x)}`, } } formatedBadges.push(formatedBadge) } return formatedBadges } public getSearchResult = async (query: string) => { const payload = { "operationName": "SearchResultsPage_SearchResults", "variables": { "query": query, "options": null, "requestID": "75948144-d051-4203-8511-57f3ee9b809a" }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "6ea6e6f66006485e41dbe3ebd69d5674c5b22896ce7b595d7fce6411a3790138" } } } const res = await fetch(this.twitchUrl, { method: 'POST', body: JSON.stringify(payload), headers: this.headers }) const data = await res.json() const resultsData = data.data.searchFor const formattedStreamers: StreamerData[] = resultsData.channels.edges.map((data: any) => { return { username: data.item.login, followers: data.item.followers.totalCount, isLive: !(data.item.stream === null), about: data.item.description, pfp: `${process.env.URL}/proxy/img/${base64(data.item.profileImageURL)}`, isPartner: null, colorHex: '#fff', id: Number(data.item.channel.id) } }) const foundCategories: Category[] = [] for (let category of resultsData.games.edges) { let tags = category.item.tags.map((data: { tagName: string }) => { return data.tagName }) foundCategories.push({ name: category.item.name, displayName: category.item.displayName, viewers: category.item.viewersCount, tags, image: category.item.boxArtURL, }) } const foundRelatedLiveChannels: StreamData[] = await Promise.all(resultsData.relatedLiveChannels.edges.map(async (data: any) => { return await this.getStreamerInfo(data.item.stream.broadcaster.login) })) const foundChannelsWithTag: StreamData[] = resultsData.channelsWithTag.edges.map((data: any) => { return { username: data.item.login, followers: data.item.followers.totalCount, isLive: !(data.item.stream === null), about: data.item.description, pfp: `${process.env.URL}/proxy/img/${base64(data.item.profileImageURL)}`, isPartner: null, colorHex: '#fff', id: Number(data.item.channel.id) } }) const finalData: SearchResult = { channels: formattedStreamers, categories: foundCategories, relatedChannels: foundRelatedLiveChannels, channelsWithTag: foundChannelsWithTag } return finalData } public getStreamerId = async (channelName: string) => { const payload = { "operationName": "ChannelRoot_AboutPanel", "variables": { "channelLogin": channelName, "skipSchedule": true }, "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "6089531acef6c09ece01b440c41978f4c8dc60cb4fa0124c9a9d3f896709b6c6" } } } const res = await fetch(this.twitchUrl, { method: 'POST', body: JSON.stringify(payload), headers: this.headers }) const data = await res.json() if (!data.data.user) { return Promise.reject(new Error(`Steamer ${channelName} is offline.`)) } return Number(data.data.user.id) } public getEmotes = async (channelName: string) => { let id = await this.getStreamerId(channelName) const ffzUrl = 'https://api.frankerfacez.com/v1/room/id/' + id const bttvGlobalUrl = 'https://api.betterttv.net/3/cached/users/twitch/121059319' const bttvChannelUrl = 'https://api.betterttv.net/3/cached/users/twitch/' + id let res = await fetch(ffzUrl) let data = await res.json() const ffzEmotes = ffzEmotesHandler(data) res = await fetch(bttvGlobalUrl) data = await res.json() const bttvGlobalEmotes = bttvEmotesHandler([...data.channelEmotes, ...data.sharedEmotes]) res = await fetch(bttvChannelUrl) data = await res.json() const bttvChannelEmotes = bttvEmotesHandler(data.channelEmotes) return [ ...ffzEmotes, ...bttvGlobalEmotes, ...bttvChannelEmotes] } }