commit 317c29879c4528ba3eea0530e7ad596ce539a1d4 Author: ZareMate <0.zaremate@gmail.com> Date: Mon Mar 23 19:37:38 2026 +0100 Add initial implementation of Stats Tracker with environment configuration and player stats tracking - Create .env.example for environment variable setup - Add .gitignore to exclude sensitive files and cache - Implement README.md with setup instructions and usage details - Develop main.py for fetching and posting player stats to Discord - Create overlay.py for displaying stats in a GUI overlay - Add players.json to define players to track - Specify dependencies in requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..63062ff --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DISCORD_WEBHOOK_URL=https://discordapp.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN +API_KEY=your_api_key_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb474e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +__pycache__/ +*.pyc +.venv/ +venv/ +message_id.txt +stats_cache.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..d610b6a --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Stats Tracker šŸ“Š + +A Python script that tracks Minecraft server player stats and posts them to Discord via webhook. + +## Setup + +### 1. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 2. Configure Environment Variables + +Copy the example file and fill in your credentials: + +```bash +cp .env.example .env +``` + +Edit `.env` and add: + +- `DISCORD_WEBHOOK_URL`: Your Discord webhook URL +- `API_KEY`: Your DonutSMP API key (get it with `/api` in-game) + +### 3. Add Players to Track + +Edit `players.json` and add the usernames you want to track: + +```json +{ + "players": ["player1", "player2", "player3"] +} +``` + +## Running + +```bash +python main.py +``` + +The script will: + +- āœ… Fetch stats every 2 minutes +- āœ… Create a new Discord message on first run +- āœ… Edit the same message on subsequent updates (no spam!) +- āœ… **Only update the message if stats actually change** (saves API calls) +- āœ… Display stats grouped by type across all players +- āœ… Show: Money, Playtime, Kills, Deaths, Mobs Killed, Blocks Placed/Broken, Shards + +## How It Works + +1. **First Run**: Posts a new message with all player stats grouped by type +2. **Subsequent Runs**: + - Compares current stats with cached stats + - Only edits the Discord message if something changed + - Skips updates if stats are unchanged (saves Discord API calls) +3. **No Message Found**: If `message_id.txt` is deleted or the Discord message is removed, it will create a new one +4. **Error Handling**: If a player doesn't exist or API fails, it shows "āŒ" for that player + +## Files Created + +- `message_id.txt` - Stores the Discord message ID for edits +- `stats_cache.json` - Caches previous stats to detect changes + +## Notes + +- The message ID is automatically saved to prevent creating duplicate messages +- Stats are cached to avoid unnecessary Discord updates +- If you want to reset and create a new message, delete `message_id.txt` and `stats_cache.json` +- The script handles network errors gracefully and will retry every 2 minutes +- Press `Ctrl+C` to stop the script diff --git a/main.py b/main.py new file mode 100644 index 0000000..d77ffa1 --- /dev/null +++ b/main.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +import json +import os +import time +import requests +from dotenv import load_dotenv +from datetime import datetime, timezone, timedelta + +# Load environment variables +load_dotenv() + +WEBHOOK_URL = os.getenv('DISCORD_WEBHOOK_URL') +API_KEY = os.getenv('API_KEY') +API_BASE_URL = 'https://api.donutsmp.net/v1/stats' +MESSAGE_ID_FILE = 'message_id.txt' +CACHE_FILE = 'stats_cache.json' +REFRESH_INTERVAL = 60 # 1 minute in seconds + +def sleep_until_next_interval(): + """Sleep until the next even 1-minute mark on the clock (e.g. :00, :01, :02...)""" + now = datetime.now() + # Seconds elapsed in the current 1-minute block + elapsed = (now.minute % 1) * 60 + now.second + wait = REFRESH_INTERVAL - elapsed + next_tick = now + timedelta(seconds=wait) + print(f"šŸ’¤ Sleeping {wait}s until next update at {next_tick.strftime('%H:%M:%S')}...") + time.sleep(wait) + +def load_players(): + """Load player list from JSON file""" + with open('players.json', 'r') as f: + data = json.load(f) + return data.get('players', []) + +def fetch_player_stats(username): + """Fetch stats for a single player from the API""" + try: + url = f'{API_BASE_URL}/{username}' + headers = {'Authorization': API_KEY} if API_KEY else {} + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + data = response.json() + return data.get('result', None) + elif response.status_code == 500: + return None # Player doesn't exist + else: + print(f"Error fetching stats for {username}: {response.status_code}") + return None + except Exception as e: + print(f"Exception fetching stats for {username}: {e}") + return None + +def format_number(value): + """Format a number with K/M/B suffixes for readability""" + if value == 'N/A' or value == 'āŒ': + return value + + try: + # Try to convert to int first + num = int(value) + + if num >= 1_000_000_000: + return f"{num / 1_000_000_000:.2f} B" + elif num >= 1_000_000: + return f"{num / 1_000_000:.2f} M" + elif num >= 1_000: + return f"{num / 1_000:.2f} K" + else: + return str(num) + except (ValueError, TypeError): + # If it's not a plain number (like playtime with format), return as-is + return str(value) + +def format_playtime(milliseconds): + """Format playtime from milliseconds to readable format (XdYhZm)""" + if milliseconds == 'N/A' or milliseconds == 'āŒ': + return milliseconds + + try: + # Convert milliseconds to seconds + total_seconds = int(milliseconds) // 1000 + + days = total_seconds // 86400 + hours = (total_seconds % 86400) // 3600 + minutes = (total_seconds % 3600) // 60 + + parts = [] + if days > 0: + parts.append(f"{days}d") + if hours > 0: + parts.append(f"{hours}h") + if minutes > 0: + parts.append(f"{minutes}m") + + return " ".join(parts) if parts else "0m" + except (ValueError, TypeError): + return str(milliseconds) + +def format_stats_message(players_data): + """Format player stats grouped by stat type into a Discord embed message""" + embed = { + "title": "šŸ“Š Server Stats Tracker", + "description": "Real-time player statistics", + "color": 3447003, + "timestamp": datetime.now(timezone.utc).isoformat(), + "fields": [] + } + + # Define stat groups with icons + stat_groups = { + "šŸ’° Money": "money", + "ā±ļø Playtime": "playtime", + "āš”ļø Kills": "kills", + "ā˜ ļø Deaths": "deaths", + "🧟 Mobs Killed": "mobs_killed", + "🪨 Blocks Placed": "placed_blocks", + "ā›ļø Blocks Broken": "broken_blocks", + "✨ Shards": "shards", + } + + # Stats that should show a total + summable_stats = {"money"} + + # Group stats by type + for group_name, stat_key in stat_groups.items(): + field_value = "" + total = 0 + can_sum = stat_key in summable_stats + + for username, stats in players_data.items(): + if stats: + value = stats.get(stat_key, 'N/A') + # Use special formatting for playtime + if stat_key == 'playtime': + formatted_value = format_playtime(value) + else: + formatted_value = format_number(value) + else: + formatted_value = "āŒ" + value = 'N/A' + + field_value += f"{username}: `{formatted_value}`\n" + + # Sum numeric values if this stat is summable + if can_sum and value != 'N/A' and value != 'āŒ': + try: + total += int(value) + except (ValueError, TypeError): + can_sum = False # Stop summing if we hit a non-numeric value + + # Add total if this stat is summable + if can_sum and total > 0: + field_value += f"\n**Total**: `{format_number(total)}`" + + embed['fields'].append({ + "name": group_name, + "value": field_value.rstrip(), + "inline": True + }) + + return {"embeds": [embed]} + +def get_saved_message_id(): + """Retrieve the saved Discord message ID""" + if os.path.exists(MESSAGE_ID_FILE): + try: + with open(MESSAGE_ID_FILE, 'r') as f: + return f.read().strip() + except: + return None + return None + + +def get_cached_stats(): + """Retrieve the cached stats from last update""" + if os.path.exists(CACHE_FILE): + try: + with open(CACHE_FILE, 'r') as f: + return json.load(f) + except: + return None + return None + +def save_cached_stats(players_data): + """Save the current stats to cache file""" + with open(CACHE_FILE, 'w') as f: + json.dump(players_data, f) + +def stats_changed(old_data, new_data): + """Check if stats have changed between updates""" + if old_data is None: + print(" ā„¹ļø First run - no cached stats") + return True # First run + + # Compare all player stats + if set(old_data.keys()) != set(new_data.keys()): + print(" ā„¹ļø Players list changed") + return True # Different players + + for player in new_data: + old_stats = old_data.get(player) + new_stats = new_data.get(player) + + # If either is None, check if both changed + if (old_stats is None) != (new_stats is None): + print(f" ā„¹ļø {player}: stats availability changed") + return True + + # If both exist, compare them + if old_stats is not None and new_stats is not None: + if old_stats != new_stats: + print(f" ā„¹ļø {player}: stats changed") + return True + + return False +def save_message_id(message_id): + """Save the Discord message ID for future edits""" + with open(MESSAGE_ID_FILE, 'w') as f: + f.write(str(message_id)) + +def post_message(content): + """Post a new message to Discord webhook""" + try: + # Use wait=true to get the message ID in response + url = f"{WEBHOOK_URL}?wait=true" + response = requests.post(url, json=content, timeout=10) + if response.status_code in [200, 204]: + # Discord returns the message data in JSON + try: + message_data = response.json() + message_id = message_data.get('id') + if message_id: + save_message_id(message_id) + print(f"āœ… Posted new message: {message_id}") + else: + print("āœ… Posted new message (no ID returned)") + return True + except Exception as e: + print(f"āœ… Posted new message (couldn't parse ID: {e})") + return True + else: + print(f"āŒ Error posting message: {response.status_code}") + print(response.text) + return False + except Exception as e: + print(f"āŒ Exception posting message: {e}") + return False + +def edit_message(message_id, content): + """Edit an existing Discord message""" + try: + # Extract webhook parts + webhook_parts = WEBHOOK_URL.rstrip('/').split('/') + if len(webhook_parts) < 2: + print("āŒ Invalid webhook URL format") + return False + + webhook_id = webhook_parts[-2] + webhook_token = webhook_parts[-1] + + edit_url = f"https://discordapp.com/api/webhooks/{webhook_id}/{webhook_token}/messages/{message_id}" + response = requests.patch(edit_url, json=content, timeout=10) + + if response.status_code == 200: + print(f"āœ… Updated message: {message_id}") + return True + else: + print(f"āŒ Error editing message: {response.status_code}") + print(response.text) + return False + except Exception as e: + print(f"āŒ Exception editing message: {e}") + return False + +def main(): + """Main loop to fetch and update stats""" + if not WEBHOOK_URL: + print("āŒ Error: DISCORD_WEBHOOK_URL not set in .env file") + return + + print("šŸš€ Starting stats tracker...") + print(f"šŸ“Ø Webhook: {WEBHOOK_URL[:50]}...") + print(f"ā±ļø Refresh interval: {REFRESH_INTERVAL} seconds") + + # Load players + players = load_players() + if not players: + print("āŒ No players found in players.json") + return + + print(f"šŸ‘„ Tracking {len(players)} players: {', '.join(players)}") + print("-" * 50) + + while True: + try: + # Fetch stats for all players + print(f"\nā° [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Fetching stats...") + players_data = {} + + for player in players: + stats = fetch_player_stats(player) + players_data[player] = stats + if stats: + print(f" āœ… {player}") + else: + print(f" āš ļø {player}") + + # Check if stats changed + cached_stats = get_cached_stats() + has_changed = stats_changed(cached_stats, players_data) + + if not has_changed: + print("šŸ“ No changes detected, skipping update") + sleep_until_next_interval() + continue + + # Format message + message_content = format_stats_message(players_data) + + # Check if we have a previous message ID + message_id = get_saved_message_id() + + if message_id: + # Edit existing message + print(f"šŸ“ Editing existing message: {message_id}") + edit_message(message_id, message_content) + else: + # Post new message + print("šŸ“® No message ID found, posting new message") + post_message(message_content) + + # Save stats for next comparison + save_cached_stats(players_data) + print("šŸ’¾ Cached stats saved") + + # Wait for next update + sleep_until_next_interval() + + except KeyboardInterrupt: + print("\n\nšŸ‘‹ Shutting down stats tracker...") + break + except Exception as e: + print(f"āŒ Unexpected error: {e}") + print(f"šŸ’¤ Retrying in {REFRESH_INTERVAL}s...") + time.sleep(REFRESH_INTERVAL) + +if __name__ == '__main__': + main() diff --git a/overlay.py b/overlay.py new file mode 100644 index 0000000..5c2b80e --- /dev/null +++ b/overlay.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +import json +import os +import re +import subprocess +import tkinter as tk +from tkinter import font as tkfont + +CACHE_FILE = 'stats_cache.json' +REFRESH_MS = 3000 # Re-read cache every 3 seconds +BG_COLOR = "#232323" +WINDOW_CHECK_MS = 80 +MINECRAFT_PATTERNS = ('minecraft', 'lunar client', 'badlion', 'fabric', 'forge') + +# ── formatting helpers (mirrors main.py, no imports needed) ────────────────── + +def fmt_money(value): + if not value or value in ('N/A', 'āŒ'): + return value or 'N/A' + try: + num = int(value) + if num >= 1_000_000_000: + return f"{num / 1_000_000_000:.2f} B" + elif num >= 1_000_000: + return f"{num / 1_000_000:.2f} M" + elif num >= 1_000: + return f"{num / 1_000:.2f} K" + return str(num) + except (ValueError, TypeError): + return str(value) + + +def _run_cmd(command): + try: + result = subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=0.10, + check=False, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + except Exception: + return None + + +def get_active_window_info(): + """Return active window id and title via xdotool, or (None, None).""" + win_id = _run_cmd(['xdotool', 'getactivewindow']) + if not win_id: + return None, None + title = _run_cmd(['xdotool', 'getwindowname', win_id]) or '' + return win_id, title + + +def is_minecraft_title(title): + title_l = (title or '').lower() + return any(pattern in title_l for pattern in MINECRAFT_PATTERNS) + + +def get_window_geometry(win_id): + """Return dict with x,y,width,height for a window id, or None.""" + out = _run_cmd(['xwininfo', '-id', str(win_id)]) + if not out: + return None + + def find_int(pattern): + match = re.search(pattern, out) + return int(match.group(1)) if match else None + + x = find_int(r'Absolute upper-left X:\s+(-?\d+)') + y = find_int(r'Absolute upper-left Y:\s+(-?\d+)') + width = find_int(r'Width:\s+(\d+)') + height = find_int(r'Height:\s+(\d+)') + + if None in (x, y, width, height): + return None + return {'x': x, 'y': y, 'width': width, 'height': height} + +# ── overlay ────────────────────────────────────────────────────────────────── + +class Overlay(tk.Tk): + def __init__(self): + super().__init__() + + # Window chrome + self.overrideredirect(True) # no title bar + self.attributes('-topmost', True) + self.configure(bg=BG_COLOR) + + # Fonts + self.name_font = tkfont.Font(family='Consolas', size=9) + self.money_font = tkfont.Font(family='Consolas', size=10, weight='bold') + + # Player rows container + self.rows_frame = tk.Frame(self, bg=BG_COLOR) + self.rows_frame.pack(fill='both', padx=6, pady=4) + + self.total_label = tk.Label( + self, + text='Total: 0', + bg=BG_COLOR, + fg='#f0c040', + font=self.money_font, + anchor='w' + ) + self.total_label.pack(fill='x', padx=6, pady=(0, 4)) + + # Initial position (top-right corner area) + self.update_idletasks() + self.geometry('+0+100') + + # Player label widgets: {username: (name_lbl, money_lbl)} + self._labels: dict[str, tuple[tk.Label, tk.Label]] = {} + self._visible_for_minecraft = True + + self._sync_window_visibility() + self._refresh() + + # ── data ──────────────────────────────────────────────────────────────── + + def _load_cache(self): + if not os.path.exists(CACHE_FILE): + return None + try: + with open(CACHE_FILE, 'r') as f: + return json.load(f) + except Exception: + return None + + def _sync_window_visibility(self): + """Show overlay only while Minecraft window is focused and anchor to it.""" + win_id, title = get_active_window_info() + + if win_id and is_minecraft_title(title): + geo = get_window_geometry(win_id) + if geo: + # Top-left corner of Minecraft with a small inset. + self.update_idletasks() + x = geo['x'] + y = geo['y'] + 90 + self.geometry(f'+{max(0, x)}+{max(0, y)}') + + if not self._visible_for_minecraft: + self.deiconify() + self._visible_for_minecraft = True + else: + if self._visible_for_minecraft: + self.withdraw() + self._visible_for_minecraft = False + + self.after(WINDOW_CHECK_MS, self._sync_window_visibility) + + def _refresh(self): + data = self._load_cache() + + if data is None: + self.total_label.configure(text='Total: N/A') + self.after(REFRESH_MS, self._refresh) + return + + # Create or update a row per player + players = list(data.keys()) + total_money = 0 + for player in players: + stats = data.get(player) or {} + raw = stats.get('money', 'N/A') + money = fmt_money(raw) if raw else 'N/A' + color = '#2ecc71' if stats else '#e74c3c' + + if stats: + try: + total_money += int(raw) + except (ValueError, TypeError): + pass + + if player not in self._labels: + row = tk.Frame(self.rows_frame, bg=BG_COLOR) + row.pack(fill='x', pady=1) + name_lbl = tk.Label(row, text=player, bg=BG_COLOR, fg='#aaaaaa', + font=self.name_font, + width=14, anchor='w') + money_lbl = tk.Label(row, text='', bg=BG_COLOR, fg=color, + font=self.money_font, + anchor='e') + name_lbl.pack(side='left') + money_lbl.pack(side='right') + self._labels[player] = (name_lbl, money_lbl) + else: + name_lbl, money_lbl = self._labels[player] + money_lbl.configure(fg=color) + + self._labels[player][1].configure(text=money) + + # Remove rows for players no longer in cache + for player in list(self._labels.keys()): + if player not in players: + self._labels[player][0].master.destroy() + del self._labels[player] + + self.total_label.configure(text=f'Total: {fmt_money(total_money)}') + self.after(REFRESH_MS, self._refresh) + + +if __name__ == '__main__': + app = Overlay() + app.mainloop() diff --git a/players.json b/players.json new file mode 100644 index 0000000..2dc8a56 --- /dev/null +++ b/players.json @@ -0,0 +1,9 @@ +{ + "players": [ + "ZareMate", + "Qawe7", + "Bronkol", + "Skybloczek", + "cytrus1111" + ] +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dde1b32 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +python-dotenv==1.0.0 +requests==2.31.0 \ No newline at end of file