package chatty.util; import chatty.Chatty; import chatty.Helper; import chatty.util.api.Emoticon; import java.util.Collections; import java.util.HashSet; import java.util.Set; import java.util.logging.Logger; import org.json.simple.JSONArray; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; /** * Requests and parses the BTTV emotes. * * @author tduva */ public class BTTVEmotes { private static final Logger LOGGER = Logger.getLogger(BTTVEmotes.class.getName()); private static final String URL = "https://api.betterttv.net/2/emotes"; //private static final String URL = "http://127.0.0.1/twitch/emotes.json"; private static final String URL_CHANNEL = "https://api.betterttv.net/2/channels/"; private static final int CACHE_EXPIRES_AFTER = 60 * 60 * 24; private static final String FILE = Chatty.getCacheDirectory() + "bttvemotes"; private final EmoticonListener listener; private final SimpleCache cache; private final Set<String> requestPending = Collections.synchronizedSet(new HashSet<String>()); private final Set<String> alreadyRequested = Collections.synchronizedSet(new HashSet<String>()); public BTTVEmotes(EmoticonListener listener) { this.listener = listener; this.cache = new SimpleCache("BTTV", FILE, CACHE_EXPIRES_AFTER); } public synchronized void requestEmotes(String channel, boolean forcedUpdate) { String stream = Helper.toStream(channel); if (!Helper.validateStream(stream) && !"$global$".equals(stream)) { return; } if (!forcedUpdate && alreadyRequested.contains(stream)) { return; } if (stream.equals("$global$")) { loadGlobal(forcedUpdate); } else { request(stream); } } private void loadGlobal(boolean forcedUpdate) { String cached = null; if (!forcedUpdate) { cached = cache.load(); } if (cached != null) { loadEmotes(cached, null); } else { request("$global$"); } } private void request(final String stream) { if (requestPending.contains(stream)) { return; } String url = getUrlForStream(stream); alreadyRequested.add(stream); requestPending.add(stream); UrlRequest request = new UrlRequest(url) { @Override public void requestResult(String result, int responseCode) { if (responseCode == 200) { if (loadEmotes(result, stream) > 0 && stream.equals("$global$")) { cache.save(result); } } requestPending.remove(stream); } }; request.setLabel("[BTTV]"); new Thread(request).start(); } private String getUrlForStream(String stream) { if (stream.equals("$global$")) { return URL; } return URL_CHANNEL+stream; } /** * Load stuff from the given JSON in the context of the given channel * restriction. The channel restriction can be "$global$" which means all * channels. * * @param json The JSON * @param streamRestriction * @return */ private int loadEmotes(String json, String streamRestriction) { if (streamRestriction != null && streamRestriction.equals("$global$")) { streamRestriction = null; } Set<Emoticon> emotes = parseEmotes(json, streamRestriction); Set<String> bots = parseBots(json); LOGGER.info("BTTV: Found " + emotes.size() + " emotes / "+bots.size()+" bots"); listener.receivedEmoticons(emotes); listener.receivedBotNames(streamRestriction, bots); return emotes.size(); } /** * Parse list of bots from the given JSON. * * @param json * @return */ private static Set<String> parseBots(String json) { Set<String> result = new HashSet<>(); if (json == null) { return result; } JSONParser parser = new JSONParser(); try { JSONObject root = (JSONObject)parser.parse(json); JSONArray botsArray = (JSONArray)root.get("bots"); if (botsArray == null) { // No bots for this channel return result; } for (Object o : botsArray) { result.add((String)o); } } catch (ParseException | ClassCastException ex) { LOGGER.warning("BTTV: Error parsing bots: "+ex); } return result; } /** * Parse emotes from the given JSON. * * @param json * @param channelRestriction * @return */ private static Set<Emoticon> parseEmotes(String json, String channelRestriction) { Set<Emoticon> emotes = new HashSet<>(); if (json == null) { return emotes; } JSONParser parser = new JSONParser(); try { JSONObject root = (JSONObject)parser.parse(json); String urlTemplate = (String)root.get("urlTemplate"); if (urlTemplate == null || urlTemplate.isEmpty()) { LOGGER.warning("No URL Template"); return emotes; } JSONArray emotesArray = (JSONArray)root.get("emotes"); for (Object o : emotesArray) { if (o instanceof JSONObject) { Emoticon emote = parseEmote((JSONObject)o, urlTemplate, channelRestriction); if (emote != null) { emotes.add(emote); } } } } catch (ParseException | ClassCastException ex) { // ClassCastException is also caught in parseEmote(), so it won't // quit completely when one emote is invalid. LOGGER.warning("BTTV: Error parsing emotes: "+ex); } return emotes; } /** * Parse a single emote from the given JSONObject. * * @param o The object containing the emote info * @param urlTemplate The URL Template to use for this emote * @param channelRestriction The channel restriction to use * @return */ private static Emoticon parseEmote(JSONObject o, String urlTemplate, String channelRestriction) { try { String url = urlTemplate; String code = (String)o.get("code"); String info = (String)o.get("channel"); String id = (String)o.get("id"); String imageType = null; if (o.get("imageType") instanceof String) { imageType = (String)o.get("imageType"); } if (code == null || code.isEmpty() || id == null || id.isEmpty()) { return null; } Emoticon.Builder builder = new Emoticon.Builder(Emoticon.Type.BTTV, code, url); builder.setStream(info); builder.setLiteral(true); builder.setStringId(id); if (channelRestriction != null) { builder.addStreamRestriction(channelRestriction); } if (imageType != null && imageType.equals("gif")) { builder.setAnimated(true); } // Adds restrictions to emote (if present) Object restriction = o.get("restrictions"); if (restriction != null && restriction instanceof JSONObject) { JSONObject restrictions = (JSONObject)restriction; for (Object r : restrictions.keySet()) { boolean knownAndValid = addRestriction(r, restrictions, builder); // Don't add emotes with unknown or invalid restrictions if (!knownAndValid) { return null; } } } return builder.build(); } catch (ClassCastException | NullPointerException ex) { LOGGER.warning("BTTV: Error parsing emote: "+o+" ["+ex+"]"); return null; } } /** * Helper to add a restriction to the emote. Returns whether the restriction * is known and valid, so unknown restrictions can prevent the emote from * being added at all. * * @param restriction The name of the restriction * @param restrictions The value(s) of the restriction * @param builder Emote builder to put the restriction in * @return true if the restriction is known and valid, false otherwise */ private static boolean addRestriction(Object restriction, JSONObject restrictions, Emoticon.Builder builder) { try { String key = (String)restriction; if (key.equals("channels")) { for (Object chan : (JSONArray) restrictions.get(restriction)) { if (chan instanceof String) { builder.addStreamRestriction((String) chan); } } return true; } else if (key.equals("emoticonSet")) { Object emoticon_set = restrictions.get(key); if (emoticon_set != null) { if (emoticon_set instanceof String) { // This also includes "night" return false; } else { builder.setEmoteset(((Number) emoticon_set).intValue()); return true; } } } else { /** * Unknown or unhandled restriction, ignore restriction and * return true anyway if restriction value is empty. */ Object value = restrictions.get(restriction); if (value == null || ((JSONArray)value).isEmpty()) { return true; } } } catch (NullPointerException | ClassCastException ex) { // Don't do anything, just return false } return false; } }