Skip to content
This repository was archived by the owner on Apr 14, 2026. It is now read-only.

Commit 53ac08b

Browse files
committed
feat(*): Country display
1 parent 6dbd6f4 commit 53ac08b

5 files changed

Lines changed: 165 additions & 14 deletions

File tree

app/fetcher.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import aiohttp
2+
3+
from app.server import Server
24
from app.xml_parser import parse_servers_from_xml
35

6+
47
async def fetch_game_servers(callback=None):
58
# Define the SOAP request body
69
soap_request = """<?xml version='1.0' encoding='utf-8'?>
@@ -28,6 +31,8 @@ async def fetch_game_servers(callback=None):
2831
servers = parse_servers_from_xml(response_text)
2932

3033
servers = [server for server in servers if server.version_nr != 0]
34+
35+
Server.get_country_codes(servers)
3136

3237
if callback:
3338
# Call the provided callback function (GUI update)

app/server.py

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
import json
12
from dataclasses import dataclass
2-
from typing import Optional
3+
from typing import ClassVar, Dict, List
4+
5+
import requests
36

47

58
@dataclass
69
class Server:
710
# Network
811
address_ipv4: str
912
port: int
10-
address_ipv6: Optional[str] = None
11-
lip: Optional[str] = None
13+
address_ipv6: str | None = None
14+
lip: str | None = None
1215

1316
# Game info
1417
game_name: str = ""
@@ -25,7 +28,7 @@ class Server:
2528
description: str = ""
2629
version: str = ""
2730
version_nr: int = 0
28-
application_instance: Optional[str] = None
31+
application_instance: str | None = None
2932

3033
GAME_MODES = {
3134
1: "Versus",
@@ -34,6 +37,9 @@ class Server:
3437
4: "Survival"
3538
}
3639

40+
# Class-level cache for IP to country code mapping
41+
_country_cache: ClassVar[Dict[str, str]] = {}
42+
3743
def __str__(self):
3844
return f"Server({self.game_name}, {self.address_ipv4}, {self.port})"
3945

@@ -50,4 +56,118 @@ def is_full(self):
5056
@property
5157
def connection_string(self):
5258
"""Get connection string for the server."""
53-
return f"{self.address_ipv4}:{self.port}"
59+
return f"{self.address_ipv4}:{self.port}"
60+
61+
@property
62+
def country_code(self) -> str:
63+
"""
64+
Get the country code for this server's IP address.
65+
66+
If the IP is not in cache, this will make an API request and block
67+
until the result is available. Uses single IP endpoint for efficiency.
68+
69+
Returns:
70+
Country code string (e.g., 'US', 'GB') or 'Unknown' if lookup fails
71+
"""
72+
# Check if this IP is already cached
73+
if self.address_ipv4 in Server._country_cache:
74+
return Server._country_cache[self.address_ipv4]
75+
76+
# IP not in cache, use single IP lookup for efficiency
77+
return Server._get_single_country_code(self.address_ipv4)
78+
79+
@staticmethod
80+
def get_country_codes(servers: List['Server']) -> Dict[str, str]:
81+
"""
82+
Retrieve country codes for a collection of Server instances using ip-api batch endpoint.
83+
84+
Args:
85+
servers: List of Server instances
86+
87+
Returns:
88+
Dictionary mapping IP addresses to country codes
89+
"""
90+
if not servers:
91+
return {}
92+
93+
# Get unique IP addresses that aren't already cached
94+
unique_ips = set(server.address_ipv4 for server in servers)
95+
uncached_ips = [ip for ip in unique_ips if ip not in Server._country_cache]
96+
97+
# If all IPs are cached, return cached results
98+
if not uncached_ips:
99+
return {ip: Server._country_cache[ip] for ip in unique_ips}
100+
101+
# Prepare batch request for uncached IPs
102+
# ip-api batch endpoint expects a JSON array of IP addresses or objects
103+
batch_data = uncached_ips
104+
105+
try:
106+
response = requests.post(
107+
'http://ip-api.com/batch?fields=16386',
108+
json=batch_data,
109+
headers={'Content-Type': 'application/json'},
110+
timeout=10
111+
)
112+
response.raise_for_status()
113+
114+
batch_results = response.json()
115+
116+
# Process results and update cache
117+
for i, result in enumerate(batch_results):
118+
ip = uncached_ips[i]
119+
if result.get('status') == 'success':
120+
country_code = result.get('countryCode', 'Unknown')
121+
Server._country_cache[ip] = country_code
122+
else:
123+
# Cache failed lookups as 'Unknown' to avoid repeated requests
124+
Server._country_cache[ip] = 'Unknown'
125+
126+
except (requests.RequestException, json.JSONDecodeError, KeyError) as e:
127+
# On error, cache all uncached IPs as 'Unknown' to avoid repeated failed requests
128+
for ip in uncached_ips:
129+
Server._country_cache[ip] = 'Unknown'
130+
print(f"Error fetching country codes: {e}")
131+
132+
# Return results for all requested IPs (both cached and newly fetched)
133+
return {ip: Server._country_cache[ip] for ip in unique_ips}
134+
135+
@staticmethod
136+
def _get_single_country_code(ip: str) -> str:
137+
"""
138+
Fetch country code for a single IP address using the single query endpoint.
139+
140+
Args:
141+
ip: IP address to lookup
142+
143+
Returns:
144+
Country code string or 'Unknown' if lookup fails
145+
"""
146+
try:
147+
response = requests.get(
148+
f'http://ip-api.com/json/{ip}?fields=16386',
149+
timeout=10
150+
)
151+
response.raise_for_status()
152+
153+
result = response.json()
154+
155+
if result.get('status') == 'success':
156+
country_code = result.get('countryCode', 'Unknown')
157+
Server._country_cache[ip] = country_code
158+
return country_code
159+
else:
160+
# Cache failed lookups as 'Unknown' to avoid repeated requests
161+
Server._country_cache[ip] = 'Unknown'
162+
return 'Unknown'
163+
164+
except (requests.RequestException, json.JSONDecodeError, KeyError) as e:
165+
# On error, cache as 'Unknown' to avoid repeated failed requests
166+
Server._country_cache[ip] = 'Unknown'
167+
print(f"Error fetching country code for {ip}: {e}")
168+
return 'Unknown'
169+
170+
@staticmethod
171+
def clear_country_cache():
172+
"""Clear the country code cache."""
173+
Server._country_cache.clear()

app/xml_parser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import xml.etree.ElementTree as Et
2+
23
from app.server import Server
34

45

gui.pyw

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import flet as ft
22
import asyncio
33
import threading
4-
from typing import List, Optional, Dict
4+
from typing import List, Dict
55
from app.fetcher import fetch_game_servers
66
from app.server import Server
77

@@ -19,15 +19,15 @@ class GameServersApp:
1919
self.page = page
2020
self.servers: List[Server] = []
2121
self.filtered_servers: List[Server] = []
22-
self.selected_server: Optional[Server] = None
22+
self.selected_server: Server | None = None
2323
self.auto_update_enabled = True
2424
self.fetch_interval = 12 # seconds
2525
self.sort_reverse: Dict[str, bool] = {} # Track sort direction for each column
26-
self.current_sort_column: Optional[str] = None # Track current sort column
26+
self.current_sort_column: str | None = None # Track current sort column
2727

2828
# Add shutdown control
2929
self.shutdown_event = threading.Event()
30-
self.auto_update_timer: Optional[threading.Timer] = None
30+
self.auto_update_timer: threading.Timer | None = None
3131

3232
# Register cleanup on page close
3333
self.page.on_window_event = self.on_window_event
@@ -112,6 +112,10 @@ class GameServersApp:
112112
ft.Text("Game Mode", weight=ft.FontWeight.BOLD),
113113
on_sort=lambda e: self.sort_table("game_mode")
114114
),
115+
ft.DataColumn(
116+
ft.Text("Country", weight=ft.FontWeight.BOLD),
117+
on_sort=lambda e: self.sort_table("country_code")
118+
),
115119
ft.DataColumn(
116120
ft.Text("Players", weight=ft.FontWeight.BOLD),
117121
on_sort=lambda e: self.sort_table("players")
@@ -311,6 +315,11 @@ class GameServersApp:
311315
cells=[
312316
ft.DataCell(ft.Text(server.game_name or "Unknown")),
313317
ft.DataCell(ft.Text(server.game_mode_name)),
318+
ft.DataCell(ft.Image(
319+
src=f"https://flagcdn.com/w20/{server.country_code.lower()}.png",
320+
width=20,
321+
fit=ft.ImageFit.CONTAIN
322+
)),
314323
ft.DataCell(ft.Text(f"{server.players}/{server.max_players}")),
315324
ft.DataCell(ft.Text("Yes" if server.has_password else "No")),
316325
ft.DataCell(ft.Text(server.version or "Unknown"))
@@ -439,6 +448,8 @@ Map Name: {server.map_name or 'Unknown'}"""
439448
self.filtered_servers.sort(key=lambda x: x.game_name or "", reverse=reverse)
440449
elif column == "game_mode":
441450
self.filtered_servers.sort(key=lambda x: x.game_mode_name, reverse=reverse)
451+
elif column == "country_code":
452+
self.filtered_servers.sort(key=lambda x: x.country_code, reverse=reverse)
442453
elif column == "players":
443454
self.filtered_servers.sort(key=lambda x: x.players, reverse=not reverse) # Default desc for players
444455
elif column == "password":
@@ -463,7 +474,8 @@ Map Name: {server.map_name or 'Unknown'}"""
463474
Game Mode: {server.game_mode_name}
464475
Players: {server.players}/{server.max_players}
465476
Version: {server.version or 'Unknown'}
466-
Address: {server.connection_string}"""
477+
Address: {server.connection_string}
478+
Country: {server.country_code}"""
467479

468480
try:
469481
self.page.set_clipboard(game_info)
@@ -510,6 +522,16 @@ Address: {server.connection_string}"""
510522
self.auto_update_timer.daemon = True
511523
self.auto_update_timer.start()
512524

525+
@staticmethod
526+
def country_code_to_flag(country_code: str) -> str:
527+
"""Convert country code to flag emoji."""
528+
if not country_code or country_code == 'Unknown' or len(country_code) != 2:
529+
return "🏳️" # White flag for unknown
530+
531+
# Convert country code to flag emoji using regional indicator symbols
532+
flag = ''.join(chr(ord(char) + 0x1F1A5) for char in country_code.upper())
533+
return flag
534+
513535

514536
def run_gui():
515537
"""Main entry point for the Flet application."""

main.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import asyncio
22
import os
3+
34
from app.fetcher import fetch_game_servers
45

6+
57
def cls():
68
os.system('cls' if os.name=='nt' else 'clear')
79

@@ -12,12 +14,13 @@ def display_server_details(servers):
1214
else:
1315
for i, server in enumerate(servers, start=1):
1416
print(f"Server {i}:")
15-
print(f" Game Name: {server.game_name}")
16-
print(f" Game Mode: {server.game_mode_name}")
1717
print(f" IP Address (IPv4): {server.address_ipv4}")
1818
#print(f" IP Address (IPv6): {server.address_ipv6}")
1919
#print(f" LIP: {server.lip}")
2020
print(f" Port: {server.port}")
21+
print(f" Country: {server.country_code}")
22+
print(f" Game Name: {server.game_name}")
23+
print(f" Game Mode: {server.game_mode_name}")
2124
print(f" Map Name: {server.map_name}")
2225
print(f" Players: {server.players}/{server.max_players}")
2326

@@ -49,8 +52,8 @@ def main():
4952

5053
display_server_details(servers)
5154

52-
# Wait for the user to press any key to continue
53-
input("\nPress any key to continue...")
55+
# Wait for the user to press Enter to continue
56+
input("\nPress Enter to continue...")
5457

5558
if __name__ == "__main__":
5659
main()

0 commit comments

Comments
 (0)