Add optional web jukebox: vote for arena music tracks via a small HTTP API
Build / build (push) Successful in 1m19s

This commit is contained in:
Michael Burgess
2026-08-07 11:46:43 -04:00
parent a29f359009
commit bdb18f9952
10 changed files with 475 additions and 8 deletions
@@ -10,6 +10,9 @@ import us.tss3.blockparty.arena.ArenaManager;
import us.tss3.blockparty.command.BlockPartyCommand;
import us.tss3.blockparty.config.ConfigManager;
import us.tss3.blockparty.config.MessagesManager;
import us.tss3.blockparty.jukebox.JukeboxServer;
import us.tss3.blockparty.jukebox.VoteCodeManager;
import us.tss3.blockparty.jukebox.VoteManager;
import us.tss3.blockparty.listener.GameplayRestrictionListener;
import us.tss3.blockparty.listener.PlayerConnectionListener;
import us.tss3.blockparty.persistence.StatsManager;
@@ -29,6 +32,9 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener {
private RewardManager rewardManager;
private StatsManager statsManager;
private SoundUtil soundUtil;
private VoteManager voteManager;
private VoteCodeManager voteCodeManager;
private JukeboxServer jukeboxServer;
@Override
public void onEnable() {
@@ -45,9 +51,15 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener {
this.statsManager = new StatsManager(this, configManager.getStorageSettings());
statsManager.init();
this.voteManager = new VoteManager();
this.voteCodeManager = new VoteCodeManager();
this.arenaManager = new ArenaManager(this);
arenaManager.loadAll();
this.jukeboxServer = new JukeboxServer(this, voteManager, voteCodeManager);
jukeboxServer.start();
this.scoreboardManager = new ScoreboardManager(this);
scoreboardManager.start();
@@ -69,6 +81,9 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener {
@Override
public void onDisable() {
if (jukeboxServer != null) {
jukeboxServer.stop();
}
if (arenaManager != null) {
arenaManager.shutdownAll();
}
@@ -144,4 +159,12 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener {
public SoundUtil getSoundUtil() {
return soundUtil;
}
public VoteManager getVoteManager() {
return voteManager;
}
public VoteCodeManager getVoteCodeManager() {
return voteCodeManager;
}
}
@@ -53,6 +53,7 @@ public class Arena {
* the match doesn't end just because "one player remains" — it keeps running rounds until
* that player is actually eliminated or leaves. */
private boolean soloMode;
private String currentMusicTrack;
private Material currentTarget;
private Material previousTarget;
private int currentRoundTime;
@@ -118,6 +119,7 @@ public class Arena {
cancelAllTasks();
billboardManager.despawn();
pauseMusic();
plugin.getVoteManager().clear(config.getName());
// Force reset players out regardless of state
List<UUID> all = new ArrayList<>();
all.addAll(players);
@@ -385,14 +387,23 @@ public class Arena {
}
}
/** Starts the configured background music (if enabled) for every current player/spectator,
* from the beginning of the track — Bukkit has no true "resume from position" API, so
* pause/resume below is implemented as stop/replay-from-start rather than a real seek. */
/** The track currently playing (or last played) for this arena's match, or null if music
* is disabled/hasn't started yet. Exposed for the optional jukebox web API. */
public String getCurrentMusicTrack() {
return currentMusicTrack;
}
/** Starts the configured (or currently vote-winning, if the jukebox has votes) background
* music for every current player/spectator, from the beginning of the track — Bukkit has
* no true "resume from position" API, so pause/resume below is implemented as
* stop/replay-from-start rather than a real seek. Re-checking the vote on every resume
* means the crowd can democratically change the track between rounds mid-match. */
private void startMusic() {
if (!plugin.getConfigManager().isMusicEnabled()) {
return;
}
String track = plugin.getConfigManager().getMusicTrack();
String voted = plugin.getVoteManager().winningTrack(config.getName());
String track = voted != null ? voted : plugin.getConfigManager().getMusicTrack();
float volume = plugin.getConfigManager().getMusicVolume();
for (UUID uuid : allParticipants()) {
Player p = plugin.getServer().getPlayer(uuid);
@@ -400,17 +411,17 @@ public class Arena {
plugin.getSoundUtil().playMusic(p, track, volume);
}
}
currentMusicTrack = track;
}
private void pauseMusic() {
if (!plugin.getConfigManager().isMusicEnabled()) {
if (!plugin.getConfigManager().isMusicEnabled() || currentMusicTrack == null) {
return;
}
String track = plugin.getConfigManager().getMusicTrack();
for (UUID uuid : allParticipants()) {
Player p = plugin.getServer().getPlayer(uuid);
if (p != null) {
plugin.getSoundUtil().stopMusic(p, track);
plugin.getSoundUtil().stopMusic(p, currentMusicTrack);
}
}
}
@@ -572,6 +583,8 @@ public class Arena {
floorManager.cancelActiveTask();
cancelAllTasks();
round = 0;
currentMusicTrack = null;
plugin.getVoteManager().clear(config.getName());
billboardManager.clear();
stateMachine.transition(ArenaPhase.WAITING);
}
@@ -60,6 +60,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
case "leave" -> leave(sender);
case "arenas" -> list(sender);
case "stats" -> stats(sender, args);
case "musicvote" -> musicVote(sender);
default -> sendHelp(sender);
}
return true;
@@ -72,6 +73,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
"/blockparty leave",
"/blockparty arenas",
"/blockparty stats [player]",
"/blockparty musicvote",
"/blockparty create <arena>",
"/blockparty delete <arena>",
"/blockparty enable <arena>",
@@ -442,6 +444,19 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(plugin.getMessages().get("player.left"));
}
private void musicVote(CommandSender sender) {
if (!requirePlayer(sender)) return;
if (!plugin.getConfigManager().isJukeboxEnabled()) {
sender.sendMessage(plugin.getMessages().get("errors.jukebox-disabled"));
return;
}
Player player = (Player) sender;
String code = plugin.getVoteCodeManager().issueCode(player.getUniqueId(),
plugin.getConfigManager().getJukeboxVoteCodeMinutes());
sender.sendMessage(plugin.getMessages().get("player.musicvote-code",
Map.of("code", code, "minutes", String.valueOf(plugin.getConfigManager().getJukeboxVoteCodeMinutes()))));
}
private void stats(CommandSender sender, String[] args) {
UUID target;
String targetName;
@@ -485,7 +500,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (args.length == 1) {
return List.of("help", "join", "leave", "arenas", "stats", "create", "delete", "enable", "disable",
return List.of("help", "join", "leave", "arenas", "stats", "musicvote", "create", "delete", "enable", "disable",
"setlobby", "setspawn", "setspectator", "setbillboard", "delbillboard", "billboards", "pos1", "pos2", "setfloor", "generate", "info", "list", "reload")
.stream().filter(s -> s.startsWith(args[0].toLowerCase())).collect(Collectors.toList());
}
@@ -109,6 +109,30 @@ public class ConfigManager {
return (float) config.getDouble("music.volume", 1.0);
}
public boolean isJukeboxEnabled() {
return config.getBoolean("jukebox.enabled", false);
}
public String getJukeboxBindAddress() {
return config.getString("jukebox.bind-address", "127.0.0.1");
}
public int getJukeboxPort() {
return config.getInt("jukebox.port", 8642);
}
public String getJukeboxAllowedOrigin() {
return config.getString("jukebox.allowed-origin", "*");
}
public int getJukeboxVoteCodeMinutes() {
return config.getInt("jukebox.vote-code-minutes", 10);
}
public List<String> getJukeboxTracks() {
return config.getStringList("jukebox.tracks");
}
public boolean isPlaceholderApiEnabled() {
return config.getBoolean("integrations.placeholderapi", true);
}
@@ -0,0 +1,216 @@
package us.tss3.blockparty.jukebox;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.bukkit.Sound;
import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.arena.Arena;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.logging.Level;
/**
* A minimal, dependency-free HTTP API (built on the JDK's own {@code com.sun.net.httpserver})
* that lets an external web page read an arena's available/current music track and cast
* votes for the next one. Off by default (see {@code jukebox.enabled} in config.yml) since
* it opens a real network port; see the README's "Jukebox" section for the security model
* and why this needs to sit behind an HTTPS reverse proxy to be reachable from a page served
* over HTTPS.
*/
public class JukeboxServer {
private final BlockPartyPlugin plugin;
private final VoteManager voteManager;
private final VoteCodeManager voteCodeManager;
private HttpServer server;
public JukeboxServer(BlockPartyPlugin plugin, VoteManager voteManager, VoteCodeManager voteCodeManager) {
this.plugin = plugin;
this.voteManager = voteManager;
this.voteCodeManager = voteCodeManager;
}
public void start() {
if (!plugin.getConfigManager().isJukeboxEnabled()) {
return;
}
String bind = plugin.getConfigManager().getJukeboxBindAddress();
int port = plugin.getConfigManager().getJukeboxPort();
try {
server = HttpServer.create(new InetSocketAddress(bind, port), 0);
server.createContext("/api/health", this::handleHealth);
server.createContext("/api/tracks", this::handleTracks);
server.createContext("/api/arenas", this::handleArenas);
server.createContext("/api/vote", this::handleVote);
server.setExecutor(Executors.newFixedThreadPool(2));
server.start();
plugin.getLogger().info("Jukebox HTTP API listening on " + bind + ":" + port
+ " - remember this needs an HTTPS reverse proxy in front of it to be reachable from a browser page.");
} catch (IOException ex) {
plugin.getLogger().log(Level.SEVERE, "Failed to start jukebox HTTP server on "
+ bind + ":" + port, ex);
}
}
public void stop() {
if (server != null) {
server.stop(0);
server = null;
}
}
// ---------- handlers ----------
private void handleHealth(HttpExchange exchange) throws IOException {
if (preflight(exchange)) return;
writeJson(exchange, 200, "{\"ok\":true}");
}
private void handleTracks(HttpExchange exchange) throws IOException {
if (preflight(exchange)) return;
List<String> configured = plugin.getConfigManager().getJukeboxTracks();
StringBuilder json = new StringBuilder("[");
boolean first = true;
for (String entry : configured) {
String[] parts = entry.split("\\|", 2);
String id = parts[0].trim();
String label = parts.length > 1 ? parts[1].trim() : id;
if (!isValidSound(id)) {
continue;
}
if (!first) json.append(',');
first = false;
json.append("{\"id\":\"").append(jsonEscape(id)).append("\",\"label\":\"").append(jsonEscape(label)).append("\"}");
}
json.append(']');
writeJson(exchange, 200, json.toString());
}
private void handleArenas(HttpExchange exchange) throws IOException {
if (preflight(exchange)) return;
StringBuilder json = new StringBuilder("[");
boolean first = true;
for (Arena arena : plugin.getArenaManager().all()) {
String name = arena.getConfig().getName();
if (!first) json.append(',');
first = false;
Map<String, Integer> tally = voteManager.tally(name);
StringBuilder votesJson = new StringBuilder("{");
boolean firstVote = true;
for (Map.Entry<String, Integer> e : tally.entrySet()) {
if (!firstVote) votesJson.append(',');
firstVote = false;
votesJson.append('"').append(jsonEscape(e.getKey())).append("\":").append(e.getValue());
}
votesJson.append('}');
String currentTrack = arena.getCurrentMusicTrack();
json.append("{\"name\":\"").append(jsonEscape(name))
.append("\",\"state\":\"").append(arena.getPhase())
.append("\",\"currentTrack\":").append(currentTrack == null ? "null" : "\"" + jsonEscape(currentTrack) + "\"")
.append(",\"votes\":").append(votesJson)
.append('}');
}
json.append(']');
writeJson(exchange, 200, json.toString());
}
private void handleVote(HttpExchange exchange) throws IOException {
if (preflight(exchange)) return;
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
writeJson(exchange, 405, "{\"error\":\"POST required\"}");
return;
}
Map<String, String> params = queryParams(exchange.getRequestURI());
String arena = params.get("arena");
String code = params.get("code");
String track = params.get("track");
if (arena == null || code == null || track == null) {
writeJson(exchange, 400, "{\"error\":\"arena, code and track are all required\"}");
return;
}
if (plugin.getArenaManager().get(arena).isEmpty()) {
writeJson(exchange, 404, "{\"error\":\"unknown arena\"}");
return;
}
if (!isValidSound(track)) {
writeJson(exchange, 400, "{\"error\":\"unknown track\"}");
return;
}
UUID playerId = voteCodeManager.resolve(code);
if (playerId == null) {
writeJson(exchange, 401, "{\"error\":\"invalid or expired code - run /blockparty musicvote in-game for a new one\"}");
return;
}
voteManager.castVote(arena, playerId, track);
StringBuilder json = new StringBuilder("{\"ok\":true,\"votes\":{");
boolean first = true;
for (Map.Entry<String, Integer> e : voteManager.tally(arena).entrySet()) {
if (!first) json.append(',');
first = false;
json.append('"').append(jsonEscape(e.getKey())).append("\":").append(e.getValue());
}
json.append("}}");
writeJson(exchange, 200, json.toString());
}
// ---------- helpers ----------
private boolean isValidSound(String name) {
try {
Sound.valueOf(name);
return true;
} catch (IllegalArgumentException ex) {
return false;
}
}
private boolean preflight(HttpExchange exchange) throws IOException {
exchange.getResponseHeaders().add("Access-Control-Allow-Origin", plugin.getConfigManager().getJukeboxAllowedOrigin());
exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type");
if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(204, -1);
return true;
}
return false;
}
private void writeJson(HttpExchange exchange, int status, String json) throws IOException {
byte[] body = json.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(status, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
}
private Map<String, String> queryParams(URI uri) {
Map<String, String> params = new java.util.HashMap<>();
String query = uri.getRawQuery();
if (query == null || query.isBlank()) {
return params;
}
for (String pair : query.split("&")) {
int eq = pair.indexOf('=');
if (eq < 0) {
continue;
}
String key = java.net.URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8);
String value = java.net.URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
params.put(key, value);
}
return params;
}
private String jsonEscape(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
@@ -0,0 +1,48 @@
package us.tss3.blockparty.jukebox;
import java.security.SecureRandom;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Issues short-lived, easy-to-type numeric codes that link a web voting session back to a
* specific logged-in player, without requiring any real account/password auth on the web
* page. A code is only ever handed out via the in-game {@code /blockparty musicvote}
* command, so possessing one proves you're the player who ran it (or someone they showed it
* to) for as long as it's valid.
*/
public class VoteCodeManager {
private record CodeEntry(UUID playerId, long expiresAtMs) {
}
private final Map<String, CodeEntry> codes = new ConcurrentHashMap<>();
private final SecureRandom random = new SecureRandom();
public String issueCode(UUID playerId, int ttlMinutes) {
codes.values().removeIf(e -> e.playerId().equals(playerId));
String code;
do {
code = String.valueOf(100000 + random.nextInt(900000));
} while (codes.containsKey(code));
codes.put(code, new CodeEntry(playerId, System.currentTimeMillis() + ttlMinutes * 60_000L));
return code;
}
/** Resolves a code to the player who issued it, or null if unknown/expired. */
public UUID resolve(String code) {
if (code == null) {
return null;
}
CodeEntry entry = codes.get(code);
if (entry == null) {
return null;
}
if (System.currentTimeMillis() > entry.expiresAtMs()) {
codes.remove(code);
return null;
}
return entry.playerId();
}
}
@@ -0,0 +1,43 @@
package us.tss3.blockparty.jukebox;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Tracks per-arena, per-player music track votes in memory (one vote per player per arena,
* re-voting replaces their previous pick). Never persisted - votes are a live, ephemeral
* "what should play next" signal, reset whenever an arena returns to WAITING.
*/
public class VoteManager {
/** arena name -> (player uuid -> track id) */
private final Map<String, Map<UUID, String>> votes = new ConcurrentHashMap<>();
public void castVote(String arena, UUID playerId, String track) {
votes.computeIfAbsent(arena, k -> new ConcurrentHashMap<>()).put(playerId, track);
}
/** track id -> vote count, insertion order not meaningful. */
public Map<String, Integer> tally(String arena) {
Map<UUID, String> arenaVotes = votes.getOrDefault(arena, Map.of());
Map<String, Integer> counts = new LinkedHashMap<>();
for (String track : arenaVotes.values()) {
counts.merge(track, 1, Integer::sum);
}
return counts;
}
/** The current leading track for an arena, or null if nobody has voted. */
public String winningTrack(String arena) {
return tally(arena).entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse(null);
}
public void clear(String arena) {
votes.remove(arena);
}
}
+29
View File
@@ -30,6 +30,35 @@ music:
track: MUSIC_DISC_PIGSTEP
volume: 1.0
jukebox:
# Lets players vote for an arena's music track from a web page, live. OFF by default:
# enabling this opens a plain HTTP port on this machine. You are responsible for
# firewalling it and putting it behind an HTTPS reverse proxy (Caddy/nginx/Cloudflare
# Tunnel/etc.) - a browser page served over HTTPS (e.g. a published Claude Artifact)
# cannot call a plain http:// address due to mixed-content blocking, and exposing raw
# HTTP directly to the internet is not recommended. See README "Jukebox" for setup.
enabled: false
bind-address: 127.0.0.1
port: 8642
# CORS origin allowed to call the API. "*" allows any page; tighten this to your
# actual voting page's origin if you want to restrict it.
allowed-origin: "*"
# How long a /blockparty musicvote code stays valid, in minutes.
vote-code-minutes: 10
# id|label pairs offered to voters. "id" must be a valid Bukkit Sound enum name.
tracks:
- "MUSIC_DISC_PIGSTEP|Pigstep"
- "MUSIC_DISC_CAT|Cat"
- "MUSIC_DISC_BLOCKS|Blocks"
- "MUSIC_DISC_CHIRP|Chirp"
- "MUSIC_DISC_FAR|Far"
- "MUSIC_DISC_MELLOHI|Mellohi"
- "MUSIC_DISC_STAL|Stal"
- "MUSIC_DISC_STRAD|Strad"
- "MUSIC_DISC_WARD|Ward"
- "MUSIC_DISC_11|13"
- "MUSIC_DISC_WAIT|Wait"
performance:
# blocks processed per server tick when generating/removing/restoring the floor
floor-blocks-per-tick: 200
+2
View File
@@ -9,6 +9,7 @@ errors:
arena-full: "<red>Arena '%arena%' is full.</red>"
no-region: "<red>Arena '%arena%' has no floor region set (use pos1/pos2).</red>"
no-materials: "<red>Arena '%arena%' has no floor materials configured.</red>"
jukebox-disabled: "<red>The music jukebox is not enabled on this server.</red>"
admin:
arena-exists: "<red>Arena '%arena%' already exists.</red>"
@@ -34,6 +35,7 @@ admin:
player:
joined: "<green>You joined arena '%arena%'.</green>"
left: "<yellow>You left the arena.</yellow>"
musicvote-code: "<green>Your music vote code: <bold>%code%</bold></green> <gray>(valid %minutes% minutes) - enter it on the jukebox voting page to pick the next track.</gray>"
countdown:
actionbar: "<yellow>Starting in %time%s...</yellow>"