1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-10-12 06:01:55 +00:00
FrankerFaceZ/src/utilities/twitch-data.js
SirStendec 8c9a3aa8a4 4.20.0
* Added: Emote Visibility Control for Emote Menu. You can now hide emotes you don't want from your emote menu. You still have them, and they'll still appear in chat if someone else uses them, but it helps keep the clutter down in your menu. (Closes #811)
* Added: Setting to toggle mute for the player when middle-clicking it. (Closes #812)
* Added: Setting to toggle the bold style applied to chat mentions. (Closes #816)
* Fixed: No background color being applied to Highlight My Message chat messages when using the new Twitch layout. Now, the default Twitch purple will be used when FFZ hasn't yet extracted the color for the current channel. Extracting the channel color is still broken at this time. (Closes #821)
* Fixed: The player volume resetting to 100% when changing channels. (Closes #820)
* Fixed: Chat appearing incorrectly when a custom width smaller than 340 pixels is set. (Closes #819)
2020-06-23 17:17:00 -04:00

967 lines
24 KiB
JavaScript

'use strict';
// ============================================================================
// Twitch Data
// Get data, from Twitch.
// ============================================================================
import Module from 'utilities/module';
import {get, debounce, generateUUID} from 'utilities/object';
const LANGUAGE_MATCHER = /^auto___lang_(\w+)$/;
const ALGOLIA_LANGUAGES = {
bg: 'bg-bg',
cs: 'cs-cz',
da: 'da-dk',
de: 'de-de',
el: 'el-gr',
en: 'en-us',
es: 'es-es',
'es-mx': 'es-mx',
fi: 'fi-fi',
fr: 'fr-fr',
hu: 'hu-hu',
it: 'it-it',
ja: 'ja-jp',
ko: 'ko-kr',
nl: 'nl-nl',
no: 'no-no',
pl: 'pl-pl',
'pt-br': 'pt-br',
pt: 'pt-pt',
ro: 'ro-ro',
ru: 'ru-ru',
sk: 'sk-sk',
sv: 'sv-se',
th: 'th-th',
tr: 'tr-tr',
vi: 'vi-vn',
'zh-cn': 'zh-cn',
'zh-tw': 'zh-tw'
};
/**
* Returns the Algolia Language code for a given locale
* @function getAlgoliaLanguage
*
* @param {string} locale - a string representation of a locale
* @returns {string} the Algolia Language code for the given locale
*
* @example
*
* console.log(getAlgoliaLanguage('en'));
*/
function getAlgoliaLanguage(locale) {
if ( ! locale )
return ALGOLIA_LANGUAGES.en;
locale = locale.toLowerCase();
if ( ALGOLIA_LANGUAGES[locale] )
return ALGOLIA_LANGUAGES[locale];
locale = locale.split('-')[0];
return ALGOLIA_LANGUAGES[locale] || ALGOLIA_LANGUAGES.en;
}
/**
* TwitchData is a container for getting different types of Twitch data
* @class TwitchData
* @extends Module
*/
export default class TwitchData extends Module {
constructor(...args) {
super(...args);
this.site = this.parent;
this.inject('site.apollo');
this.inject('site.web_munch');
this._waiting_stream_ids = new Map;
this._waiting_stream_logins = new Map;
this.tag_cache = new Map;
this._waiting_tags = new Map;
this._loadTags = debounce(this._loadTags, 50);
this._loadStreams = debounce(this._loadStreams, 50);
}
queryApollo(query, variables, options) {
let thing;
if ( ! variables && ! options && query.query )
thing = query;
else {
thing = {
query,
variables
};
if ( options )
thing = Object.assign(thing, options);
}
return this.apollo.client.query(thing);
}
mutate(mutation, variables, options) {
let thing;
if ( ! variables && ! options && mutation.mutation )
thing = mutation;
else {
thing = {
mutation,
variables
};
if ( options )
thing = Object.assign(thing, options);
}
return this.apollo.client.mutate(thing);
}
get languageCode() {
const session = this.site.getSession();
return session && session.languageCode || 'en'
}
get locale() {
const session = this.site.getSession();
return session && session.locale || 'en-US'
}
get searchClient() {
if ( this._search )
return this._search;
const apollo = this.apollo.client,
core = this.site.getCore(),
search_module = this.web_munch.getModule('algolia-search'),
SearchClient = search_module && search_module.a;
if ( ! SearchClient || ! apollo || ! core )
return null;
this._search = new SearchClient({
appId: core.config.algoliaApplicationID,
apiKey: core.config.algoliaAPIKey,
apolloClient: apollo,
logger: core.logger,
config: core.config,
stats: core.stats
});
return this._search;
}
// ========================================================================
// Categories
// ========================================================================
/**
* Queries Apollo for categories matching the search argument
* @function getMatchingCategories
* @memberof TwitchData
* @async
*
* @param {string} query - query text to match to a category
* @returns {Object} a collection of matches for the query string
*
* @example
*
* console.log(this.twitch_data.getMatchingCategories("siege"));
*/
async getMatchingCategories(query) {
const data = await this.queryApollo(
await import(/* webpackChunkName: 'queries' */ './data/search-category.gql'),
{ query }
);
return {
cursor: get('data.searchFor.games.cursor', data),
items: get('data.searchFor.games.items', data) || [],
finished: ! get('data.searchFor.games.pageInfo.hasNextPage', data)
};
}
/**
* Queries Apollo for category details given the id or name. One of (id, name) MUST be specified
* @function getCategory
* @memberof TwitchData
* @async
*
* @param {int|string|null|undefined} id - the category id number (can be an integer string)
* @param {string|null|undefined} name - the category name
* @returns {Object} information about the requested stream
*
* @example
*
* console.log(this.twitch_data.getCategory(null, 'Just Chatting'));
*/
async getCategory(id, name) {
const data = await this.queryApollo(
await import(/* webpackChunkName: 'queries' */ './data/category-fetch.gql'),
{ id, name }
);
return get('data.game', data);
}
// ========================================================================
// Users
// ========================================================================
/**
* Queries Apollo for users matching the search argument
* @function getMatchingUsers
* @memberof TwitchData
* @async
*
* @param {string} query - query text to match to a username
* @returns {Object} a collection of matches for the query string
*
* @example
*
* console.log(this.twitch_data.getMatchingUsers("ninja"));
*/
async getMatchingUsers(query) {
const data = await this.queryApollo(
await import(/* webpackChunkName: 'queries' */ './data/search-user.gql'),
{ query }
);
return {
cursor: get('data.searchFor.users.cursor', data),
items: get('data.searchFor.users.items', data) || [],
finished: ! get('data.searchFor.users.pageInfo.hasNextPage', data)
};
}
/**
* Queries Apollo for user details given the id or name. One of (id, login) MUST be specified
* @function getUser
* @memberof TwitchData
* @async
*
* @param {int|string|null|undefined} id - the user id number (can be an integer string)
* @param {string|null|undefined} login - the username
* @returns {Object} information about the requested user
*
* @example
*
* console.log(this.twitch_data.getUser(19571641, null));
*/
async getUser(id, login) {
const data = await this.queryApollo(
await import(/* webpackChunkName: 'queries' */ './data/user-fetch.gql'),
{ id, login }
);
return get('data.user', data);
}
/**
* Queries Apollo for the logged in user's relationship to the channel with given the id or name. One of (id, login) MUST be specified
* @function getUserSelf
* @memberof TwitchData
* @async
*
* @param {int|string|null|undefined} id - the channel id number (can be an integer string)
* @param {string|null|undefined} login - the channel username
* @returns {Object} information about your status in the channel
*
* @example
*
* console.log(this.twitch_data.getUserSelf(null, "ninja"));
*/
async getUserSelf(id, login) {
const data = await this.queryApollo(
await import(/* webpackChunkName: 'queries' */ './data/user-self.gql'),
{ id, login }
);
return get('data.user.self', data);
}
/**
* Queries Apollo for the requested user's latest broadcast. One of (id, login) MUST be specified
* @function getLastBroadcast
* @memberof TwitchData
* @async
*
* @param {int|string|null|undefined} id - the channel id number (can be an integer string)
* @param {string|null|undefined} login - the channel username
* @returns {Object} information about the requested user's latest broadcast
*
* @example
*
* console.log(this.twitch_data.getLastBroadcast(19571641, null));
*/
async getLastBroadcast(id, login) {
const data = await this.queryApollo(
await import(/* webpackChunkName: 'queries' */ './data/last-broadcast.gql'),
{ id, login }
);
return get('data.user.lastBroadcast', data);
}
// ========================================================================
// Broadcast ID
// ========================================================================
/**
* Queries Apollo for the ID of the specified user's current broadcast. This ID will become the VOD ID. One of (id, login) MUST be specified
* @function getBroadcastID
* @memberof TwitchData
* @async
*
* @param {int|string|null|undefined} id - the channel id number (can be an integer string)
* @param {string|null|undefined} login - the channel username
* @returns {Object} information about the current broadcast
*
* @example
*
* console.log(this.twitch_data.getBroadcastID(null, "ninja"));
*/
async getBroadcastID(id, login) {
const data = await this.queryApollo({
query: await import(/* webpackChunkName: 'queries' */ './data/broadcast-id.gql'),
variables: {
id,
login
}
});
return get('data.user.stream.archiveVideo.id', data);
}
async getChannelColor(id, login) {
const data = await this.queryApollo({
query: await import(/* webpackChunkName: 'queries' */ './data/user-color.gql'),
variables: {
id,
login
}
});
return get('data.user.primaryColorHex', data);
}
// ========================================================================
// Polls
// ========================================================================
/**
* Queries Apollo for information about the specified poll.
* @function getPoll
* @memberof TwitchData
* @async
*
* @param {int|string} poll_id - the poll id number (can be an integer string)
* @returns {Object} information about the specified poll
*
* @example
*
* console.log(this.twitch_data.getPoll(1337));
*/
async getPoll(poll_id) {
const data = await this.queryApollo({
query: await import(/* webpackChunkName: 'queries' */ './data/poll-get.gql'),
variables: {
id: poll_id
}
});
return get('data.poll', data);
}
/**
* Create a new poll
* @function createPoll
* @memberof TwitchData
* @async
*
* @param {int|string} channel_id - the channel id number (can be an integer string)
* @param {string} title - the poll title
* @param {string[]} choices - an array of poll choices
* @param {Object} [options] - an object containing poll options
* @param {int} [options.bits=0] - how many bits it costs to vote
* @param {int} [options.duration=60] - how long the poll will be held for, in seconds
* @param {bool} [options.subscriberMultiplier=false] - whether to activate subsriber 2x multiplier
* @param {bool} [options.subscriberOnly=false] - whether only subscribers may vote
* @returns {Object} poll data
*
* @example
*
* console.log(this.twitch_data.createPoll(19571641, "Pick an option:", ["One", "Two", "Three"], {bits: 10, duration: 120, subscriberMultiplier: false, subscriberOnly: true}));
*/
async createPoll(channel_id, title, choices, options = {}) {
if ( typeof title !== 'string' )
throw new TypeError('title must be string');
if ( ! Array.isArray(choices) || choices.some(x => typeof x !== 'string') )
throw new TypeError('choices must be array of strings');
let bits = options.bits || 0,
duration = options.duration || 60;
if ( typeof bits !== 'number' || bits < 0 )
bits = 0;
if ( typeof duration !== 'number' || duration < 0 )
duration = 60;
const data = await this.mutate({
mutation: await import(/* webpackChunkName: 'queries' */ './data/poll-create.gql'),
variables: {
input: {
bitsCost: bits,
bitsVoting: bits > 0,
choices: choices.map(x => ({title: x})),
durationSeconds: duration,
ownedBy: `${channel_id}`,
subscriberMultiplier: options.subscriberMultiplier || false,
subscriberOnly: options.subscriberOnly || false,
title
}
}
});
return get('data.createPoll.poll', data);
}
/**
* Place specified poll into archive
* @function archivePoll
* @memberof TwitchData
* @async
*
* @param {int|string|null|undefined} poll_id - the poll id number (can be an integer string)
* @returns {Object} information about the specified poll
*
* @example
*
* console.log(this.twitch_data.archivePoll(1337));
*/
async archivePoll(poll_id) {
const data = await this.mutate({
mutation: await import(/* webpackChunkName: 'queries' */ './data/poll-archive.gql'),
variables: {
id: poll_id
}
});
return get('data.archivePoll.poll', data);
}
/**
* Terminate specified poll
* @function terminatePoll
* @memberof TwitchData
* @async
*
* @param {int|string|null|undefined} poll_id - the poll id number (can be an integer string)
* @returns {Object} information about the specified poll
*
* @example
*
* console.log(this.twitch_data.archivePoll(1337));
*/
async terminatePoll(poll_id) {
const data = await this.mutate({
mutation: await import(/* webpackChunkName: 'queries' */ './data/poll-terminate.gql'),
variables: {
id: poll_id
}
});
return get('data.terminatePoll.poll', data);
}
// ========================================================================
// Stream Up-Type (Uptime and Type, for Directory Purposes)
// ========================================================================
/**
* Queries Apollo for stream metadata. One of (id, login) MUST be specified
* @function getStreamMeta
* @memberof TwitchData
*
* @param {int|string|null|undefined} id - the channel id number (can be an integer string)
* @param {string|null|undefined} login - the channel name
* @returns {Promise} information about the requested stream
*
* @example
*
* this.twitch_data.getStreamMeta(19571641, null).then(function(returnObj){console.log(returnObj);});
*/
getStreamMeta(id, login) {
return new Promise((s, f) => {
if ( id ) {
if ( this._waiting_stream_ids.has(id) )
this._waiting_stream_ids.get(id).push([s, f]);
else
this._waiting_stream_ids.set(id, [[s, f]]);
} else if ( login ) {
if ( this._waiting_stream_logins.has(login) )
this._waiting_stream_logins.get(login).push([s, f]);
else
this._waiting_stream_logins.set(login, [[s, f]]);
} else
f('id and login cannot both be null');
if ( ! this._loading_streams )
this._loadStreams();
})
}
async _loadStreams() {
if ( this._loading_streams )
return;
this._loading_streams = true;
// Get the first 50... things.
const ids = [...this._waiting_stream_ids.keys()].slice(0, 50),
remaining = 50 - ids.length,
logins = remaining > 0 ? [...this._waiting_stream_logins.keys()].slice(0, remaining) : [];
let nodes;
try {
const data = await this.queryApollo({
query: await import(/* webpackChunkName: 'queries' */ './data/stream-fetch.gql'),
variables: {
ids: ids.length ? ids : null,
logins: logins.length ? logins : null
}
});
nodes = get('data.users', data);
} catch(err) {
for(const id of ids) {
const promises = this._waiting_stream_ids.get(id);
this._waiting_stream_ids.delete(id);
for(const pair of promises)
pair[1](err);
}
for(const login of logins) {
const promises = this._waiting_stream_logins.get(login);
this._waiting_stream_logins.delete(login);
for(const pair of promises)
pair[1](err);
}
return;
}
const id_set = new Set(ids),
login_set = new Set(logins);
if ( Array.isArray(nodes) )
for(const node of nodes) {
if ( ! node || ! node.id )
continue;
id_set.delete(node.id);
login_set.delete(node.login);
let promises = this._waiting_stream_ids.get(node.id);
if ( promises ) {
this._waiting_stream_ids.delete(node.id);
for(const pair of promises)
pair[0](node.stream);
}
promises = this._waiting_stream_logins.get(node.login);
if ( promises ) {
this._waiting_stream_logins.delete(node.login);
for(const pair of promises)
pair[0](node.stream);
}
}
for(const id of id_set) {
const promises = this._waiting_stream_ids.get(id);
if ( promises ) {
this._waiting_stream_ids.delete(id);
for(const pair of promises)
pair[0](null);
}
}
for(const login of login_set) {
const promises = this._waiting_stream_logins.get(login);
if ( promises ) {
this._waiting_stream_logins.delete(login);
for(const pair of promises)
pair[0](null);
}
}
this._loading_streams = false;
if ( this._waiting_stream_ids.size || this._waiting_stream_logins.size )
this._loadStreams();
}
// ========================================================================
// Tags
// ========================================================================
memorizeTag(node, dispatch = true) {
// We want properly formed tags.
if ( ! node || ! node.id || ! node.tagName || ! node.localizedName )
return;
let old = null;
if ( this.tag_cache.has(node.id) )
old = this.tag_cache.get(old);
const match = node.isLanguageTag && LANGUAGE_MATCHER.exec(node.tagName),
lang = match && match[1] || null;
const new_tag = {
id: node.id,
value: node.id,
is_language: node.isLanguageTag,
language: lang,
name: node.tagName,
label: node.localizedName
};
if ( node.localizedDescription )
new_tag.description = node.localizedDescription;
const tag = old ? Object.assign(old, new_tag) : new_tag;
this.tag_cache.set(tag.id, tag);
if ( dispatch && tag.description && this._waiting_tags.has(tag.id) ) {
const promises = this._waiting_tags.get(tag.id);
this._waiting_tags.delete(tag.id);
for(const pair of promises)
pair[0](tag);
}
return tag;
}
async _loadTags() {
if ( this._loading_tags )
return;
this._loading_tags = true;
// Get the first 50 tags.
const ids = [...this._waiting_tags.keys()].slice(0, 50);
let nodes
try {
const data = await this.queryApollo(
await import(/* webpackChunkName: 'queries' */ './data/tags-fetch.gql'),
{
ids
}
);
nodes = get('data.contentTags', data);
} catch(err) {
for(const id of ids) {
const promises = this._waiting_tags.get(id);
this._waiting_tags.delete(id);
for(const pair of promises)
pair[1](err);
}
return;
}
const id_set = new Set(ids);
if ( Array.isArray(nodes) )
for(const node of nodes) {
const tag = this.memorizeTag(node, false),
promises = this._waiting_tags.get(tag.id);
this._waiting_tags.delete(tag.id);
id_set.delete(tag.id);
if ( promises )
for(const pair of promises)
pair[0](tag);
}
for(const id of id_set) {
const promises = this._waiting_tags.get(id);
this._waiting_tags.delete(id);
for(const pair of promises)
pair[0](null);
}
this._loading_tags = false;
if ( this._waiting_tags.size )
this._loadTags();
}
/**
* Queries Apollo for tag information
* @function getTag
* @memberof TwitchData
*
* @param {int|string} id - the tag id
* @param {bool} [want_description=false] - whether the description is also required
* @returns {Promise} tag information
*
* @example
*
* this.twitch_data.getTag(50).then(function(returnObj){console.log(returnObj);});
*/
getTag(id, want_description = false) {
// Make sure we weren't accidentally handed a tag object.
if ( id && id.id )
id = id.id;
if ( this.tag_cache.has(id) ) {
const out = this.tag_cache.get(id);
if ( out && (out.description || ! want_description) )
return Promise.resolve(out);
}
return new Promise((s, f) => {
if ( this._waiting_tags.has(id) )
this._waiting_tags.get(id).push([s, f]);
else {
this._waiting_tags.set(id, [[s, f]]);
if ( ! this._loading_tags )
this._loadTags();
}
});
}
/**
* Queries the tag cache for tag information, queries Apollo on cache miss
* @function getTagImmediate
* @memberof TwitchData
*
* @param {int|string} id - the tag id
* @param {getTagImmediateCallback} callback - callback function for use when requested tag information is not cached
* @param {bool} [want_description=false] - whether the tag description is required
* @returns {Object|null} tag information object, or on null, expect callback
*
* @example
*
* console.log(this.twitch_data.getTagImmediate(50));
*/
getTagImmediate(id, callback, want_description = false) {
// Make sure we weren't accidentally handed a tag object.
if ( id && id.id )
id = id.id;
let out = null;
if ( this.tag_cache.has(id) )
out = this.tag_cache.get(id);
if ( (want_description && (! out || ! out.description)) || (! out && callback) ) {
const promise = this.getTag(id, want_description);
if ( callback )
promise.then(tag => callback(id, tag)).catch(err => callback(id, null, err));
}
return out;
}
/**
* Callback function used when getTagImmediate experiences a cache miss
* @callback getTagImmediateCallback
* @param {int} tag_id - The tag ID number
* @param {Object} tag_object - the object containing tag data
* @param {Object} [error_object] - returned error information on tag data fetch failure
*/
/**
* Get top [n] tags
* @function getTopTags
* @memberof TwitchData
* @async
*
* @param {int|string} limit=50 - the number of tags to return (can be an integer string)
* @returns {string[]} an array containing the top tags up to the limit requested
*
* @example
*
* console.log(this.twitch_data.getTopTags(20));
*/
async getTopTags(limit = 50) {
const data = await this.queryApollo(
await import(/* webpackChunkName: 'queries' */ './data/tags-top.gql'),
{limit}
);
const nodes = get('data.topTags', data);
if ( ! Array.isArray(nodes) )
return [];
const out = [], seen = new Set;
for(const node of nodes) {
if ( ! node || seen.has(node.id) )
continue;
seen.add(node.id);
out.push(this.memorizeTag(node));
}
return out;
}
/**
* Queries tag languages
* @function getLanguagesFromTags
* @memberof TwitchData
*
* @param {int[]} tags - an array of tag IDs
* @returns {string[]} tag information
*
* @example
*
* console.log(this.twitch_data.getLanguagesFromTags([50, 53, 58, 84]));
*/
getLanguagesFromTags(tags, callback) { // TODO: actually use the callback
const out = [],
fn = callback ? debounce(() => {
this.getLanguagesFromTags(tags, callback);
}, 16) : null
if ( Array.isArray(tags) )
for(const tag_id of tags) {
const tag = this.getTagImmediate(tag_id, fn);
if ( tag && tag.is_language ) {
const match = LANGUAGE_MATCHER.exec(tag.name);
if ( match )
out.push(match[1]);
}
}
return out;
}
/**
* Search tags
* @function getMatchingTags
* @memberof TwitchData
* @async
*
* @param {string} query - the search string
* @param {string} [locale] - the locale to return tags from
* @param {string} [category=null] - the category to return tags from
* @returns {string[]} an array containing tags that match the query string
*
* @example
*
* console.log(this.twitch_data.getMatchingTags("Rainbo"));
*/
async getMatchingTags(query, locale, category = null) {
if ( ! locale )
locale = this.locale;
locale = getAlgoliaLanguage(locale);
let nodes;
if ( category ) {
const data = await this.searchClient.queryForType(
'stream_tag', query, generateUUID(), {
hitsPerPage: 100,
faceFilters: [
`category_id:${category}`
],
restrictSearchableAttributes: [
`localizations.${locale}`,
'tag_name'
]
}
);
nodes = get('streamTags.hits', data);
} else {
const data = await this.searchClient.queryForType(
'tag', query, generateUUID(), {
hitsPerPage: 100,
facetFilters: [
['tag_scope:SCOPE_ALL', 'tag_scope:SCOPE_CATEGORY']
],
restrictSearchableAttributes: [
`localizations.${locale}`,
'tag_name'
]
}
);
nodes = get('tags.hits', data);
}
if ( ! Array.isArray(nodes) )
return [];
const out = [], seen = new Set;
for(const node of nodes) {
const tag_id = node.tag_id || node.objectID;
if ( ! node || seen.has(tag_id) )
continue;
seen.add(tag_id);
if ( ! this.tag_cache.has(tag_id) ) {
const match = node.tag_name && LANGUAGE_MATCHER.exec(node.tag_name),
lang = match && match[1] || null;
const tag = {
id: tag_id,
value: tag_id,
is_language: lang != null,
language: lang,
label: node.localizations && (node.localizations[locale] || node.localizations['en-us']) || node.tag_name
};
if ( node.description_localizations ) {
const desc = node.description_localizations[locale] || node.description_localizations['en-us'];
if ( desc )
tag.description = desc;
}
this.tag_cache.set(tag.id, tag);
out.push(tag);
} else {
const tag = this.tag_cache.get(tag_id);
if ( ! tag.description && node.description_localizations ) {
const desc = node.description_localizations[locale] || node.description_localizations['en-us'];
if ( desc ) {
tag.description = desc;
this.tag_cache.set(tag.id, tag);
}
}
out.push(tag);
}
}
return out;
}
}