package chatty.util.api;
import chatty.Helper;
import chatty.util.StringUtil;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Map;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
import java.util.logging.Logger;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
/**
*
* @author tduva
*/
public class UserIDs {
private static final Logger LOGGER = Logger.getLogger(UserIDs.class.getName());
private static final long CHECK_PENDING_DELAY = 10*1000;
private static final long REQUEST_DELAY = 5;
private static final long ERROR_PENALTY = 30;
private final Data data = new Data();
private final Collection<Request> requests = new LinkedList<>();
private final Set<String> requestPending = new HashSet<>();
private int errors = 0;
private long lastRequest = 0;
private final TwitchApi api;
public UserIDs(TwitchApi api) {
this.api = api;
Timer checkPending = new Timer("UserIDPending", true);
checkPending.schedule(new TimerTask() {
@Override
public void run() {
checkRequest();
}
}, CHECK_PENDING_DELAY, CHECK_PENDING_DELAY);
}
/**
* Request the userids for the given names, and wait for the result. Will
* only return a result if the id for *all* names is available, so the
* caller may end up waiting forever. This is useful if not having the code
* prevents you from progressing and any way.
*
* These are not removed, unless the request succeeds, but the name is not
* found. This means that requests can more easily stack up, so the caller
* should make sure to prevent that, or else if errors clear up (like the
* API can suddenly be reached after some time) a whole lot of request
* listeners may be called at once.
*
* @param result
* @param usernames
*/
public void waitForUserIDs(UserIdResultListener result, String... usernames) {
Collection names = prepareNames(usernames);
addRequest(result, names, true);
checkDoneRequests(true);
checkRequest();
}
/**
* Request the ID for each of the given names, as soon as possible. This
* will not wait for a request, it will request missing IDs immediately.
* This can be used for actions that are triggered by the user and thus
* should be done asap.
*
* @param result
* @param usernames
*/
public void getUserIDsAsap(UserIdResultListener result, String... usernames) {
Collection names = prepareNames(usernames);
addRequest(result, names, false);
checkDoneRequests(true);
performRequest();
}
/**
* Convenience method for getUserIDs(UserIdResultListener, Collection<String>).
*
* @param result
* @param usernames
*/
public void getUserIDs(UserIdResultListener result, String... usernames) {
Collection names = prepareNames(usernames);
getUserIDs(result, names);
}
/**
* Gets the given user ids, if cached, or otherwise requests them on the
* normal request cycle (so it could take a while).
*
* @param result
* @param names
*/
public void getUserIDs(UserIdResultListener result, Collection<String> names) {
addRequest(result, names, false);
checkDoneRequests(true);
}
/**
* Return cached results if all names are cached (whether they have an id
* or not, but have been requsted before), or null. Adds any names missing
* an id to be requested.
*
* @param usernames
* @return
*/
public synchronized UserIdResult requestUserIDs(String... usernames) {
Collection names = prepareNames(usernames);
UserIdResult result = getCachedResult(names);
if (result == null || result.getValidIDs().size() < names.size()) {
addRequest(null, names, false);
}
return result;
}
private Collection<String> prepareNames(String[] usernames) {
Collection<String> names = new ArrayList<>();
for (String name : usernames) {
names.add(StringUtil.toLowerCase(name));
}
return names;
}
public void setUserId(String name, String id) {
System.out.println("setUserId "+name+" "+id);
if (name == null || id == null || name.isEmpty() || id.isEmpty()) {
return;
}
data.setId(StringUtil.toLowerCase(name), id);
}
public void handleRequestResult(Set<String> requestedNames, String text) {
synchronized(this) {
requestPending.removeAll(requestedNames);
Map<String, String> result = parseResult(text);
if (result == null) {
requestedNames.stream().forEach(n -> data.setError(n));
errors++;
} else {
for (String name : requestedNames) {
if (!result.keySet().contains(name)) {
data.setNotFound(name);
} else {
data.setId(name, result.get(name));
}
}
errors = errors > 0 ? errors - 1 : 0;
}
}
checkDoneRequests(false);
}
private static Map<String, String> parseResult(String text) {
if (text == null) {
return null;
}
Map<String, String> result = new HashMap<>();
try {
JSONParser parser = new JSONParser();
JSONObject root = (JSONObject)parser.parse(text);
JSONArray users = (JSONArray)root.get("users");
for (Object o : users) {
JSONObject user = (JSONObject)o;
String id = (String)user.get("_id");
String name = (String)user.get("name");
if (id != null && name != null) {
result.put(name, id);
}
}
return result;
} catch (Exception ex) {
LOGGER.warning("Error parsing userids: "+ex);
return null;
}
}
private synchronized void checkRequest() {
long timePassed = System.currentTimeMillis() - lastRequest;
if (timePassed > (REQUEST_DELAY + ERROR_PENALTY * errors) * 1000) {
performRequest();
}
}
private synchronized void performRequest() {
if (requests.isEmpty()) {
return;
}
Set<String> namesToRequest = new HashSet<>();
requests.stream().forEach(r -> {
r.usernames.stream().forEach(n -> {
if (!requestPending.contains(n) && data.shouldRequest(n) && namesToRequest.size() < 100) {
if (Helper.validateStreamStrict(n)) {
namesToRequest.add(n);
requestPending.add(n);
} else {
data.setNotFound(n);
}
}
});
});
if (!namesToRequest.isEmpty()) {
api.requests.requestUserIDs(namesToRequest);
lastRequest = System.currentTimeMillis();
} else {
checkDoneRequests(false);
}
}
private synchronized void addRequest(UserIdResultListener result, Collection<String> usernames,
boolean wait) {
requests.add(new Request(new HashSet<>(usernames), result, wait));
//System.out.println("Added request: "+requests);
}
private void checkDoneRequests(boolean onlyComplete) {
Collection<Request> done = getDoneRequests(onlyComplete);
if (done == null) {
return;
}
for (Request r : done) {
if (r.listener != null) {
r.listener.result(r.getResult());
}
}
clearUp();
}
private synchronized Collection<Request> getDoneRequests(boolean onlyComplete) {
if (requests.isEmpty()) {
return null;
}
Collection<Request> result = new ArrayList<>();
for (Request r : requests) {
UserIdResult idResult = getCachedResult(r.usernames);
if (idResult != null
&& (!r.wait || !idResult.hasError())
&& (!onlyComplete || !eligibleForRequest(r))) {
r.setResult(idResult);
result.add(r);
}
}
requests.removeAll(result);
return result;
}
/**
* Returns the Entry object for all given usernames, or null if not all
* are cached. Note that the Entry objects may not have an idea attached,
* they could also have been added due to a request error. It just means
* that they have been requested before, successful or not.
*
* @param usernames
* @return
*/
private synchronized UserIdResult getCachedResult(Collection<String> usernames) {
Map<String, Entry> result = data.get(usernames);
if (result.size() < usernames.size()) {
return null;
}
return new UserIdResult(result);
}
private synchronized void clearUp() {
// System.out.println("Before cleanup: "+requests);
if (!requests.isEmpty()) {
Iterator<Request> it = requests.iterator();
while (it.hasNext()) {
if (!eligibleForRequest(it.next())) {
it.remove();
}
}
}
// System.out.println("After cleanup: "+requests);
}
/**
* Returns true if at least one of the names in the Request is still
* eligible for request.
*
* @param request
* @return
*/
private boolean eligibleForRequest(Request request) {
for (String name : request.usernames) {
if (data.shouldRequest(name)) {
return true;
}
}
return false;
}
public interface UserIdResultListener {
public void result(UserIdResult result);
}
public static class UserIdResult {
private final Map<String, String> data = new HashMap<>();
private boolean hasError;
private String error;
private UserIdResult(Map<String, Entry> result) {
for (Entry entry : result.values()) {
if (entry.id != null) {
data.put(entry.name, entry.id);
}
if (entry.id == null) {
hasError = true;
}
if (entry.notFound) {
error = "User not found";
}
}
}
public boolean hasError() {
return hasError;
}
public String getError() {
if (hasError && error == null) {
return "Unknown Error";
}
return error;
}
public Collection<String> getValidIDs() {
return data.values();
}
public Map<String, String> getData() {
return data;
}
public String getId(String name) {
return data.get(StringUtil.toLowerCase(name));
}
@Override
public String toString() {
return data.toString()+"/"+getError();
}
}
public static void main(String[] args) {
// UserIDs2 u = new UserIDs2();
// u.setUserId("a", null);
// u.setUserId("b", "bid");
// u.getUserIDs(r -> {
// System.out.println(r.hasError()+" "+r.getValidIDs());
// }, "a", "b");
}
private static class Request {
private final Set<String> usernames;
private final UserIdResultListener listener;
private final boolean wait;
private UserIdResult result;
Request(Set<String> usernames, UserIdResultListener listener, boolean wait) {
this.usernames = usernames;
this.listener = listener;
this.wait = wait;
}
public synchronized void setResult(UserIdResult result) {
this.result = result;
}
public synchronized UserIdResult getResult() {
return result;
}
@Override
public String toString() {
return String.format("{%s/%s}", usernames.toString(), wait);
}
}
private static class Data {
private final Map<String, Entry> data = new HashMap<>();
public synchronized void put(String name, Entry entry) {
data.put(name, entry);
}
public synchronized boolean containsKey(String name) {
return data.containsKey(name);
}
public synchronized Entry get(String name) {
return data.get(name);
}
public synchronized boolean setId(String name, String id) {
if (!data.containsKey(name) || get(name).id == null) {
data.put(name, new Entry(name, id));
return true;
}
return false;
}
public synchronized void setNotFound(String name) {
Entry entry;
if (!data.containsKey(name)) {
entry = new Entry(name, null);
data.put(name, entry);
} else {
entry = data.get(name);
}
entry.notFound = true;
entry.errors += 4;
}
public synchronized void setError(String name) {
if (data.containsKey(name)) {
// Entry entry = data.get(name);
// entry.errors++;
// System.out.println(entry);
} else {
Entry entry = new Entry(name, null);
//entry.errors++;
data.put(name, entry);
}
}
/**
* Return all Entry objects for the given names, as far as they are
* available. This includes all Entry objects, even those that don't
* contain an id (that would be from errored requests, or if the name
* doesn't exist at all in the API).
*
* @param usernames
* @return
*/
public synchronized Map<String, Entry> get(Collection<String> usernames) {
Map<String, Entry> result = new HashMap<>();
for (String name : usernames) {
if (data.containsKey(name)) {
result.put(name, data.get(name));
}
}
return result;
}
public synchronized boolean hasId(String name) {
return data.containsKey(name) && data.get(name).id != null;
}
/**
* Returns true if the id for this name should be requested again, so if
* it hasn't been requested yet, or only few errors occured so far.
*
* @param name
* @return
*/
public synchronized boolean shouldRequest(String name) {
if (!data.containsKey(name)) {
return true;
}
Entry entry = data.get(name);
return entry.id == null && entry.errors < 10;
}
}
private static class Entry {
private final String name;
private final String id;
private volatile boolean notFound;
private int errors;
public Entry(String name, String id) {
this.name = name;
this.id = id;
}
public String getName() {
return name;
}
public String getId() {
return id;
}
public boolean notFound() {
return notFound;
}
public int errorCount() {
return errors;
}
@Override
public String toString() {
return name+"/"+id+"/"+(notFound ? "n/a" : "")+"/"+errors;
}
}
}