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
+54
View File
@@ -57,6 +57,7 @@ work fine from console.
| `/blockparty leave` | Leave your current arena |
| `/blockparty arenas` | List arenas (alias of `list`) |
| `/blockparty stats [player]` | View your or another player's stats |
| `/blockparty musicvote` | (Only if the jukebox is enabled) get a one-time code to vote for the next track on the jukebox web page |
## Permissions
@@ -204,6 +205,59 @@ stop/replay-from-the-start rather than a true mid-track pause/resume — vanilla
API has no seek/resume-from-position, so each "resume" restarts the track from the beginning.
It's stopped entirely at match end, arena disable, or plugin shutdown.
### Jukebox (web-based track voting)
Optional: let players vote for an arena's music track from a web page, live, instead of (or as
a live override to) the fixed `music.track`. **Off by default** — enabling it opens a real HTTP
port on your server. Config:
```yaml
jukebox:
enabled: true
bind-address: 127.0.0.1
port: 8642
allowed-origin: "*"
vote-code-minutes: 10
tracks:
- "MUSIC_DISC_PIGSTEP|Pigstep"
- "MUSIC_DISC_CAT|Cat"
# ... id|label pairs; "id" must be a valid Bukkit Sound enum name
```
**How it works:**
1. A player runs `/blockparty musicvote` in-game and gets a short numeric code (valid for
`vote-code-minutes`). This is the only way to get a code — it proves you're a real player on
the server, without needing any separate account/password system on the web page.
2. On the voting page, they enter the code plus their arena and pick a track. Each vote is a
simple HTTP `POST /api/vote?arena=<arena>&code=<code>&track=<id>` — the plugin resolves the
code back to their player UUID, so re-voting just changes their pick rather than adding a
duplicate.
3. The plugin re-checks the current vote leader every time music resumes after a floor-wipe
pause, so the crowd can change the track between rounds within a match, not just before it
starts. Votes for an arena are cleared when it returns to `WAITING`.
4. `GET /api/tracks` lists the configured, valid tracks; `GET /api/arenas` lists each arena's
state, current track, and live vote tally, for the page to render.
**Security model and requirements — read before enabling:**
- This is a **plain, unauthenticated-by-default HTTP API** on your server. `bind-address:
127.0.0.1` (the default) means it's only reachable from the same machine — you must put a
reverse proxy (Caddy, nginx, Cloudflare Tunnel, etc.) with a real TLS certificate in front of
it to expose it to the internet safely. Do **not** set `bind-address` to `0.0.0.0` and forward
the raw port directly without TLS in front of it.
- A web voting page is normally served over HTTPS (e.g. a published page); browsers block an
HTTPS page from calling a plain `http://` address ("mixed content"), so the API being behind
HTTPS isn't optional if you want a hosted page to reach it — it's required.
- The only "auth" is the short-lived numeric code from `/blockparty musicvote`; anyone who
obtains a valid code (e.g. a player shares it) can vote as that player until it expires.
There's no rate-limiting on the HTTP endpoints — treat this as a fun, low-stakes feature, not
a hardened public API.
- `allowed-origin` sets the `Access-Control-Allow-Origin` response header; tighten it from `*`
to your actual voting page's origin if you want to restrict which sites can call it.
- Changes to any `jukebox.*` setting require a full server restart to take effect — the HTTP
server isn't restarted by `/blockparty reload`.
### Reload behavior
`/blockparty reload` re-reads `config.yml`, `messages.yml`, and every arena file. Arenas
@@ -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>"