com.gmt2001.TwitchAPIv5.java Source code

Java tutorial

Introduction

Here is the source code for com.gmt2001.TwitchAPIv5.java

Source

/*
 * Copyright (C) 2016-2018 phantombot.tv
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package com.gmt2001;

import com.gmt2001.datastore.DataStore;

import tv.phantombot.cache.UsernameCache;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URL;
import java.net.URLEncoder;
import javax.net.ssl.HttpsURLConnection;
import org.apache.commons.io.IOUtils;
import org.json.JSONArray;
import org.json.JSONObject;
import org.json.JSONStringer;
import java.util.List;
import java.util.ArrayList;
import java.util.zip.GZIPInputStream;

/**
 * Communicates with Twitch Kraken server using the version 5 API
 *
 * @author gmt2001
 * @author illusionaryone
 */
public class TwitchAPIv5 {

    private static final TwitchAPIv5 instance = new TwitchAPIv5();
    private static final String base_url = "https://api.twitch.tv/kraken";
    private static final String header_accept = "application/vnd.twitchtv.v5+json";
    private static final int timeout = 2 * 1000;
    private String clientid = "";
    private String oauth = "";
    private String cheerEmotes = "";

    private enum request_type {

        GET, POST, PUT, DELETE
    };

    public static TwitchAPIv5 instance() {
        return instance;
    }

    private TwitchAPIv5() {
        Thread.setDefaultUncaughtExceptionHandler(com.gmt2001.UncaughtExceptionHandler.instance());
    }

    private JSONObject GetData(request_type type, String url, boolean isJson) {
        return GetData(type, url, "", isJson);
    }

    private JSONObject GetData(request_type type, String url, String post, boolean isJson) {
        return GetData(type, url, post, "", isJson);
    }

    private static void fillJSONObject(JSONObject jsonObject, boolean success, String type, String post, String url,
            int responseCode, String exception, String exceptionMessage, String jsonContent) {
        jsonObject.put("_success", success);
        jsonObject.put("_type", type);
        jsonObject.put("_post", post);
        jsonObject.put("_url", url);
        jsonObject.put("_http", responseCode);
        jsonObject.put("_exception", exception);
        jsonObject.put("_exceptionMessage", exceptionMessage);
        jsonObject.put("_content", jsonContent);
    }

    @SuppressWarnings("UseSpecificCatch")
    private JSONObject GetData(request_type type, String url, String post, String oauth, boolean isJson) {
        JSONObject j = new JSONObject("{}");
        InputStream i = null;
        String content = "";

        try {
            URL u = new URL(url);
            HttpsURLConnection c = (HttpsURLConnection) u.openConnection();
            c.addRequestProperty("Accept", header_accept);
            c.addRequestProperty("Content-Type", isJson ? "application/json" : "application/x-www-form-urlencoded");

            if (!clientid.isEmpty()) {
                c.addRequestProperty("Client-ID", clientid);
            }

            if (!oauth.isEmpty()) {
                c.addRequestProperty("Authorization", "OAuth " + oauth);
            } else {
                if (!this.oauth.isEmpty()) {
                    c.addRequestProperty("Authorization", "OAuth " + oauth);
                }
            }

            c.setRequestMethod(type.name());
            c.setConnectTimeout(timeout);
            c.setRequestProperty("User-Agent",
                    "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.52 Safari/537.36 PhantomBotJ/2015");

            if (!post.isEmpty()) {
                c.setDoOutput(true);
            }

            c.connect();

            if (!post.isEmpty()) {
                try (OutputStream o = c.getOutputStream()) {
                    IOUtils.write(post, o);
                }
            }

            if (c.getResponseCode() == 200) {
                i = c.getInputStream();
            } else {
                i = c.getErrorStream();
            }

            if (c.getResponseCode() == 204 || i == null) {
                content = "{}";
            } else {
                // default to UTF-8, it'll probably be the best bet if there's
                // no charset specified.
                String charset = "utf-8";
                String ct = c.getContentType();
                if (ct != null) {
                    String[] cts = ct.split(" *; *");
                    for (int idx = 1; idx < cts.length; ++idx) {
                        String[] val = cts[idx].split("=", 2);
                        if (val[0] == "charset" && val.length > 1) {
                            charset = val[1];
                        }
                    }
                }

                if ("gzip".equals(c.getContentEncoding())) {
                    i = new GZIPInputStream(i);
                }

                content = IOUtils.toString(i, charset);
            }

            j = new JSONObject(content);
            fillJSONObject(j, true, type.name(), post, url, c.getResponseCode(), "", "", content);
        } catch (Exception ex) {
            Throwable rootCause = ex;
            while (rootCause.getCause() != null && rootCause.getCause() != rootCause) {
                rootCause = rootCause.getCause();
            }

            fillJSONObject(j, false, type.name(), post, url, 0, ex.getClass().getSimpleName(), ex.getMessage(),
                    content);
            com.gmt2001.Console.debug
                    .println("Failed to get data [" + ex.getClass().getSimpleName() + "]: " + ex.getMessage());
        } finally {
            if (i != null) {
                try {
                    i.close();
                } catch (IOException ex) {
                    fillJSONObject(j, false, type.name(), post, url, 0, "IOException", ex.getMessage(), content);
                    com.gmt2001.Console.err.println("IOException: " + ex.getMessage());
                }
            }
        }

        return j;
    }

    /**
     * Sets the Twitch API Client-ID header
     *
     * @param clientid
     */
    public void SetClientID(String clientid) {
        this.clientid = clientid;
    }

    /**
     * Sets the Twitch API OAuth header
     *
     * @param oauth
     */
    public void SetOAuth(String oauth) {
        this.oauth = oauth.replace("oauth:", "");
    }

    public boolean HasOAuth() {
        return !this.oauth.isEmpty();
    }

    /**
     * Determines the ID of a username, if this fails it returns "0".
     *
     * @param channel
     * @return
     */
    private String getIDFromChannel(String channel) {
        return UsernameCache.instance().getID(channel);
    }

    /**
     * Gets a channel object
     *
     * @param channel
     * @return
     */
    public JSONObject GetChannel(String channel) {
        return GetData(request_type.GET, base_url + "/channels/" + getIDFromChannel(channel), false);
    }

    /**
     * Updates the status and game of a channel
     *
     * @param channel
     * @param status
     * @param game
     * @param delay -1 to not update
     * @return
     */
    public JSONObject UpdateChannel(String channel, String status, String game, int delay) {
        return UpdateChannel(channel, this.oauth, status, game, delay);
    }

    /**
     * Updates the status and game of a channel
     *
     * @param channel
     * @param oauth
     * @param status
     * @param game
     * @return
     */
    public JSONObject UpdateChannel(String channel, String oauth, String status, String game) {
        return UpdateChannel(channel, oauth, status, game, -1);
    }

    /**
     * Updates the status and game of a channel
     *
     * @param channel
     * @param status
     * @param game
     * @return
     */
    public JSONObject UpdateChannel(String channel, String status, String game) {
        return UpdateChannel(channel, this.oauth, status, game, -1);
    }

    /**
     * Updates the status and game of a channel
     *
     * @param channel
     * @param oauth
     * @param status
     * @param game
     * @param delay -1 to not update
     * @return
     */
    public JSONObject UpdateChannel(String channel, String oauth, String status, String game, int delay) {
        JSONObject j = new JSONObject("{}");
        JSONObject c = new JSONObject("{}");

        if (!status.isEmpty()) {
            c.put("status", status);
        }

        if (!game.isEmpty()) {
            JSONObject g = SearchGame(game);
            String gn = game;

            if (g.getBoolean("_success")) {
                if (g.getInt("_http") == 200) {
                    JSONArray a = g.getJSONArray("games");

                    if (a.length() > 0) {
                        boolean found = false;

                        for (int i = 0; i < a.length() && !found; i++) {
                            JSONObject o = a.getJSONObject(i);

                            gn = o.getString("name");

                            if (gn.equalsIgnoreCase(game)) {
                                found = true;
                            }
                        }

                        if (!found) {
                            JSONObject o = a.getJSONObject(0);

                            gn = o.getString("name");
                        }
                    }
                }
            }

            c.put("game", gn);
        }

        if (delay >= 0) {
            c.put("delay", delay);
        }

        j.put("channel", c);

        return GetData(request_type.PUT, base_url + "/channels/" + getIDFromChannel(channel), j.toString(), oauth,
                true);
    }

    /*
     * Updates the channel communities.
     */
    public JSONObject UpdateCommunities(String channel, String[] communities) {
        JSONObject j = new JSONObject("{}");
        List<String> c = new ArrayList<String>();

        if (communities.length < 1) {
            j.put("community_ids", c.toArray(new String[c.size()]));
            return GetData(request_type.PUT, base_url + "/channels/" + getIDFromChannel(channel) + "/communities",
                    j.toString(), oauth, true);
        }

        for (String community : communities) {
            JSONObject o = GetCommunityID(community);
            if (o.getBoolean("_success") && o.getInt("_http") == 200) {
                c.add(o.getString("_id"));
            }
        }

        j.put("community_ids", c.toArray(new String[c.size()]));

        return GetData(request_type.PUT, base_url + "/channels/" + getIDFromChannel(channel) + "/communities",
                j.toString(), oauth, true);
    }

    /*
     * Searches for a game.
     */
    public JSONObject SearchGame(String game) {
        try {
            String url = base_url + "/search/games?q=" + URLEncoder.encode(game, "UTF-8") + "&type=suggest";
            return GetData(request_type.GET, url, false);
        } catch (UnsupportedEncodingException ex) {
            JSONObject j = new JSONObject("{}");
            fillJSONObject(j, false, "", "", base_url + "/search/games", 0, ex.getClass().getName(),
                    ex.getMessage(), "");
            com.gmt2001.Console.err.println(ex.getClass().getName() + ": " + ex.getMessage());
            return j;
        }
    }

    /*
     * Gets a communities id.
     */
    public JSONObject GetCommunityID(String name) {
        return GetData(request_type.GET, base_url + "/communities?name=" + name, false);
    }

    /**
     * Gets an object listing the users following a channel
     *
     * @param channel
     * @param limit between 1 and 100
     * @param offset
     * @param ascending
     * @return
     */
    public JSONObject GetChannelFollows(String channel, int limit, int offset, boolean ascending) {
        limit = Math.max(0, Math.min(limit, 100));
        offset = Math.max(0, offset);
        String dir = ascending ? "asc" : "desc";
        return GetData(request_type.GET, base_url + "/channels/" + getIDFromChannel(channel) + "/follows?limit="
                + limit + "&offset=" + offset + "&direction=" + dir, false);
    }

    /**
     * Gets an object listing the users subscribing to a channel
     *
     * @param channel
     * @param limit between 1 and 100
     * @param offset
     * @param ascending
     * @return
     */
    public JSONObject GetChannelSubscriptions(String channel, int limit, int offset, boolean ascending) {
        return GetChannelSubscriptions(channel, limit, offset, ascending, this.oauth);
    }

    /**
     * Gets an object listing the users subscribing to a channel
     *
     * @param channel
     * @param limit between 1 and 100
     * @param offset
     * @param ascending
     * @param oauth
     * @return
     */
    public JSONObject GetChannelSubscriptions(String channel, int limit, int offset, boolean ascending,
            String oauth) {
        limit = Math.max(0, Math.min(limit, 100));
        offset = Math.max(0, offset);
        String dir = ascending ? "asc" : "desc";
        return GetData(request_type.GET, base_url + "/channels/" + getIDFromChannel(channel)
                + "/subscriptions?limit=" + limit + "&offset=" + offset + "&direction=" + dir, "", oauth, false);
    }

    /**
     * Gets a stream object
     *
     * @param channel
     * @return
     */
    public JSONObject GetStream(String channel) {
        return GetData(request_type.GET, base_url + "/streams/" + getIDFromChannel(channel), false);
    }

    /**
     * Gets a streams object array. Each channel id should be seperated with a comma.
     *
     * @param channels
     * @return
     */
    public JSONObject GetStreams(String channels) {
        return GetData(request_type.GET, base_url + "/streams?channel=" + channels, false);
    }

    /**
     * Gets the communities object array.
     *
     * @param channel
     * @return
     */
    public JSONObject GetCommunities(String channel) {
        return GetData(request_type.GET, base_url + "/channels/" + getIDFromChannel(channel) + "/communities",
                false);
    }

    /**
     * Gets a user object by user name
     *
     * @param user
     * @return
     */
    public JSONObject GetUser(String user) {
        return GetData(request_type.GET, base_url + "/users?login=" + user, false);
    }

    /**
     * Gets a user object by ID
     *
     * @param user
     * @return
     */
    public JSONObject GetUserByID(String userID) {
        return GetData(request_type.GET, base_url + "/users/" + userID, false);
    }

    /**
     * Runs a commercial
     *
     * @param channel
     * @param length (30, 60, 90)
     * @return
     */
    public JSONObject RunCommercial(String channel, int length) {
        return RunCommercial(channel, length, this.oauth);
    }

    /**
     * Runs a commercial
     *
     * @param channel
     * @param length (30, 60, 90)
     * @param oauth
     * @return
     */
    public JSONObject RunCommercial(String channel, int length, String oauth) {
        return GetData(request_type.POST, base_url + "/channels/" + getIDFromChannel(channel) + "/commercial",
                "length=" + length, oauth, false);
    }

    /**
     * Gets a list of users in the channel
     *
     * @param channel
     * @return
     */
    public JSONObject GetChatUsers(String channel) {
        return GetData(request_type.GET, "https://tmi.twitch.tv/group/user/" + channel + "/chatters", false);
    }

    /**
     * Checks if a user is following a channel
     *
     * @param user
     * @param channel
     * @return
     */
    public JSONObject GetUserFollowsChannel(String user, String channel) {
        return GetData(request_type.GET,
                base_url + "/users/" + getIDFromChannel(user) + "/follows/channels/" + getIDFromChannel(channel),
                false);
    }

    /**
     * Gets the full list of emotes from Twitch
     *
     * @return
     */
    public JSONObject GetEmotes() {
        return GetData(request_type.GET, base_url + "/chat/emoticons", false);
    }

    /**
     * Gets the list of cheer emotes from Twitch
     *
     * @return
     */
    public JSONObject GetCheerEmotes() {
        return GetData(request_type.GET, base_url + "/bits/actions", false);
    }

    /**
     * Builds a RegExp String to match cheer emotes from Twitch
     *
     * @return
     */
    public String GetCheerEmotesRegex() {
        String[] emoteList;
        JSONObject jsonInput;
        JSONArray jsonArray;

        if (cheerEmotes == "") {
            jsonInput = GetCheerEmotes();
            if (jsonInput.has("actions")) {
                jsonArray = jsonInput.getJSONArray("actions");
                emoteList = new String[jsonArray.length()];
                for (int idx = 0; idx < jsonArray.length(); idx++) {
                    emoteList[idx] = "\\b" + jsonArray.getJSONObject(idx).getString("prefix") + "\\d+\\b";
                }
                cheerEmotes = String.join("|", emoteList);
            }
        }
        return cheerEmotes;
    }

    /**
     * Gets the list of VODs from Twitch
     *
     * @param   channel  The channel requesting data for
     * @param   type  The type of data: current, highlights, archives
     * @return  String  List of Twitch VOD URLs (as a JSON String) or empty String in failure.
     */
    public String GetChannelVODs(String channel, String type) {
        JSONStringer jsonOutput = new JSONStringer();
        JSONObject jsonInput;
        JSONArray jsonArray;

        if (type.equals("current")) {
            jsonInput = GetData(request_type.GET,
                    base_url + "/channels/" + getIDFromChannel(channel) + "/videos?broadcast_type=archive&limit=1",
                    false);
            if (jsonInput.has("videos")) {
                jsonArray = jsonInput.getJSONArray("videos");
                if (jsonArray.length() > 0) {
                    if (jsonArray.getJSONObject(0).has("status")) {
                        if (jsonArray.getJSONObject(0).getString("status").equals("recording")) {
                            jsonOutput.object().key("videos").array().object();
                            jsonOutput.key("url").value(jsonArray.getJSONObject(0).getString("url"));
                            jsonOutput.key("recorded_at")
                                    .value(jsonArray.getJSONObject(0).getString("recorded_at"));
                            jsonOutput.key("length").value(jsonArray.getJSONObject(0).getInt("length"));
                            jsonOutput.endObject().endArray().endObject();
                        }
                        com.gmt2001.Console.debug.println("TwitchAPIv5::GetChannelVODs: " + jsonOutput.toString());
                        if (jsonOutput.toString() == null) {
                            return "";
                        }
                        return jsonOutput.toString();
                    }
                }
            }
        }

        if (type.equals("highlights")) {
            jsonInput = GetData(request_type.GET, base_url + "/channels/" + getIDFromChannel(channel)
                    + "/videos?broadcast_type=highlight&limit=5", false);
            if (jsonInput.has("videos")) {
                jsonArray = jsonInput.getJSONArray("videos");
                if (jsonArray.length() > 0) {
                    jsonOutput.object().key("videos").array();
                    for (int idx = 0; idx < jsonArray.length(); idx++) {
                        jsonOutput.object();
                        jsonOutput.key("url").value(jsonArray.getJSONObject(idx).getString("url"));
                        jsonOutput.key("recorded_at").value(jsonArray.getJSONObject(idx).getString("recorded_at"));
                        jsonOutput.key("length").value(jsonArray.getJSONObject(idx).getInt("length"));
                        jsonOutput.endObject();
                    }
                    jsonOutput.endArray().endObject();
                    com.gmt2001.Console.debug.println("TwitchAPIv5::GetChannelVODs: " + jsonOutput.toString());
                    if (jsonOutput.toString() == null) {
                        return "";
                    }
                    return jsonOutput.toString();
                }
            }
        }

        if (type.equals("archives")) {
            jsonInput = GetData(request_type.GET, base_url + "/channels/" + getIDFromChannel(channel)
                    + "/videos?broadcast_type=archive,upload&limit=5", false);
            if (jsonInput.has("videos")) {
                jsonArray = jsonInput.getJSONArray("videos");
                if (jsonArray.length() > 0) {
                    jsonOutput.object().key("videos").array();
                    for (int idx = 0; idx < jsonArray.length(); idx++) {
                        jsonOutput.object();
                        jsonOutput.key("url").value(jsonArray.getJSONObject(idx).getString("url"));
                        jsonOutput.key("recorded_at").value(jsonArray.getJSONObject(idx).getString("recorded_at"));
                        jsonOutput.key("length").value(jsonArray.getJSONObject(idx).getInt("length"));
                        jsonOutput.endObject();
                    }
                    jsonOutput.endArray().endObject();
                    com.gmt2001.Console.debug.println("TwitchAPIv5::GetChannelVODs: " + jsonOutput.toString());
                    if (jsonOutput.toString() == null) {
                        return "";
                    }
                    return jsonOutput.toString();
                }
            }
        }

        /* Just return an empty string. */
        return "";
    }

    /**
     * Returns when a Twitch account was created.
     *
     * @param   channel
     * @return  String   date-time representation (2015-05-09T00:08:04Z)
     */
    public String getChannelCreatedDate(String channel) {
        JSONObject jsonInput = GetData(request_type.GET, base_url + "/channels/" + getIDFromChannel(channel),
                false);
        if (jsonInput.has("created_at")) {
            return jsonInput.getString("created_at");
        }
        return "ERROR";
    }

    /**
      * Checks to see if the bot account is verified by Twitch.
      *
      * @param  channel
      * @return boolean  true if verified
      */
    public boolean getBotVerified(String channel) {
        JSONObject jsonInput = GetData(request_type.GET, base_url + "/users/" + getIDFromChannel(channel) + "/chat",
                false);
        if (jsonInput.has("is_verified_bot")) {
            return jsonInput.getBoolean("is_verified_bot");
        }
        return false;
    }

    /**
     * Get the clips from today for a channel.
     *
     * @param channel
     * @return JSONObject  clips object.
     */
    public JSONObject getClipsToday(String channel) {
        /* Yes, the v5 endpoint for this does use the Channel Name and not the ID. */
        return GetData(request_type.GET, base_url + "/clips/top?channel=" + channel + "&limit=100&period=day",
                false);
    }

    /**
     * Populates the followed table from a JSONArray. The database auto commit is disabled
     * as otherwise the large number of writes in a row can cause some delay.  We only
     * update the followed table if the user has an entry in the time table. This way we
     * do not potentially enter thousands, or tens of thousands, or even more, entries into
     * the followed table for folks that do not visit the stream.
     *
     * @param   JSONArray   JSON array object containing the followers data
     * @param   DataStore   Copy of database object for writing
     * @return  int         How many objects were inserted into the database
     */
    private int PopulateFollowedTable(JSONArray followsArray, DataStore dataStore) {
        int insertCtr = 0;
        dataStore.setAutoCommit(false);
        for (int idx = 0; idx < followsArray.length(); idx++) {
            if (followsArray.getJSONObject(idx).has("user")) {
                if (followsArray.getJSONObject(idx).getJSONObject("user").has("name")) {
                    if (dataStore.exists("time",
                            followsArray.getJSONObject(idx).getJSONObject("user").getString("name"))) {
                        insertCtr++;
                        dataStore.set("followed_fixtable",
                                followsArray.getJSONObject(idx).getJSONObject("user").getString("name"), "true");
                    }
                }
            }
        }
        dataStore.setAutoCommit(true);
        return insertCtr;
    }

    /**
     * Updates the followed table with a complete list of followers. This should only ever
     * be executed once, when the database does not have complete list of followers.
     *
     * @param   String      Name of the channel to lookup data for
     * @param   DataStore   Copy of database object for reading data from
     * @param   int         Total number of followers reported from Twitch API
     */
    @SuppressWarnings("SleepWhileInLoop")
    private void FixFollowedTableWorker(String channel, DataStore dataStore, int followerCount) {
        int insertCtr = 0;
        JSONObject jsonInput;
        String baseLink = base_url + "/channels/" + getIDFromChannel(channel) + "/follows";
        String nextLink = baseLink + "?limit=100";

        com.gmt2001.Console.out.println(
                "FixFollowedTable: Retrieving followers that exist in the time table, this may take some time.");

        /* Perform the lookups. The initial lookup will return the next API endpoint
         * as a _cursor object. Use this to build the next query. We do this to prepare
         * for Twitch API v5 which will require this.
         */
        do {
            jsonInput = GetData(request_type.GET, nextLink, false);
            if (!jsonInput.has("follows")) {
                return;
            }
            insertCtr += PopulateFollowedTable(jsonInput.getJSONArray("follows"), dataStore);

            if (!jsonInput.has("_cursor")) {
                break;
            }
            nextLink = baseLink + "?limit=100&cursor=" + jsonInput.getString("_cursor");

            /* Be kind to Twitch during this process. */
            try {
                Thread.sleep(1000);
            } catch (InterruptedException ex) {
                /* Since it might be possible that we have hundreds, even thousands of calls,
                 * we do not dump even a debug statement here.
                 */
            }
        } while (jsonInput.getJSONArray("follows").length() > 0);

        dataStore.RenameFile("followed_fixtable", "followed");
        com.gmt2001.Console.out.println("FixFollowedTable: Pulled followers into the followed table, loaded "
                + insertCtr + "/" + followerCount + " records.");
    }

    /**
     * Wrapper to perform the followed table updated.  In order to ensure that PhantomBot
     * does not get stuck trying to perform this work, a thread is spawned to perform the
     * work.
     *
     * @param   channel      Name of the channel to lookup data for
     * @param   dataStore   Copy of database object
     * @param   force     Force the run even if the number of followers is too high
     */
    public void FixFollowedTable(String channel, DataStore dataStore, Boolean force) {

        /* Determine number of followers to determine if this should not execute unless forced. */
        JSONObject jsonInput = GetData(request_type.GET,
                base_url + "/channels/" + getIDFromChannel(channel) + "/follows?limit=1", false);
        if (!jsonInput.has("_total")) {
            com.gmt2001.Console.err.println("Failed to pull follower count for FixFollowedTable");
            return;
        }
        int followerCount = jsonInput.getInt("_total");
        if (followerCount > 10000 && !force) {
            com.gmt2001.Console.out.println(
                    "Follower count is above 10,000 (" + followerCount + "). Not executing. You may force this.");
            return;
        }

        try {
            FixFollowedTableRunnable fixFollowedTableRunnable = new FixFollowedTableRunnable(channel, dataStore,
                    followerCount);
            new Thread(fixFollowedTableRunnable, "com.gmt2001.TwitchAPIv5::fixFollowedTable").start();
        } catch (Exception ex) {
            com.gmt2001.Console.err.println("Failed to start thread for updating followed table.");
        }
    }

    /**
     * Class for Thread for running the FixFollowedTableWorker job in the background.
     */
    private class FixFollowedTableRunnable implements Runnable {
        private final DataStore dataStore;
        private final String channel;
        private final int followerCount;

        public FixFollowedTableRunnable(String channel, DataStore dataStore, int followerCount) {
            this.channel = channel;
            this.dataStore = dataStore;
            this.followerCount = followerCount;
        }

        @Override
        public void run() {
            FixFollowedTableWorker(channel, dataStore, followerCount);
        }
    }

    /**
     * Tests the Twitch API to ensure that authentication is good.
     * @return
     */
    public boolean TestAPI() {
        JSONObject jsonObject = GetData(request_type.GET, base_url, false);
        if (jsonObject.has("identified")) {
            return jsonObject.getBoolean("identified");
        }
        return false;
    }

    /**
     * Returns a username when given an Oauth.
     *
     * @param   userOauth      Oauth to check with.
     * @return  String      The name of the user or null to indicate that there was an error.
     */
    public String GetUserFromOauth(String userOauth) {
        JSONObject jsonInput = GetData(request_type.GET, base_url, "", userOauth, false);
        if (jsonInput.has("token")) {
            if (jsonInput.getJSONObject("token").has("user_name")) {
                com.gmt2001.Console.out
                        .println("username = " + jsonInput.getJSONObject("token").getString("user_name"));
                return jsonInput.getJSONObject("token").getString("user_name");
            }
        }
        return null;
    }

    /**
     * Returns the channel Id
     *
     * @param   channel      channel name
     * @return  int      the channel id.
     */
    public int getChannelId(String channel) {
        return Integer.parseUnsignedInt(UsernameCache.instance().getID(channel));
    }
}