Skip to content
Snippets Groups Projects
TwitchScript.js 58.9 KiB
Newer Older
Koen's avatar
Koen committed
//* Constants
const BASE_URL = 'https://www.twitch.tv/'
const CLIENT_ID = 'ue6666qo983tsx6so1t0vnawi233wa' // old: kimne78kx3ncx6brgo4mv6wki5h1ko
const GQL_URL = 'https://gql.twitch.tv/gql#origin=twilight'
const PLATFORM = 'Twitch'
const PLATFORM_CLAIMTYPE = 14;
Koen's avatar
Koen committed
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36'

//* Global Variables
let CLIENT_SESSION_ID = ''
let CLIENT_VERSION = ''
let INTEGRITY = ''

var config = {}

//* Source
/**
 * The enable endpoint gets an integrity token. These integrity tokens must be passed into the stream playback access token endpoint. The integrity endpoint always returns a token but it is not always valid. Valid tokens work for 16 hours. Valid tokens are generated through a kasada challenge. The way to tell if a token is invalid is to try an endpoint and see if it fails.
 */
source.enable = function (conf) {
    config = conf ?? {}
    CLIENT_VERSION = `3e62b6e7-8e71-47f1-a2b3-0d661abad039`

    const resp = http.POST('https://gql.twitch.tv/integrity', '', {
        'User-Agent': USER_AGENT,
        Accept: '*/*',
        DNT: '1',
        Host: 'gql.twitch.tv',
        Origin: 'https://www.twitch.tv',
        Referer: 'https://www.twitch.tv/',
        'Client-Id': CLIENT_ID,
        'Client-Version': '3e62b6e7-8e71-47f1-a2b3-0d661abad039',
        'Client-Session-Id': '',
        'Client-Request-Id': '',
        'X-Device-Id': '',
    })

    const json = JSON.parse(resp.body)

    INTEGRITY = json.token

    return INTEGRITY
}
source.getHome = function () {
    return getHomePagerPopular({ cursor: null, page_size: 20 })
}
source.searchSuggestions = function (query) {
    const gql = {
        extensions: {
            persistedQuery: {
                sha256Hash: 'b71566f2c593dd906493b0ab2012e5626c7f277d3e435504d4454de2ff15788a',
                version: 1,
            },
        },
        query: 'query SearchTray_SearchSuggestions($queryFragment: String! $requestID: ID $withOfflineChannelContent: Boolean) { searchSuggestions(queryFragment: $queryFragment requestID: $requestID withOfflineChannelContent: $withOfflineChannelContent){ edges { ...searchSuggestionNode } tracking { modelTrackingID responseID } } } fragment searchSuggestionNode on SearchSuggestionEdge { node { content { __typename ... on SearchSuggestionChannel { id isLive isVerified login profileImageURL(width: 50) user { id stream { id game { id } } } } ... on SearchSuggestionCategory { id boxArtURL(width: 30 height: 40) } } matchingCharacters { start end } id text } }',
        operationName: 'SearchTray_SearchSuggestions',
        variables: {
            queryFragment: query,
            requestID: '',
            skipSchedule: false,
        },
    }

    /** @type {import("./types.d.ts").SearchSuggestionsResponse} */
    const json = callGQL(gql)

    return json.data.searchSuggestions.edges.map((edge) => edge.node.text)
}
source.getSearchCapabilities = () => {
    return { types: [Type.Feed.Mixed], sorts: [], filters: [] }
}
source.search = function (query, type, order, filters) {
    return getSearchPagerAll({ q: query })
}
source.getSearchChannelContentsCapabilities = function () {
    return { types: [Type.Feed.Mixed], sorts: [Type.Order.Chronological], filters: [] }
}
// not in twitch
source.searchChannelContents = function (channelUrl, query, type, order, filters) {
    return []
}
source.searchChannels = function (query) {
    return getSearchPagerChannels({ q: query, page_size: 20, results_returned: 0, cursor: null })
}
source.isChannelUrl = function (url) {
    return /twitch\.tv\/[a-zA-Z0-9-_]+\/?/.test(url) || /twitch\.tv\/[a-zA-Z0-9-_]+\/videos\/?/.test(url)
}
source.getChannel = function (url) {
    const login = url.split('/').pop()

    const gql = [
        {
            query: 'query ChannelRoot_AboutPanel($channelLogin: String! $skipSchedule: Boolean!) { currentUser { id login } user(login: $channelLogin) { id description displayName isPartner primaryColorHex profileImageURL(width: 300) followers { totalCount } channel { id socialMedias { ...SocialMedia } schedule @skip(if: $skipSchedule) { id nextSegment { id startAt hasReminder } } } lastBroadcast { id game { id displayName } } primaryTeam { id name displayName } videos(first: 30 sort: TIME type: ARCHIVE) { edges { ...userBioVideo } } } } fragment userBioVideo on VideoEdge { node { id game { id displayName } status } } fragment SocialMedia on SocialMedia { id name title url }',
            operationName: 'ChannelRoot_AboutPanel',
            variables: {
                channelLogin: login,
                skipSchedule: false,
            },
            extensions: {
                persistedQuery: {
                    sha256Hash: '6089531acef6c09ece01b440c41978f4c8dc60cb4fa0124c9a9d3f896709b6c6',
                    version: 1,
                },
            },
        },
        {
            query: '#import "./query-channel-with-home-prefs-fragment.gql" query ChannelShell($login: String!) { userOrError: userResultByLogin(login: $login) { ...coreChannelWithHomePrefsFragment ... on UserDoesNotExist { userDoesNotExist: key reason } ... on UserError { userError: key } } }',
            operationName: 'ChannelShell',
            variables: {
                login: login,
            },
            extensions: {
                persistedQuery: {
                    sha256Hash: '580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe',
                    version: 1,
                },
            },
        },
    ]

    const json = callGQL(gql)

    /** @type {import("./types.d.ts").ChannelAboutResponse} */
    const user_resp = json[0]
    const user = user_resp.data.user

    /** @type {import("./types.d.ts").ChannelShellResponse} */
    const shell_resp = json[1]
    const shell = shell_resp.data.userOrError

    return new PlatformChannel({
        id: new PlatformID(PLATFORM, user.id, config.id, PLATFORM_CLAIMTYPE),
Koen's avatar
Koen committed
        name: user.displayName,
        thumbnail: user.profileImageURL,
        banner: shell.bannerImageURL,
        subscribers: user.followers.totalCount,
        description: user.description,
        url: BASE_URL + login,
        links: user.channel.socialMedias.map((social) => social.url),
    })
}
source.getChannelContents = function (url) {
    return getChannelPager({ url, page_size: 20, cursor: null })
}
source.isContentDetailsUrl = function (url) {
    // https://www.twitch.tv/user or https://www.twitch.tv/videos/123456789
    return /twitch\.tv\/[a-zA-Z0-9-_]+\/?/.test(url) || /twitch\.tv\/videos\/[0-9]+\/?/.test(url)
}
source.getContentDetails = function (url) {
    if (url.includes('/video/') || url.includes('/videos/')) {
        return getSavedVideo(url)
    } else {
        return getLiveVideo(url)
    }
}
source.getUserSubscriptions = function () {
    const gql = {
        query: 'query PersonalSections( $input: PersonalSectionInput! $creatorAnniversariesFeature: Boolean! ) { personalSections(input: $input) { type title { ...personalSectionTitle } items { ...personalSectionItem } } } fragment personalSectionTitle on PersonalSectionTitle { localizedFallback localizedTokens { ... on PersonalSectionTextToken { value } ... on User { id login displayName } } } fragment personalSectionItem on PersonalSectionChannel { trackingID promotionsCampaignID user { ...personalSectionItemUser } label content { ...personalSectionsStream } } fragment personalSectionItemUser on User { id login displayName profileImageURL(width: 70) primaryColorHex broadcastSettings { id title } channel @include(if: $creatorAnniversariesFeature) { id activeCreatorEventCelebration { id } } } fragment personalSectionsStream on Stream { id previewImageURL(width: 320 height: 180) broadcaster { id broadcastSettings { id title } } viewersCount game { id displayName name } type }',
        extensions: {
            persistedQuery: {
                sha256Hash: '807e3cce07a1cef5c772bbc46c12ead2898edd043ad4dd2236707f6f7995769c',
                version: 1,
            },
        },
        operationName: 'PersonalSections',
        variables: {
            creatorAnniversariesFeature: false,
            input: {
                recommendationContext: {
                    categoryName: null,
                    channelName: null,
                    clientApp: 'twilight',
                    lastCategoryName: null,
                    lastChannelName: null,
                    pageviewContent: null,
                    pageviewContentType: null,
                    pageviewLocation: null,
                    pageviewMedium: null,
                    platform: 'web',
                    previousPageviewContent: null,
                    previousPageviewContentType: null,
                    previousPageviewLocation: null,
                    previousPageviewMedium: null,
                },
                sectionInputs: ['RECS_FOLLOWED_SECTION'],
            },
        },
    }

    /** @type {import("./types.d.ts").PersonalSectionsFollowedResponse} */
    const json = callGQL(gql, true)

    const sections = json.data.personalSections[0]

    if (sections.type !== 'RECS_FOLLOWED_SECTION') throw new ScriptException('Authentication Failed')

    return sections.items.map((section) => BASE_URL + section.user.login)
}

/**
Loading
Loading full blame...