[TwitchBridge] Switch to unofficial GraphQL API (#1829)

* [TwitchBridge] Switch to unofficial GraphQL API

The GraphQL API that the twitch.tv website uses has a lot more
information available than the official APIs. Hopefully it'll be stable.
This commit is contained in:
Roliga 2020-10-30 14:50:36 +01:00 committed by GitHub
parent 6af87b2f32
commit b48bc77c22
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
1 changed files with 125 additions and 63 deletions

View File

@ -20,7 +20,9 @@ class TwitchBridge extends BridgeAbstract {
'All' => 'all', 'All' => 'all',
'Archive' => 'archive', 'Archive' => 'archive',
'Highlights' => 'highlight', 'Highlights' => 'highlight',
'Uploads' => 'upload' 'Uploads' => 'upload',
'Past Premieres' => 'past_premiere',
'Premiere Uploads' => 'premiere_upload'
), ),
'defaultValue' => 'archive' 'defaultValue' => 'archive'
) )
@ -32,43 +34,90 @@ class TwitchBridge extends BridgeAbstract {
*/ */
const CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; const CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
const API_ENDPOINT = 'https://gql.twitch.tv/gql';
const BROADCAST_TYPES = array(
'all' => array(
'ARCHIVE',
'HIGHLIGHT',
'UPLOAD',
'PAST_PREMIERE',
'PREMIERE_UPLOAD'
),
'archive' => 'ARCHIVE',
'highlight' => 'HIGHLIGHT',
'upload' => 'UPLOAD',
'past_premiere' => 'PAST_PREMIERE',
'premiere_upload' => 'PREMIERE_UPLOAD'
);
public function collectData(){ public function collectData(){
// get channel user $query = <<<'EOD'
$query_data = array( query VODList($channel: String!, $types: [BroadcastType!]) {
'login' => $this->getInput('channel') user(login: $channel) {
displayName
videos(types: $types, sort: TIME) {
edges {
node {
id
title
publishedAt
lengthSeconds
viewCount
thumbnailURLs(width: 640, height: 360)
previewThumbnailURL(width: 640, height: 360)
description
tags
contentTags {
isLanguageTag
localizedName
}
game {
displayName
}
moments(momentRequestType: VIDEO_CHAPTER_MARKERS) {
edges {
node {
description
positionMilliseconds
}
}
}
}
}
}
}
}
EOD;
$variables = array(
'channel' => $this->getInput('channel'),
'types' => self::BROADCAST_TYPES[$this->getInput('type')]
); );
$users = $this->apiGet('users', $query_data)->users; $data = $this->apiRequest($query, $variables);
if(count($users) === 0)
returnClientError('User "'
. $this->getInput('channel')
. '" could not be found');
$user = $users[0];
// get video list $user = $data->user;
$query_endpoint = 'channels/' . $user->_id . '/videos'; foreach($user->videos->edges as $edge) {
$query_data = array( $video = $edge->node;
'broadcast_type' => $this->getInput('type'),
'limit' => 10 $url = 'https://www.twitch.tv/videos/' . $video->id;
);
$videos = $this->apiGet($query_endpoint, $query_data)->videos;
foreach($videos as $video) {
$item = array( $item = array(
'uri' => $video->url, 'uri' => $url,
'title' => $video->title, 'title' => $video->title,
'timestamp' => $video->published_at, 'timestamp' => $video->publishedAt,
'author' => $video->channel->display_name, 'author' => $user->displayName,
); );
// Add categories for tags and played game // Add categories for tags and played game
$item['categories'] = array_filter(explode(' ', $video->tag_list)); $item['categories'] = $video->tags;
if(!empty($video->game)) if(!is_null($video->game))
$item['categories'][] = $video->game; $item['categories'][] = $video->game->displayName;
foreach($video->contentTags as $tag)
if(!$tag->isLanguageTag)
$item['categories'][] = $tag->localizedName;
// Add enclosures for thumbnails from a few points in the video // Add enclosures for thumbnails from a few points in the video
$item['enclosures'] = array(); // Thumbnail list has duplicate entries sometimes so remove those
foreach($video->thumbnails->large as $thumbnail) $item['enclosures'] = array_unique($video->thumbnailURLs);
$item['enclosures'][] = $thumbnail->url;
/* /*
* Content format example: * Content format example:
@ -86,44 +135,45 @@ class TwitchBridge extends BridgeAbstract {
* *
*/ */
$item['content'] = '<p><a href="' $item['content'] = '<p><a href="'
. $video->url . $url
. '"><img src="' . '"><img src="'
. $video->preview->large . $video->previewThumbnailURL
. '" /></a></p><p>' . '" /></a></p><p>'
. $video->description_html . $video->description // in markdown format
. '</p><p><b>Duration:</b> ' . '</p><p><b>Duration:</b> '
. $this->formatTimestampTime($video->length) . $this->formatTimestampTime($video->lengthSeconds)
. '<br/><b>Views:</b> ' . '<br/><b>Views:</b> '
. $video->views . $video->viewCount
. '</p>'; . '</p>';
// Add played games list to content // Add played games list to content
$video_id = trim($video->_id, 'v'); // _id gives 'v1234' but API wants '1234' $item['content'] .= '<p><b>Played games:</b><ul>';
$markers = $this->apiGet('videos/' . $video_id . '/markers')->markers; if(count($video->moments->edges) > 0) {
$item['content'] .= '<p><b>Played games:</b></b><ul><li><a href="' foreach($video->moments->edges as $edge) {
. $video->url $moment = $edge->node;
. '">00:00:00</a> - '
. $video->game $item['categories'][] = $moment->description;
. '</li>';
if(isset($markers->game_changes)) {
usort($markers->game_changes, function($a, $b) {
return $a->time - $b->time;
});
foreach($markers->game_changes as $game_change) {
$item['categories'][] = $game_change->label;
$item['content'] .= '<li><a href="' $item['content'] .= '<li><a href="'
. $video->url . $url
. '?t=' . '?t='
. $this->formatQueryTime($game_change->time) . $this->formatQueryTime($moment->positionMilliseconds / 1000)
. '">' . '">'
. $this->formatTimestampTime($game_change->time) . $this->formatTimestampTime($moment->positionMilliseconds / 1000)
. '</a> - ' . '</a> - '
. $game_change->label . $moment->description
. '</li>'; . '</li>';
} }
} else {
$item['content'] .= '<li><a href="'
. $url
. '">00:00:00</a> - '
. ($video->game ? $video->game->displayName : 'No Game')
. '</li>';
} }
$item['content'] .= '</ul></p>'; $item['content'] .= '</ul></p>';
$item['categories'] = array_unique($item['categories']);
$this->items[] = $item; $this->items[] = $item;
} }
} }
@ -144,25 +194,37 @@ class TwitchBridge extends BridgeAbstract {
$seconds % 60); $seconds % 60);
} }
/* // GraphQL: https://graphql.org/
* Ideally the new 'helix' API should be used as v5/'kraken' is deprecated. // Tool for developing/testing queries: https://github.com/skevy/graphiql-app
* The new API however still misses many features (markers, played game..) of private function apiRequest($query, $variables) {
* the old one, so let's use the old one for as long as it's available. $request = array(
*/ 'query' => $query,
private function apiGet($endpoint, $query_data = array()) { 'variables' => $variables
$query_data['api_version'] = 5; );
$url = 'https://api.twitch.tv/kraken/'
. $endpoint
. '?'
. http_build_query($query_data);
$header = array( $header = array(
'Client-ID: ' . self::CLIENT_ID 'Client-ID: ' . self::CLIENT_ID
); );
$opts = array(
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => json_encode($request)
);
$data = json_decode(getContents($url, $header)) Debug::log("Sending GraphQL query:\n" . $query);
or returnServerError('API request to "' . $url . '" failed.'); Debug::log("Sending GraphQL variables:\n"
. json_encode($variables, JSON_PRETTY_PRINT));
return $data; $response = json_decode(getContents(self::API_ENDPOINT, $header, $opts))
or returnServerError('API request to "' . self::API_ENDPOINT . '" failed.');
Debug::log("Got GraphQL response:\n"
. json_encode($response, JSON_PRETTY_PRINT));
if(isset($response->errors)) {
$messages = array_column($response->errors, 'message');
returnServerError('API error(s): ' . implode("\n", $messages));
}
return $response->data;
} }
public function getName(){ public function getName(){