contentdb.lua 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. --Minetest
  2. --Copyright (C) 2018-24 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.
  13. --
  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.
  17. if not core.get_http_api then
  18. return
  19. end
  20. contentdb = {
  21. loading = false,
  22. load_ok = false,
  23. load_error = false,
  24. -- Unordered preserves the original order of the ContentDB API,
  25. -- before the package list is ordered based on installed state.
  26. packages = {},
  27. packages_full = {},
  28. packages_full_unordered = {},
  29. package_by_id = {},
  30. aliases = {},
  31. number_downloading = 0,
  32. download_queue = {},
  33. REASON_NEW = "new",
  34. REASON_UPDATE = "update",
  35. REASON_DEPENDENCY = "dependency",
  36. }
  37. local function get_download_url(package, reason)
  38. local base_url = core.settings:get("contentdb_url")
  39. local ret = base_url .. ("/packages/%s/releases/%d/download/"):format(
  40. package.url_part, package.release)
  41. if reason then
  42. ret = ret .. "?reason=" .. reason
  43. end
  44. return ret
  45. end
  46. local function download_and_extract(param)
  47. local package = param.package
  48. local filename = core.get_temp_path(true)
  49. if filename == "" or not core.download_file(param.url, filename) then
  50. core.log("error", "Downloading " .. dump(param.url) .. " failed")
  51. return {
  52. msg = fgettext_ne("Failed to download \"$1\"", package.title)
  53. }
  54. end
  55. local tempfolder = core.get_temp_path()
  56. if tempfolder ~= "" and not core.extract_zip(filename, tempfolder) then
  57. tempfolder = ""
  58. end
  59. os.remove(filename)
  60. if tempfolder == "" then
  61. return {
  62. msg = fgettext_ne("Failed to extract \"$1\" " ..
  63. "(insufficient disk space, unsupported file type or broken archive)",
  64. package.title),
  65. }
  66. end
  67. return {
  68. path = tempfolder
  69. }
  70. end
  71. local function start_install(package, reason)
  72. local params = {
  73. package = package,
  74. url = get_download_url(package, reason),
  75. }
  76. contentdb.number_downloading = contentdb.number_downloading + 1
  77. local function callback(result)
  78. if result.msg then
  79. gamedata.errormessage = result.msg
  80. else
  81. local path, msg = pkgmgr.install_dir(package.type, result.path, package.name, package.path)
  82. core.delete_dir(result.path)
  83. if not path then
  84. gamedata.errormessage = fgettext_ne("Error installing \"$1\": $2", package.title, msg)
  85. else
  86. core.log("action", "Installed package to " .. path)
  87. local conf_path
  88. local name_is_title = false
  89. if package.type == "mod" then
  90. local actual_type = pkgmgr.get_folder_type(path)
  91. if actual_type.type == "modpack" then
  92. conf_path = path .. DIR_DELIM .. "modpack.conf"
  93. else
  94. conf_path = path .. DIR_DELIM .. "mod.conf"
  95. end
  96. elseif package.type == "game" then
  97. conf_path = path .. DIR_DELIM .. "game.conf"
  98. name_is_title = true
  99. elseif package.type == "txp" then
  100. conf_path = path .. DIR_DELIM .. "texture_pack.conf"
  101. end
  102. if conf_path then
  103. local conf = Settings(conf_path)
  104. if not conf:get("title") then
  105. conf:set("title", package.title)
  106. end
  107. if not name_is_title then
  108. conf:set("name", package.name)
  109. end
  110. if not conf:get("description") then
  111. conf:set("description", package.short_description)
  112. end
  113. conf:set("author", package.author)
  114. conf:set("release", package.release)
  115. conf:write()
  116. end
  117. end
  118. end
  119. package.downloading = false
  120. contentdb.number_downloading = contentdb.number_downloading - 1
  121. local next = contentdb.download_queue[1]
  122. if next then
  123. table.remove(contentdb.download_queue, 1)
  124. start_install(next.package, next.reason)
  125. end
  126. ui.update()
  127. end
  128. package.queued = false
  129. package.downloading = true
  130. if not core.handle_async(download_and_extract, params, callback) then
  131. core.log("error", "ERROR: async event failed")
  132. gamedata.errormessage = fgettext_ne("Failed to download $1", package.name)
  133. return
  134. end
  135. end
  136. function contentdb.queue_download(package, reason)
  137. if package.queued or package.downloading then
  138. return
  139. end
  140. local max_concurrent_downloads = tonumber(core.settings:get("contentdb_max_concurrent_downloads"))
  141. if contentdb.number_downloading < math.max(max_concurrent_downloads, 1) then
  142. start_install(package, reason)
  143. else
  144. table.insert(contentdb.download_queue, { package = package, reason = reason })
  145. package.queued = true
  146. end
  147. end
  148. function contentdb.get_package_by_id(id)
  149. return contentdb.package_by_id[id]
  150. end
  151. local function get_raw_dependencies(package)
  152. if package.type ~= "mod" then
  153. return {}
  154. end
  155. if package.raw_deps then
  156. return package.raw_deps
  157. end
  158. local url_fmt = "/api/packages/%s/dependencies/?only_hard=1&protocol_version=%s&engine_version=%s"
  159. local version = core.get_version()
  160. local base_url = core.settings:get("contentdb_url")
  161. local url = base_url .. url_fmt:format(package.url_part, core.get_max_supp_proto(), core.urlencode(version.string))
  162. local http = core.get_http_api()
  163. local response = http.fetch_sync({ url = url })
  164. if not response.succeeded then
  165. core.log("error", "Unable to fetch dependencies for " .. package.url_part)
  166. return
  167. end
  168. local data = core.parse_json(response.data) or {}
  169. for id, raw_deps in pairs(data) do
  170. local package2 = contentdb.package_by_id[id:lower()]
  171. if package2 and not package2.raw_deps then
  172. package2.raw_deps = raw_deps
  173. for _, dep in pairs(raw_deps) do
  174. local packages = {}
  175. for i=1, #dep.packages do
  176. packages[#packages + 1] = contentdb.package_by_id[dep.packages[i]:lower()]
  177. end
  178. dep.packages = packages
  179. end
  180. end
  181. end
  182. return package.raw_deps
  183. end
  184. function contentdb.has_hard_deps(package)
  185. local raw_deps = get_raw_dependencies(package)
  186. if not raw_deps then
  187. return nil
  188. end
  189. for i=1, #raw_deps do
  190. if not raw_deps[i].is_optional then
  191. return true
  192. end
  193. end
  194. return false
  195. end
  196. -- Recursively resolve dependencies, given the installed mods
  197. local function resolve_dependencies_2(raw_deps, installed_mods, out)
  198. local function resolve_dep(dep)
  199. -- Check whether it's already installed
  200. if installed_mods[dep.name] then
  201. return {
  202. is_optional = dep.is_optional,
  203. name = dep.name,
  204. installed = true,
  205. }
  206. end
  207. -- Find exact name matches
  208. local fallback
  209. for _, package in pairs(dep.packages) do
  210. if package.type ~= "game" then
  211. if package.name == dep.name then
  212. return {
  213. is_optional = dep.is_optional,
  214. name = dep.name,
  215. installed = false,
  216. package = package,
  217. }
  218. elseif not fallback then
  219. fallback = package
  220. end
  221. end
  222. end
  223. -- Otherwise, find the first mod that fulfills it
  224. if fallback then
  225. return {
  226. is_optional = dep.is_optional,
  227. name = dep.name,
  228. installed = false,
  229. package = fallback,
  230. }
  231. end
  232. return {
  233. is_optional = dep.is_optional,
  234. name = dep.name,
  235. installed = false,
  236. }
  237. end
  238. for _, dep in pairs(raw_deps) do
  239. if not dep.is_optional and not out[dep.name] then
  240. local result = resolve_dep(dep)
  241. out[dep.name] = result
  242. if result and result.package and not result.installed then
  243. local raw_deps2 = get_raw_dependencies(result.package)
  244. if raw_deps2 then
  245. resolve_dependencies_2(raw_deps2, installed_mods, out)
  246. end
  247. end
  248. end
  249. end
  250. return true
  251. end
  252. -- Resolve dependencies for a package, calls the recursive version.
  253. function contentdb.resolve_dependencies(package, game)
  254. assert(game)
  255. local raw_deps = get_raw_dependencies(package)
  256. local installed_mods = {}
  257. local mods = {}
  258. pkgmgr.get_game_mods(game, mods)
  259. for _, mod in pairs(mods) do
  260. installed_mods[mod.name] = true
  261. end
  262. for _, mod in pairs(pkgmgr.global_mods:get_list()) do
  263. installed_mods[mod.name] = true
  264. end
  265. local out = {}
  266. if not resolve_dependencies_2(raw_deps, installed_mods, out) then
  267. return nil
  268. end
  269. local retval = {}
  270. for _, dep in pairs(out) do
  271. retval[#retval + 1] = dep
  272. end
  273. table.sort(retval, function(a, b)
  274. return a.name < b.name
  275. end)
  276. return retval
  277. end
  278. local function fetch_pkgs(params)
  279. local version = core.get_version()
  280. local base_url = core.settings:get("contentdb_url")
  281. local url = base_url ..
  282. "/api/packages/?type=mod&type=game&type=txp&protocol_version=" ..
  283. core.get_max_supp_proto() .. "&engine_version=" .. core.urlencode(version.string)
  284. for _, item in pairs(core.settings:get("contentdb_flag_blacklist"):split(",")) do
  285. item = item:trim()
  286. if item ~= "" then
  287. url = url .. "&hide=" .. core.urlencode(item)
  288. end
  289. end
  290. local languages
  291. local current_language = core.get_language()
  292. if current_language ~= "" then
  293. languages = { current_language, "en;q=0.8" }
  294. else
  295. languages = { "en" }
  296. end
  297. local http = core.get_http_api()
  298. local response = http.fetch_sync({
  299. url = url,
  300. extra_headers = {
  301. "Accept-Language: " .. table.concat(languages, ", ")
  302. },
  303. })
  304. if not response.succeeded then
  305. return
  306. end
  307. local packages = core.parse_json(response.data)
  308. if not packages or #packages == 0 then
  309. return
  310. end
  311. local aliases = {}
  312. for _, package in pairs(packages) do
  313. local name_len = #package.name
  314. -- This must match what contentdb.update_paths() does!
  315. package.id = package.author:lower() .. "/"
  316. if package.type == "game" and name_len > 5 and package.name:sub(name_len - 4) == "_game" then
  317. package.id = package.id .. package.name:sub(1, name_len - 5)
  318. else
  319. package.id = package.id .. package.name
  320. end
  321. package.url_part = core.urlencode(package.author) .. "/" .. core.urlencode(package.name)
  322. if package.aliases then
  323. for _, alias in ipairs(package.aliases) do
  324. -- We currently don't support name changing
  325. local suffix = "/" .. package.name
  326. if alias:sub(-#suffix) == suffix then
  327. aliases[alias:lower()] = package.id
  328. end
  329. end
  330. end
  331. end
  332. return { packages = packages, aliases = aliases }
  333. end
  334. function contentdb.fetch_pkgs(callback)
  335. contentdb.loading = true
  336. core.handle_async(fetch_pkgs, nil, function(result)
  337. if result then
  338. contentdb.load_ok = true
  339. contentdb.load_error = false
  340. contentdb.packages = result.packages
  341. contentdb.packages_full = result.packages
  342. contentdb.packages_full_unordered = result.packages
  343. contentdb.aliases = result.aliases
  344. for _, package in ipairs(result.packages) do
  345. contentdb.package_by_id[package.id] = package
  346. end
  347. else
  348. contentdb.load_error = true
  349. end
  350. contentdb.loading = false
  351. callback(result)
  352. end)
  353. end
  354. function contentdb.update_paths()
  355. local mod_hash = {}
  356. pkgmgr.refresh_globals()
  357. for _, mod in pairs(pkgmgr.global_mods:get_list()) do
  358. local cdb_id = pkgmgr.get_contentdb_id(mod)
  359. if cdb_id then
  360. mod_hash[contentdb.aliases[cdb_id] or cdb_id] = mod
  361. end
  362. end
  363. local game_hash = {}
  364. pkgmgr.update_gamelist()
  365. for _, game in pairs(pkgmgr.games) do
  366. local cdb_id = pkgmgr.get_contentdb_id(game)
  367. if cdb_id then
  368. game_hash[contentdb.aliases[cdb_id] or cdb_id] = game
  369. end
  370. end
  371. local txp_hash = {}
  372. for _, txp in pairs(pkgmgr.get_texture_packs()) do
  373. local cdb_id = pkgmgr.get_contentdb_id(txp)
  374. if cdb_id then
  375. txp_hash[contentdb.aliases[cdb_id] or cdb_id] = txp
  376. end
  377. end
  378. for _, package in pairs(contentdb.packages_full) do
  379. local content
  380. if package.type == "mod" then
  381. content = mod_hash[package.id]
  382. elseif package.type == "game" then
  383. content = game_hash[package.id]
  384. elseif package.type == "txp" then
  385. content = txp_hash[package.id]
  386. end
  387. if content then
  388. package.path = content.path
  389. package.installed_release = content.release or 0
  390. else
  391. package.path = nil
  392. package.installed_release = nil
  393. end
  394. end
  395. end
  396. function contentdb.sort_packages()
  397. local ret = {}
  398. -- Add installed content
  399. for _, pkg in ipairs(contentdb.packages_full_unordered) do
  400. if pkg.path then
  401. ret[#ret + 1] = pkg
  402. end
  403. end
  404. -- Sort installed content first by "is there an update available?", then by title
  405. table.sort(ret, function(a, b)
  406. local a_updatable = a.installed_release < a.release
  407. local b_updatable = b.installed_release < b.release
  408. if a_updatable and not b_updatable then
  409. return true
  410. elseif b_updatable and not a_updatable then
  411. return false
  412. end
  413. return a.title < b.title
  414. end)
  415. -- Add uninstalled content
  416. for _, pkg in ipairs(contentdb.packages_full_unordered) do
  417. if not pkg.path then
  418. ret[#ret + 1] = pkg
  419. end
  420. end
  421. contentdb.packages_full = ret
  422. end
  423. function contentdb.filter_packages(query, by_type)
  424. if query == "" and by_type == nil then
  425. contentdb.packages = contentdb.packages_full
  426. return
  427. end
  428. local keywords = {}
  429. for word in query:lower():gmatch("%S+") do
  430. table.insert(keywords, word)
  431. end
  432. local function matches_keywords(package)
  433. for k = 1, #keywords do
  434. local keyword = keywords[k]
  435. if string.find(package.name:lower(), keyword, 1, true) or
  436. string.find(package.title:lower(), keyword, 1, true) or
  437. string.find(package.author:lower(), keyword, 1, true) or
  438. string.find(package.short_description:lower(), keyword, 1, true) then
  439. return true
  440. end
  441. end
  442. return false
  443. end
  444. contentdb.packages = {}
  445. for _, package in pairs(contentdb.packages_full) do
  446. if (query == "" or matches_keywords(package)) and
  447. (by_type == nil or package.type == by_type) then
  448. contentdb.packages[#contentdb.packages + 1] = package
  449. end
  450. end
  451. end