Rework PlayerHead Getter. (#1446)

* Rework PlayerHead Getter.

Generate player head based on WebAPI (as it is faster) and GameProfile texture (require NMS).
Cache is suitable for storing into file format.
Add ability to add custom HeadCache object into local cache.
Add ability to modify cache keeping length.
Add ability to keep all, or just a single element into cache until server restart.

* Address issues/improvements suggested from review.

- config will store time in minutes.
- default value will be 1h.
This commit is contained in:
BONNe 2020-07-11 15:13:32 +03:00 committed by GitHub
parent 0df69f1498
commit fa259611fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 433 additions and 54 deletions

View File

@ -184,6 +184,13 @@
</repositories>
<dependencies>
<!-- Spigot NMS. Used for Head Getter. -->
<dependency>
<groupId>org.spigotmc</groupId>
<artifactId>spigot</artifactId>
<version>${spigot.version}</version>
<scope>provided</scope>
</dependency>
<!-- Spigot API -->
<dependency>
<groupId>org.spigotmc</groupId>

View File

@ -126,6 +126,12 @@ public class Settings implements ConfigObject {
@ConfigEntry(path = "panel.filler-material", since = "1.14.0")
private Material panelFillerMaterial = Material.LIGHT_BLUE_STAINED_GLASS_PANE;
@ConfigComment("Defines how long player skin texture link is stored into local cache before it is requested again.")
@ConfigComment("Defined value is in the minutes.")
@ConfigComment("Value 0 will not clear cache until server restart.")
@ConfigEntry(path = "panel.head-cache-time", since = "1.14.1")
private long playerHeadCacheTime = 60;
/*
* Logs
*/
@ -714,4 +720,27 @@ public class Settings implements ConfigObject {
public void setPanelFillerMaterial(Material panelFillerMaterial) {
this.panelFillerMaterial = panelFillerMaterial;
}
/**
* Method Settings#getPlayerHeadCacheTime returns the playerHeadCacheTime of this object.
*
* @return the playerHeadCacheTime (type long) of this object.
* @since 1.14.1
*/
public long getPlayerHeadCacheTime()
{
return playerHeadCacheTime;
}
/**
* Method Settings#setPlayerHeadCacheTime sets new value for the playerHeadCacheTime of this object.
* @param playerHeadCacheTime new value for this object.
* @since 1.14.1
*/
public void setPlayerHeadCacheTime(long playerHeadCacheTime)
{
this.playerHeadCacheTime = playerHeadCacheTime;
}
}

View File

@ -161,7 +161,10 @@ public class PanelItem {
}
public void setHead(ItemStack itemStack) {
// update amount before replacing.
itemStack.setAmount(this.icon.getAmount());
this.icon = itemStack;
// Get the meta
meta = icon.getItemMeta();
if (meta != null) {

View File

@ -1,35 +1,157 @@
package world.bentobox.bentobox.util.heads;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.lang.reflect.Field;
import java.util.UUID;
import com.mojang.authlib.GameProfile;
import com.mojang.authlib.properties.Property;
import world.bentobox.bentobox.BentoBox;
/**
* @since 1.14.0
* @author tastybento
* This would allow to implement changeable player head for server owner.
* @since 1.14.1
* @author tastybento, BONNe1704
*/
public class HeadCache {
private final ItemStack head;
private final long timestamp;
public class HeadCache
{
// ---------------------------------------------------------------------
// Section: Variables
// ---------------------------------------------------------------------
/**
* @param head - head ItemStack
* @param timestamp - timestamp when made
* Username for cached head.
*/
public HeadCache(ItemStack head, long timestamp) {
super();
this.head = head;
private final String userName;
/**
* Userid for cached head.
*/
private final UUID userId;
/**
* Base64 Encoded texture link to given player skin.
*/
public final String encodedTextureLink;
/**
* Time when head was created. Setting it to 0 will result in keeping head in cache
* for ever.
*/
private final long timestamp;
// ---------------------------------------------------------------------
// Section: Constructors
// ---------------------------------------------------------------------
/**
* Constructor HeadCache creates a new HeadCache instance.
*
* @param userName of type String
* @param userId of type String
* @param encodedTextureLink of type String
*/
public HeadCache(String userName, UUID userId, String encodedTextureLink)
{
this(userName, userId, encodedTextureLink, System.currentTimeMillis());
}
/**
* Constructor HeadCache creates a new HeadCache instance.
*
* @param userName of type String
* @param userId of type UUID
* @param encodedTextureLink of type String
* @param timestamp of type long
*/
public HeadCache(String userName,
UUID userId,
String encodedTextureLink,
long timestamp)
{
this.userName = userName;
this.encodedTextureLink = encodedTextureLink;
this.userId = userId;
this.timestamp = timestamp;
}
// ---------------------------------------------------------------------
// Section: Methods
// ---------------------------------------------------------------------
/**
* @return the head
* Returns a new Player head with a cached texture. Be AWARE, usage does not use clone
* method. If for some reason item stack is stored directly, then use clone in return
* :)
*
* @return an ItemStack of the custom head.
*/
public ItemStack getHead() {
return head;
public ItemStack getPlayerHead()
{
ItemStack item = new ItemStack(Material.PLAYER_HEAD);
ItemMeta meta = item.getItemMeta();
// Set correct Skull texture
if (meta != null && this.encodedTextureLink != null && !this.encodedTextureLink.isEmpty())
{
GameProfile profile = new GameProfile(this.userId, this.userName);
profile.getProperties().put("textures",
new Property("textures", this.encodedTextureLink));
try
{
Field profileField = meta.getClass().getDeclaredField("profile");
profileField.setAccessible(true);
profileField.set(meta, profile);
item.setItemMeta(meta);
}
catch (NoSuchFieldException | IllegalArgumentException | IllegalAccessException e)
{
BentoBox.getInstance().log("Error while creating Skull Icon");
}
}
return item;
}
/**
* @return the timestamp
* Method HeadCache#getUserName returns the userName of this object.
*
* @return the userName (type String) of this object.
*/
public long getTimestamp() {
public String getUserName()
{
return this.userName;
}
/**
* Method HeadCache#getTimestamp returns the timestamp of this object.
*
* @return the timestamp (type long) of this object.
*/
public long getTimestamp()
{
return timestamp;
}
/**
* Method HeadCache#getUserId returns the userId of this object.
*
* @return the userId (type UUID) of this object.
*/
public UUID getUserId()
{
return this.userId;
}
}

View File

@ -1,78 +1,291 @@
package world.bentobox.bentobox.util.heads;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.SkullMeta;
import org.eclipse.jdt.annotation.Nullable;
import world.bentobox.bentobox.BentoBox;
import world.bentobox.bentobox.api.panels.PanelItem;
public class HeadGetter {
private static Map<String,HeadCache> cachedHeads = new HashMap<>();
/**
* This class manages getting player heads for requester.
* @author tastybento, BONNe1704
*/
public class HeadGetter
{
/**
* Local cache for storing player heads.
*/
private static final Map<String, HeadCache> cachedHeads = new HashMap<>();
/**
* Local cache for storing requested names and items which must be updated.
*/
private static final Map<String, PanelItem> names = new HashMap<>();
private static final long TOO_LONG = 360000; // 3 minutes
private BentoBox plugin;
private static Map<String,Set<HeadRequester>> headRequesters = new HashMap<>();
/**
* Requesters of player heads.
*/
private static final Map<String, Set<HeadRequester>> headRequesters = new HashMap<>();
/**
* Instance of plugin.
*/
private final BentoBox plugin;
/**
* @param plugin - plugin
*/
public HeadGetter(BentoBox plugin) {
super();
public HeadGetter(BentoBox plugin)
{
this.plugin = plugin;
runPlayerHeadGetter();
this.runPlayerHeadGetter();
}
@SuppressWarnings("deprecation")
private void runPlayerHeadGetter() {
/**
* @param panelItem - head to update
* @param requester - callback class
*/
public static void getHead(PanelItem panelItem, HeadRequester requester)
{
// Freshen cache
// If memory is an issue we sacrifice performance?
// cachedHeads.values().removeIf(cache -> System.currentTimeMillis() - cache.getTimestamp() > TOO_LONG);
HeadCache cache = cachedHeads.get(panelItem.getPlayerHeadName());
// Get value from config. Multiply value to 60 000 as internally it uses miliseconds.
// Config value stores minutes.
long cacheTimeout = BentoBox.getInstance().getSettings().getPlayerHeadCacheTime() * 60 * 1000;
// to avoid every time clearing stored heads (as they may become very large)
// just check if requested cache exists and compare it with value from plugin settings.
// If timestamp is set to 0, then it must be kept forever.
// If settings time is set to 0, then always use cache.
if (cache != null &&
cache.getTimestamp() != 0 &&
cacheTimeout > 0 &&
System.currentTimeMillis() - cache.getTimestamp() <= cacheTimeout)
{
panelItem.setHead(cachedHeads.get(panelItem.getPlayerHeadName()).getPlayerHead());
requester.setHead(panelItem);
}
else
{
// Get the name
headRequesters.computeIfAbsent(panelItem.getPlayerHeadName(), k -> new HashSet<>()).
add(requester);
names.put(panelItem.getPlayerHeadName(), panelItem);
}
}
/**
* This method allows to add HeadCache object into local cache.
* It will provide addons to use HeadGetter cache directly.
* @param cache Cache object that need to be added into local cache.
* @since 1.14.1
*/
public static void addToCache(HeadCache cache)
{
cachedHeads.put(cache.getUserName(), cache);
}
// ---------------------------------------------------------------------
// Section: Private methods
// ---------------------------------------------------------------------
/**
* This is main task that runs once every 20 ticks and tries to get a player head.
*/
private void runPlayerHeadGetter()
{
Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, () -> {
synchronized(names) {
synchronized (names)
{
Iterator<Entry<String, PanelItem>> it = names.entrySet().iterator();
if (it.hasNext()) {
Entry<String, PanelItem> en = it.next();
ItemStack playerSkull = new ItemStack(Material.PLAYER_HEAD, en.getValue().getItem().getAmount());
SkullMeta meta = (SkullMeta) playerSkull.getItemMeta();
meta.setOwner(en.getKey());
playerSkull.setItemMeta(meta);
if (it.hasNext())
{
Entry<String, PanelItem> elementEntry = it.next();
// TODO: In theory BentoBox could use User instance to find existing user UUID's.
// It would avoid one API call.
final String userName = elementEntry.getKey();
// Use cached userId as userId will not change :)
UUID userId = cachedHeads.containsKey(userName) ?
cachedHeads.get(userName).getUserId() :
HeadGetter.getUserIdFromName(userName);
// Create new cache object.
HeadCache cache = new HeadCache(userName,
userId,
HeadGetter.getTextureFromUUID(userId));
// Save in cache
cachedHeads.put(en.getKey(), new HeadCache(playerSkull, System.currentTimeMillis()));
cachedHeads.put(userName, cache);
// Tell requesters the head came in
if (headRequesters.containsKey(en.getKey())) {
for (HeadRequester req : headRequesters.get(en.getKey())) {
en.getValue().setHead(playerSkull.clone());
Bukkit.getServer().getScheduler().runTask(plugin, () -> req.setHead(en.getValue()));
if (headRequesters.containsKey(userName))
{
for (HeadRequester req : headRequesters.get(userName))
{
elementEntry.getValue().setHead(cache.getPlayerHead());
Bukkit.getServer().getScheduler().runTaskAsynchronously(plugin,
() -> req.setHead(elementEntry.getValue()));
}
}
it.remove();
}
}
}, 0L, 20L);
}
/**
* @param panelItem - head to update
* @param requester - callback class
* This method gets and returns userId from mojang web API based on user name.
* @param name user which Id must be returned.
* @return String value for user Id.
*/
public static void getHead(PanelItem panelItem, HeadRequester requester) {
// Freshen cache
cachedHeads.values().removeIf(c -> System.currentTimeMillis() - c.getTimestamp() > TOO_LONG);
// Check if in cache
if (cachedHeads.containsKey(panelItem.getPlayerHeadName())) {
panelItem.setHead(cachedHeads.get(panelItem.getPlayerHeadName()).getHead().clone());
requester.setHead(panelItem);
} else {
// Get the name
headRequesters.computeIfAbsent(panelItem.getPlayerHeadName(), k -> new HashSet<>()).add(requester);
names.put(panelItem.getPlayerHeadName(), panelItem);
private static UUID getUserIdFromName(String name)
{
UUID userId;
try
{
Gson gsonReader = new Gson();
// Get mojang user-id from given nickname
JsonObject jsonObject = gsonReader.fromJson(
HeadGetter.getURLContent("https://api.mojang.com/users/profiles/minecraft/" + name),
JsonObject.class);
/*
* Returned Json Object:
{
name: USER_NAME,
id: USER_ID
}
*/
// Mojang returns ID without `-`. So it is necessary to insert them back.
// Well technically it is not necessary and can use just a string instead of UUID.
// UUID just looks more fancy :)
String userIdString = jsonObject.get("id").toString().
replace("\"", "").
replaceFirst("([0-9a-fA-F]{8})([0-9a-fA-F]{4})([0-9a-fA-F]{4})([0-9a-fA-F]{4})([0-9a-fA-F]+)",
"$1-$2-$3-$4-$5");
userId = UUID.fromString(userIdString);
}
catch (Exception ignored)
{
// Return random if failed?
userId = UUID.randomUUID();
}
return userId;
}
}
/**
* This method gets and returns base64 encoded link to player skin texture, based on
* given player UUID.
*
* @param userId UUID value for the user.
* @return Encoded player skin texture or null.
*/
private static @Nullable String getTextureFromUUID(UUID userId)
{
try
{
Gson gsonReader = new Gson();
// Get user encoded texture value.
JsonObject jsonObject = gsonReader.fromJson(
HeadGetter.getURLContent("https://sessionserver.mojang.com/session/minecraft/profile/" + userId.toString()),
JsonObject.class);
/*
* Returned Json Object:
{
id: USER_ID,
name: USER_NAME,
properties: [
{
name: "textures",
value: ENCODED_BASE64_TEXTURE
}
]
}
*/
String decodedTexture = "";
for (JsonElement element : jsonObject.getAsJsonArray("properties"))
{
JsonObject object = element.getAsJsonObject();
if (object.has("name") &&
object.get("name").getAsString().equals("textures"))
{
decodedTexture = object.get("value").getAsString();
break;
}
}
return decodedTexture;
}
catch (Exception ignored)
{
}
return null;
}
/**
* This method gets page content of requested url
*
* @param requestedUrl Url which content must be returned.
* @return Content of a page or empty string.
*/
private static String getURLContent(String requestedUrl)
{
String returnValue;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(new URL(requestedUrl).openStream(), StandardCharsets.UTF_8)))
{
returnValue = reader.lines().collect(Collectors.joining());
}
catch (Exception ignored)
{
returnValue = "";
}
return returnValue;
}
}

View File

@ -82,6 +82,11 @@ panel:
# Defines the Material of the item that fills the gaps (in the header, etc.) of most panels.
# Added since 1.14.0.
filler-material: LIGHT_BLUE_STAINED_GLASS_PANE
# Defines how long player skin texture link is stored into local cache before it is requested again.
# Defined value is in the minutes.
# Value 0 will not clear cache until server restart.
# Added since 1.14.1.
head-cache-time: 60
logs:
# Toggle whether superflat chunks regeneration should be logged in the server logs or not.
# It can be spammy if there are a lot of superflat chunks to regenerate.