Settings multi-select filters Top/category/tag lists via Helix (with over-fetch for multiple languages). Larger Helix pages, safer cursors, and scroll near-end loading restore pagination; GQL page-2 integrity failures end the list cleanly. Co-authored-by: Cursor <cursoragent@cursor.com>
345 lines
13 KiB
Dart
345 lines
13 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:frosty/apis/base_api_client.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 = <String, String>{
|
|
'Client-ID': twitchGqlClientId,
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
Future<JsonMap> _gql(Map<String, Object?> body) async {
|
|
final response = await post<JsonMap>(
|
|
'/gql',
|
|
data: body,
|
|
headers: _gqlHeaders,
|
|
);
|
|
final errors = response['errors'];
|
|
if (errors is List && errors.isNotEmpty) {
|
|
final data = response['data'];
|
|
final dataEmpty = data == null ||
|
|
(data is Map &&
|
|
data.values.every((value) => value == null));
|
|
if (dataEmpty) {
|
|
final message = (errors.first as JsonMap)['message'] as String? ??
|
|
'Twitch GQL error';
|
|
throw ApiException(message);
|
|
}
|
|
}
|
|
return response;
|
|
}
|
|
|
|
/// 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 = <String, String>{
|
|
'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<PlaybackAccessToken> getPlaybackAccessToken({
|
|
required String login,
|
|
String? authToken,
|
|
String playerType = 'site',
|
|
String platform = 'web',
|
|
}) async {
|
|
final headers = <String, String>{
|
|
'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<JsonMap> query(Map<String, Object> body) =>
|
|
post<JsonMap>('/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<String> fetchPlaylistText(String url) async {
|
|
return get<String>(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<List<String>> 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<String>()
|
|
.toList();
|
|
}
|
|
|
|
/// Live streams matching optional [freeformTags] / [languages], sorted by viewers.
|
|
///
|
|
/// Twitch caps `first` at 30 for this connection. Cursor pagination often
|
|
/// fails with `IntegrityCheckFailed` without a web integrity token — callers
|
|
/// should treat a failed page-2 as end-of-list.
|
|
Future<StreamsTwitch> getStreams({
|
|
List<String>? freeformTags,
|
|
List<String>? languages,
|
|
StreamViewerSort sort = StreamViewerSort.viewersDesc,
|
|
String? cursor,
|
|
int first = 30,
|
|
}) 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,
|
|
},
|
|
});
|
|
|
|
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<StreamsTwitch> getGameStreams({
|
|
required String gameId,
|
|
List<String>? languages,
|
|
StreamViewerSort sort = StreamViewerSort.viewersDesc,
|
|
String? cursor,
|
|
int first = 30,
|
|
}) 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,
|
|
},
|
|
});
|
|
|
|
final game = (response['data'] as JsonMap?)?['game'] as JsonMap?;
|
|
if (game == null) {
|
|
return const StreamsTwitch([], {});
|
|
}
|
|
return _streamsFromConnection(game['streams'] as JsonMap? ?? const {});
|
|
}
|
|
|
|
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 = <StreamTwitch>[];
|
|
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,
|
|
);
|
|
}
|
|
}
|