Move values the mainmenu caches to dedicated files (#14433)
[minetest.git] / builtin / mainmenu / serverlistmgr.lua
blob997768c1557e06075c36da0d9e6dad20cdc6a8e1
1 --Minetest
2 --Copyright (C) 2020 rubenwardy
3 --
4 --This program is free software; you can redistribute it and/or modify
5 --it under the terms of the GNU Lesser General Public License as published by
6 --the Free Software Foundation; either version 2.1 of the License, or
7 --(at your option) any later version.
8 --
9 --This program is distributed in the hope that it will be useful,
10 --but WITHOUT ANY WARRANTY; without even the implied warranty of
11 --MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 --GNU Lesser General Public License for more details.
14 --You should have received a copy of the GNU Lesser General Public License along
15 --with this program; if not, write to the Free Software Foundation, Inc.,
16 --51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
18 serverlistmgr = {
19 -- continent code we detected for ourselves
20 my_continent = nil,
22 -- list of locally favorites servers
23 favorites = nil,
25 -- list of servers fetched from public list
26 servers = nil,
30 if check_cache_age("geoip_last_checked", 3600) then
31 local tmp = cache_settings:get("geoip") or ""
32 if tmp:match("^[A-Z][A-Z]$") then
33 serverlistmgr.my_continent = tmp
34 end
35 end
36 end
38 --------------------------------------------------------------------------------
39 -- Efficient data structure for normalizing arbitrary scores attached to objects
40 -- e.g. {{"a", 3.14}, {"b", 3.14}, {"c", 20}, {"d", 0}}
41 -- -> {["d"] = 0, ["a"] = 0.5, ["b"] = 0.5, ["c"] = 1}
42 local Normalizer = {}
44 function Normalizer:new()
45 local t = {
46 map = {}
48 setmetatable(t, self)
49 self.__index = self
50 return t
51 end
53 function Normalizer:push(obj, score)
54 if not self.map[score] then
55 self.map[score] = {}
56 end
57 local t = self.map[score]
58 t[#t + 1] = obj
59 end
61 function Normalizer:calc()
62 local list = {}
63 for k, _ in pairs(self.map) do
64 list[#list + 1] = k
65 end
66 table.sort(list)
67 local ret = {}
68 for i, k in ipairs(list) do
69 local score = #list == 1 and 1 or ( (i - 1) / (#list - 1) )
70 for _, obj in ipairs(self.map[k]) do
71 ret[obj] = score
72 end
73 end
74 return ret
75 end
77 --------------------------------------------------------------------------------
78 -- how much the pre-sorted server list contributes to the final ranking
79 local WEIGHT_SORT = 2
80 -- how much the estimated latency contributes to the final ranking
81 local WEIGHT_LATENCY = 1
83 local function order_server_list(list)
84 -- calculate the scores
85 local s1 = Normalizer:new()
86 local s2 = Normalizer:new()
87 for i, fav in ipairs(list) do
88 -- first: the original position
89 s1:push(fav, #list - i)
90 -- second: estimated latency
91 local ping = (fav.ping or 0) * 1000
92 if ping < 400 then
93 -- If ping is under 400ms replace it with our own estimate,
94 -- we assume the server has latency issues anyway otherwise
95 ping = estimate_continent_latency(serverlistmgr.my_continent, fav) or 0
96 end
97 s2:push(fav, -ping)
98 end
99 s1 = s1:calc()
100 s2 = s2:calc()
102 -- make a shallow copy and pre-calculate ordering
103 local res, order = {}, {}
104 for i = 1, #list do
105 local fav = list[i]
106 res[i] = fav
108 local n = s1[fav] * WEIGHT_SORT + s2[fav] * WEIGHT_LATENCY
109 order[fav] = n
112 -- now sort the list
113 table.sort(res, function(fav1, fav2)
114 return order[fav1] > order[fav2]
115 end)
117 return res
120 local public_downloading = false
121 local geoip_downloading = false
123 --------------------------------------------------------------------------------
124 local function fetch_geoip()
125 local http = core.get_http_api()
126 local url = core.settings:get("serverlist_url") .. "/geoip"
128 local response = http.fetch_sync({ url = url })
129 if not response.succeeded then
130 return
133 local retval = core.parse_json(response.data)
134 if type(retval) ~= "table" then
135 return
137 return type(retval.continent) == "string" and retval.continent
140 function serverlistmgr.sync()
141 if not serverlistmgr.servers then
142 serverlistmgr.servers = {{
143 name = fgettext("Loading..."),
144 description = fgettext_ne("Try reenabling public serverlist and check your internet connection.")
148 local serverlist_url = core.settings:get("serverlist_url") or ""
149 if not core.get_http_api or serverlist_url == "" then
150 serverlistmgr.servers = {{
151 name = fgettext("Public server list is disabled"),
152 description = ""
154 return
157 if not serverlistmgr.my_continent and not geoip_downloading then
158 geoip_downloading = true
159 core.handle_async(fetch_geoip, nil, function(result)
160 geoip_downloading = false
161 if not result then
162 return
164 serverlistmgr.my_continent = result
165 cache_settings:set("geoip", result)
166 cache_settings:set("geoip_last_checked", tostring(os.time()))
168 -- re-sort list if applicable
169 if serverlistmgr.servers then
170 serverlistmgr.servers = order_server_list(serverlistmgr.servers)
171 core.event_handler("Refresh")
173 end)
176 if public_downloading then
177 return
179 public_downloading = true
181 -- note: this isn't cached because it's way too dynamic
182 core.handle_async(
183 function(param)
184 local http = core.get_http_api()
185 local url = ("%s/list?proto_version_min=%d&proto_version_max=%d"):format(
186 core.settings:get("serverlist_url"),
187 core.get_min_supp_proto(),
188 core.get_max_supp_proto())
190 local response = http.fetch_sync({ url = url })
191 if not response.succeeded then
192 return {}
195 local retval = core.parse_json(response.data)
196 return retval and retval.list or {}
197 end,
198 nil,
199 function(result)
200 public_downloading = false
201 local favs = order_server_list(result)
202 if favs[1] then
203 serverlistmgr.servers = favs
205 core.event_handler("Refresh")
210 --------------------------------------------------------------------------------
211 local function get_favorites_path(folder)
212 local base = core.get_user_path() .. DIR_DELIM .. "client" .. DIR_DELIM .. "serverlist" .. DIR_DELIM
213 if folder then
214 return base
216 return base .. core.settings:get("serverlist_file")
219 --------------------------------------------------------------------------------
220 local function save_favorites(favorites)
221 local filename = core.settings:get("serverlist_file")
222 -- If setting specifies legacy format change the filename to the new one
223 if filename:sub(#filename - 3):lower() == ".txt" then
224 core.settings:set("serverlist_file", filename:sub(1, #filename - 4) .. ".json")
227 assert(core.create_dir(get_favorites_path(true)))
228 core.safe_file_write(get_favorites_path(), core.write_json(favorites))
231 --------------------------------------------------------------------------------
232 function serverlistmgr.read_legacy_favorites(path)
233 local file = io.open(path, "r")
234 if not file then
235 return nil
238 local lines = {}
239 for line in file:lines() do
240 lines[#lines + 1] = line
242 file:close()
244 local favorites = {}
246 local i = 1
247 while i < #lines do
248 local function pop()
249 local line = lines[i]
250 i = i + 1
251 return line and line:trim()
254 if pop():lower() == "[server]" then
255 local name = pop()
256 local address = pop()
257 local port = tonumber(pop())
258 local description = pop()
260 if name == "" then
261 name = nil
264 if description == "" then
265 description = nil
268 if not address or #address < 3 then
269 core.log("warning", "Malformed favorites file, missing address at line " .. i)
270 elseif not port or port < 1 or port > 65535 then
271 core.log("warning", "Malformed favorites file, missing port at line " .. i)
272 elseif (name and name:upper() == "[SERVER]") or
273 (address and address:upper() == "[SERVER]") or
274 (description and description:upper() == "[SERVER]") then
275 core.log("warning", "Potentially malformed favorites file, overran at line " .. i)
276 else
277 favorites[#favorites + 1] = {
278 name = name,
279 address = address,
280 port = port,
281 description = description
287 return favorites
290 --------------------------------------------------------------------------------
291 local function read_favorites()
292 local path = get_favorites_path()
294 -- If new format configured fall back to reading the legacy file
295 if path:sub(#path - 4):lower() == ".json" then
296 local file = io.open(path, "r")
297 if file then
298 local json = file:read("*all")
299 file:close()
300 return core.parse_json(json)
303 path = path:sub(1, #path - 5) .. ".txt"
306 local favs = serverlistmgr.read_legacy_favorites(path)
307 if favs then
308 save_favorites(favs)
309 os.remove(path)
311 return favs
314 --------------------------------------------------------------------------------
315 local function delete_favorite(favorites, del_favorite)
316 for i=1, #favorites do
317 local fav = favorites[i]
319 if fav.address == del_favorite.address and fav.port == del_favorite.port then
320 table.remove(favorites, i)
321 return
326 --------------------------------------------------------------------------------
327 function serverlistmgr.get_favorites()
328 if serverlistmgr.favorites then
329 return serverlistmgr.favorites
332 serverlistmgr.favorites = {}
334 -- Add favorites, removing duplicates
335 local seen = {}
336 for _, fav in ipairs(read_favorites() or {}) do
337 local key = ("%s:%d"):format(fav.address:lower(), fav.port)
338 if not seen[key] then
339 seen[key] = true
340 serverlistmgr.favorites[#serverlistmgr.favorites + 1] = fav
344 return serverlistmgr.favorites
347 --------------------------------------------------------------------------------
348 function serverlistmgr.add_favorite(new_favorite)
349 assert(type(new_favorite.port) == "number")
351 -- Whitelist favorite keys
352 new_favorite = {
353 name = new_favorite.name,
354 address = new_favorite.address,
355 port = new_favorite.port,
356 description = new_favorite.description,
359 local favorites = serverlistmgr.get_favorites()
360 delete_favorite(favorites, new_favorite)
361 table.insert(favorites, 1, new_favorite)
362 save_favorites(favorites)
365 --------------------------------------------------------------------------------
366 function serverlistmgr.delete_favorite(del_favorite)
367 local favorites = serverlistmgr.get_favorites()
368 delete_favorite(favorites, del_favorite)
369 save_favorites(favorites)