#!/usr/bin/env python3 """ VoxelRank player count for a Palworld dedicated server. Palworld has no plugin system on a dedicated server, so this small program runs next to it. Every minute it asks the server's own REST API how many players are online and sends that number to VoxelRank, which shows it on your listing instead of "Unknown". Needs Python 3.8+ and nothing else (standard library only). Set up 1. In PalWorldSettings.ini turn the REST API on and set an admin password: RESTAPIEnabled=True, RESTAPIPort=8212, AdminPassword="something-long" Keep port 8212 closed to the internet. This program talks to it from the same machine (127.0.0.1). 2. Copy your API key from your listing's edit page on VoxelRank. 3. Run it once to create the config file, fill it in, run it again: python3 voxelrank_palworld.py 4. Keep it running (systemd service, screen/tmux, or a Windows scheduled task that starts at boot). It reads the config again every cycle, so a change takes effect within a minute without a restart. Run with --once to send a single report and exit (handy for testing). """ import base64 import json import os import sys import time import urllib.error import urllib.request VERSION = "1.0.0" CONFIG_FILE = os.environ.get("VOXELRANK_CONFIG") or os.path.join( os.path.dirname(os.path.abspath(__file__)), "voxelrank_palworld.json") DEFAULTS = { "api_key": "your-api-key", "site_url": "https://voxelrank.com", "rest_url": "http://127.0.0.1:8212", "rest_user": "admin", "rest_password": "your-AdminPassword", # Used only when the REST API does not report the slot limit. "max_players_fallback": 32, "interval_seconds": 60, } def log(msg): print(time.strftime("%Y-%m-%d %H:%M:%S"), msg, flush=True) def load_config(): """Read the config file, writing a fresh one with the defaults if missing.""" if not os.path.exists(CONFIG_FILE): with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(DEFAULTS, f, indent=2) log("Wrote %s. Fill it in, then run this again." % CONFIG_FILE) return None with open(CONFIG_FILE, encoding="utf-8") as f: cfg = dict(DEFAULTS) cfg.update(json.load(f)) return cfg def get_json(url, headers, timeout=8): req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=timeout) as res: return json.loads(res.read().decode("utf-8")) def palworld_counts(cfg): """(players, max_players) from the game server's REST API.""" token = base64.b64encode( ("%s:%s" % (cfg["rest_user"], cfg["rest_password"])).encode("utf-8")).decode("ascii") headers = {"Authorization": "Basic " + token, "Accept": "application/json"} base = cfg["rest_url"].rstrip("/") metrics = get_json(base + "/v1/api/metrics", headers) players = int(metrics["currentplayernum"]) max_players = int(metrics.get("maxplayernum") or 0) if max_players < 1: try: max_players = int(get_json(base + "/v1/api/settings", headers)["ServerPlayerMaxNum"]) except (urllib.error.URLError, KeyError, ValueError, OSError): max_players = int(cfg["max_players_fallback"]) return players, max(max_players, 1) def send_heartbeat(cfg, players, max_players): body = json.dumps({"players": players, "max_players": max_players}).encode("utf-8") req = urllib.request.Request( cfg["site_url"].rstrip("/") + "/api/v1/heartbeat", data=body, method="POST", headers={ "Content-Type": "application/json", "X-Api-Key": cfg["api_key"], "User-Agent": "VoxelRank-Palworld/" + VERSION, }) with urllib.request.urlopen(req, timeout=10) as res: return json.loads(res.read().decode("utf-8")) def cycle(cfg): """One report. Returns True when VoxelRank accepted it.""" try: players, max_players = palworld_counts(cfg) except urllib.error.HTTPError as e: hint = " (wrong rest_user/rest_password?)" if e.code == 401 else "" log("Palworld REST API answered HTTP %d%s" % (e.code, hint)) return False except (urllib.error.URLError, OSError, KeyError, ValueError) as e: log("Could not read the Palworld REST API at %s: %s. Is RESTAPIEnabled=True?" % (cfg["rest_url"], e)) return False try: send_heartbeat(cfg, players, max_players) except urllib.error.HTTPError as e: try: detail = json.loads(e.read().decode("utf-8")).get("error", "") except Exception: detail = "" log("VoxelRank refused the report: HTTP %d %s%s" % ( e.code, detail, " (check api_key)" if e.code == 401 else "")) return False except (urllib.error.URLError, OSError, ValueError) as e: log("Could not reach VoxelRank: %s" % e) return False log("Sent %d/%d players" % (players, max_players)) return True def main(): once = "--once" in sys.argv[1:] while True: cfg = load_config() if cfg is None: return 1 if cfg["api_key"] in ("", DEFAULTS["api_key"]): log("Set api_key in %s first." % CONFIG_FILE) return 1 ok = cycle(cfg) if once: return 0 if ok else 1 time.sleep(max(30, int(cfg["interval_seconds"]))) if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: sys.exit(0)