import 'dart:math'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_integrity.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/playback_access_token.dart'; import 'package:frosty/models/stream.dart'; /// Viewer sort for GQL directory / tag stream lists. enum StreamViewerSort { viewersDesc, viewersAsc; String get gqlValue => switch (this) { StreamViewerSort.viewersDesc => 'VIEWER_COUNT', StreamViewerSort.viewersAsc => 'VIEWER_COUNT_ASC', }; } /// Twitch GQL API client for playback tokens and directory queries /// (freeform tags + sorted streams) that Helix does not expose. /// /// Uses the Twitch web client ID (not the app's registered client ID) /// since the GQL endpoint requires it. class TwitchGqlApi extends BaseApiClient { TwitchGqlApi(Dio dio) : super(dio, 'https://gql.twitch.tv'); static const _gqlHeaders = { 'Client-ID': twitchGqlClientId, 'Content-Type': 'application/json', }; Future _gql( Map body, { Map? extraHeaders, }) async { final response = await post( '/gql', data: body, headers: {..._gqlHeaders, ...?extraHeaders}, ); final errors = response['errors']; if (errors is List && errors.isNotEmpty) { final message = (errors.first as JsonMap)['message'] as String? ?? 'Twitch GQL error'; if (message.toLowerCase().contains('integrity')) { throw IntegrityCheckException(message); } final data = response['data']; final dataEmpty = data == null || (data is Map && data.values.every((value) => value == null)); if (dataEmpty) { throw ApiException(message); } } return response; } /// Optional web-session headers for directory pagination (page-2+). Map _directoryHeaders({ String? authToken, String? integrityToken, String? deviceId, }) { return { 'User-Agent': playbackUserAgent, if (authToken != null && authToken.isNotEmpty) 'Authorization': 'OAuth $authToken', if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId, if (integrityToken != null && integrityToken.isNotEmpty) 'Client-Integrity': integrityToken, }; } /// Browser-consistent User-Agent for playback-related requests. Twitch has /// rejected access-token requests with non-browser UAs before /// (streamlink#6574); sending one consistent with the web client ID and /// web session token avoids the obvious fingerprint mismatch. static const playbackUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'; /// Headers for the HLS playlist/segment requests issued by the native /// player, mirroring what the web player sends to Usher/weaver. static const playbackHttpHeaders = { 'User-Agent': playbackUserAgent, 'Referer': 'https://player.twitch.tv', 'Origin': 'https://player.twitch.tv', }; /// Fetches a playback access token for the given channel [login]. /// /// When [authToken] is provided, the request is authenticated which may /// grant ad-free playback for subscribers and Turbo users. /// /// [playerType] / [platform] mirror the web player's GQL variables. Ad /// blocking (TwitchAdSolutions / vaft) uses non-`site` player types such as /// `popout` / `embed` / `autoplay`. /// /// Uses the web client's persisted GQL query (smaller payload, blends in /// with regular web traffic); falls back to the inline query template if /// Twitch ever rotates the persisted-query hash. Future getPlaybackAccessToken({ required String login, String? authToken, String playerType = 'site', String platform = 'web', }) async { final headers = { 'Client-ID': twitchGqlClientId, 'User-Agent': playbackUserAgent, if (authToken != null) 'Authorization': 'OAuth $authToken', }; final variables = { 'isLive': true, 'login': login, 'isVod': false, 'vodID': '', 'playerType': playerType, 'platform': platform, }; Future query(Map body) => post('/gql', data: body, headers: headers); final response = await query({ 'operationName': 'PlaybackAccessToken', 'extensions': { 'persistedQuery': { 'version': 1, // Stable for years; used by the web player and streamlink. 'sha256Hash': 'ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9', }, }, 'variables': variables, }); // PersistedQueryNotFound (or similar) comes back as a 200 with an // errors array — retry once with the inline template before failing. if (response['errors'] != null && response['data'] == null) { debugPrint( 'TwitchGqlApi: persisted query failed (${response['errors']}), ' 'falling back to inline template', ); return PlaybackAccessToken.fromGqlResponse( await query({ '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': variables, }), ); } return PlaybackAccessToken.fromGqlResponse(response); } /// Fetches raw playlist text (multivariant or media) with playback headers. Future fetchPlaylistText(String url) async { return get(url, headers: playbackHttpHeaders); } /// Builds the HLS stream URL for the given channel [login] and [token]. String buildHlsUrl({ required String login, required PlaybackAccessToken token, }) { final random = Random().nextInt(999999); final encodedToken = Uri.encodeComponent(token.value); // Usher v2 — what the web player and streamlink use today. Same query // params and token; the multivariant playlist is standards-compliant // (EXT-X-SESSION-DATA + STREAM-INF only, no EXT-X-MEDIA/TWITCH-INFO). return 'https://usher.ttvnw.net/api/v2/channel/hls/$login.m3u8' '?sig=${token.signature}' '&token=$encodedToken' '&allow_source=true' '&allow_audio_only=true' '&fast_bread=true' '&supported_codecs=av1,h265,h264' '&reassignments_supported=true' '&platform=web' '&player_backend=mediaplayer' '&play_session_id=${_randomSessionId()}' '&playlist_include_framerate=true' '&p=$random'; } /// 32-char hex play-session id, matching the web client and streamlink. Sent /// to Usher to identify the playback session; Usher tolerates its absence /// today but has historically tightened required params. String _randomSessionId() { final random = Random(); final buffer = StringBuffer(); for (var i = 0; i < 32; i++) { buffer.write(random.nextInt(16).toRadixString(16)); } return buffer.toString(); } /// Freeform tag name suggestions for search-as-you-type. Future> searchFreeformTags({ required String query, int first = 20, }) async { if (query.trim().isEmpty) return const []; final response = await _gql({ 'operationName': 'SearchFreeformTags', 'query': r'query SearchFreeformTags($query: String!, $first: Int) {' r' searchFreeformTags(userQuery: $query, first: $first) {' r' edges { node { tagName } } } }', 'variables': {'query': query, 'first': first}, }); final edges = (((response['data'] as JsonMap?)?['searchFreeformTags'] as JsonMap?)?['edges'] as List?) ?? const []; return edges .map((edge) { final node = (edge as JsonMap)['node'] as JsonMap?; return node?['tagName'] as String?; }) .whereType() .toList(); } /// Live streams matching optional [freeformTags] / [languages], sorted by viewers. /// /// Twitch caps `first` at 30 for this connection. Cursor pagination needs a /// Client-Integrity token (+ matching [deviceId] / web [authToken]); without /// them page-2 throws [IntegrityCheckException]. Future getStreams({ List? freeformTags, List? languages, StreamViewerSort sort = StreamViewerSort.viewersDesc, String? cursor, int first = 30, String? authToken, String? integrityToken, String? deviceId, }) async { final cappedFirst = first.clamp(1, 30); final gqlLangs = languages == null || languages.isEmpty ? null : languages .map((l) => l.trim().toUpperCase()) .where((l) => l.isNotEmpty) .toList(); final response = await _gql( { 'operationName': 'FrostyTopStreams', 'query': r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {' r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {' r' edges { cursor node {' r' id title viewersCount createdAt language' r' game { id name }' r' broadcaster { id login displayName }' r' } } pageInfo { hasNextPage } } }', 'variables': { 'first': cappedFirst, 'after': cursor, 'tags': freeformTags, 'sort': sort.gqlValue, 'langs': gqlLangs, }, }, extraHeaders: _directoryHeaders( authToken: authToken, integrityToken: integrityToken, deviceId: deviceId, ), ); final connection = (response['data'] as JsonMap?)?['streams'] as JsonMap? ?? const {}; return _streamsFromConnection(connection); } /// Live streams under a category/game, with viewer sort and optional languages. Future getGameStreams({ required String gameId, List? languages, StreamViewerSort sort = StreamViewerSort.viewersDesc, String? cursor, int first = 30, String? authToken, String? integrityToken, String? deviceId, }) async { final cappedFirst = first.clamp(1, 30); final gqlLangs = languages == null || languages.isEmpty ? null : languages .map((l) => l.trim().toUpperCase()) .where((l) => l.isNotEmpty) .toList(); final response = await _gql( { 'operationName': 'FrostyGameStreams', 'query': r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {' r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {' r' edges { cursor node {' r' id title viewersCount createdAt language' r' game { id name }' r' broadcaster { id login displayName }' r' } } pageInfo { hasNextPage } } } }', 'variables': { 'id': gameId, 'first': cappedFirst, 'after': cursor, 'sort': sort.gqlValue, 'langs': gqlLangs, }, }, extraHeaders: _directoryHeaders( authToken: authToken, integrityToken: integrityToken, deviceId: deviceId, ), ); final game = (response['data'] as JsonMap?)?['game'] as JsonMap?; if (game == null) { return const StreamsTwitch([], {}); } return _streamsFromConnection(game['streams'] as JsonMap? ?? const {}); } /// Persisted hashes used by the Twitch web Follow button (same as Xtra). static const _followUserHash = '800e7346bdf7e5278a3c1d3f21b2b56e2639928f86815677a7126b093b2fdd08'; static const _unfollowUserHash = 'f7dae976ebf41c755ae2d758546bfd176b4eeb856656098bb40e0a672ca0d880'; /// Follows [targetId] using the web GQL session. Helix no longer supports this. /// /// Requires [authToken] from the Twitch web cookie (`gqlToken`). Some accounts /// need a [integrityToken] + matching [deviceId]; callers should retry after /// [fetchIntegrityToken] when [IntegrityCheckException] is thrown. Future followUser({ required String targetId, required String authToken, bool disableNotifications = false, String? integrityToken, String? deviceId, }) async { await _mutateFollow( operationName: 'FollowButton_FollowUser', sha256Hash: _followUserHash, authToken: authToken, integrityToken: integrityToken, deviceId: deviceId, variables: { 'input': { 'disableNotifications': disableNotifications, 'targetID': targetId, }, }, successPath: const ['followUser'], ); } /// Unfollows [targetId] using the web GQL session. Future unfollowUser({ required String targetId, required String authToken, String? integrityToken, String? deviceId, }) async { await _mutateFollow( operationName: 'FollowButton_UnfollowUser', sha256Hash: _unfollowUserHash, authToken: authToken, integrityToken: integrityToken, deviceId: deviceId, variables: { 'input': {'targetID': targetId}, }, successPath: const ['unfollowUser'], ); } /// Mints a Client-Integrity token for authenticated GQL mutations. /// /// Bound to [deviceId] and [authToken]; send the same values on follow calls. /// Tokens minted outside a real browser are usually flagged `is_bad_bot` — /// those throw [IntegrityCheckException] so callers can fall back to a /// WebView capture. Future<({String token, int expirationMs})> fetchIntegrityToken({ required String authToken, required String deviceId, }) async { final response = await post( '/integrity', data: {}, headers: { 'Client-ID': twitchGqlClientId, 'Authorization': 'OAuth $authToken', 'X-Device-Id': deviceId, 'User-Agent': playbackUserAgent, 'Content-Type': 'application/json', }, ); final token = response['token'] as String?; final expiration = response['expiration']; if (token == null || token.isEmpty) { throw const ApiException('Twitch integrity token missing from response'); } if (TwitchIntegrity.isBadBot(token)) { throw const IntegrityCheckException( 'Twitch integrity token flagged as bad bot', ); } final expirationMs = expiration is int ? expiration : int.tryParse('$expiration') ?? TwitchIntegrity.expirationMs(token) ?? 0; return (token: token, expirationMs: expirationMs); } Future _mutateFollow({ required String operationName, required String sha256Hash, required String authToken, required JsonMap variables, required List successPath, String? integrityToken, String? deviceId, }) async { final headers = { 'Client-ID': twitchGqlClientId, 'Content-Type': 'application/json', 'Authorization': 'OAuth $authToken', 'User-Agent': playbackUserAgent, if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId, if (integrityToken != null && integrityToken.isNotEmpty) 'Client-Integrity': integrityToken, }; final response = await post( '/gql', data: { 'operationName': operationName, 'variables': variables, 'extensions': { 'persistedQuery': { 'version': 1, 'sha256Hash': sha256Hash, }, }, }, headers: headers, ); final map = _gqlPayloadMap(response); _throwIfGqlFailed(map); JsonMap? cursor = map['data'] as JsonMap?; for (final key in successPath) { final next = cursor?[key]; if (next is JsonMap) { cursor = next; } else if (next == null) { throw ApiException('Twitch $operationName returned no data'); } else { return; } } } JsonMap _gqlPayloadMap(dynamic response) { if (response is List) { if (response.isEmpty || response.first is! Map) { throw const ApiException('Unexpected Twitch GQL response'); } return Map.from(response.first as Map); } if (response is Map) { return Map.from(response); } throw const ApiException('Unexpected Twitch GQL response'); } void _throwIfGqlFailed(JsonMap map) { final errors = map['errors']; if (errors is! List || errors.isEmpty) return; final message = (errors.first as JsonMap)['message'] as String? ?? 'Twitch GQL error'; final lower = message.toLowerCase(); if (lower.contains('integrity')) { throw IntegrityCheckException(message); } throw ApiException(message); } StreamsTwitch _streamsFromConnection(JsonMap connection) { final edges = (connection['edges'] as List?) ?? const []; final pageInfo = connection['pageInfo'] as JsonMap?; // Prefer explicit pageInfo; if missing, assume more when we got a full page. final hasNext = pageInfo?['hasNextPage'] as bool? ?? edges.length >= 30; final streams = []; String? nextCursor; for (final edge in edges) { final edgeMap = edge as JsonMap; nextCursor = edgeMap['cursor'] as String?; final node = edgeMap['node'] as JsonMap?; if (node == null) continue; streams.add(_streamFromNode(node)); } return StreamsTwitch( streams, hasNext && nextCursor != null && nextCursor.isNotEmpty ? {'cursor': nextCursor} : const {}, ); } StreamTwitch _streamFromNode(JsonMap node) { final broadcaster = node['broadcaster'] as JsonMap? ?? const {}; final game = node['game'] as JsonMap? ?? const {}; final login = broadcaster['login'] as String? ?? ''; final language = (node['language'] as String? ?? '').toLowerCase(); return StreamTwitch( broadcaster['id'] as String? ?? '', login, broadcaster['displayName'] as String? ?? login, game['id'] as String? ?? '', game['name'] as String? ?? '', node['title'] as String? ?? '', (node['viewersCount'] as num?)?.toInt() ?? 0, node['createdAt'] as String? ?? '', // Helix-style placeholder so existing thumbnail widgets keep working. 'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg', language, const [], ); } }