contentdb.lua 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  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. pkgmgr.reload_by_type(package.type)
  118. end
  119. end
  120. package.downloading = false
  121. contentdb.number_downloading = contentdb.number_downloading - 1
  122. local next = contentdb.download_queue[1]
  123. if next then
  124. table.remove(contentdb.download_queue, 1)
  125. start_install(next.package, next.reason)
  126. end
  127. ui.update()
  128. end
  129. package.queued = false
  130. package.downloading = true
  131. if not core.handle_async(download_and_extract, params, callback) then
  132. core.log("error", "ERROR: async event failed")
  133. gamedata.errormessage = fgettext_ne("Failed to download $1", package.name)
  134. return
  135. end
  136. end
  137. function contentdb.queue_download(package, reason)
  138. if package.queued or package.downloading then
  139. return
  140. end
  141. local max_concurrent_downloads = tonumber(core.settings:get("contentdb_max_concurrent_downloads"))
  142. if contentdb.number_downloading < math.max(max_concurrent_downloads, 1) then
  143. start_install(package, reason)
  144. else
  145. table.insert(contentdb.download_queue, { package = package, reason = reason })
  146. package.queued = true
  147. end
  148. end
  149. function contentdb.get_package_by_id(id)
  150. return contentdb.package_by_id[id]
  151. end
  152. -- Create a coroutine from `fn` and provide results to `callback` when complete (dead).
  153. -- Returns a resumer function.
  154. local function make_callback_coroutine(fn, callback)
  155. local co = coroutine.create(fn)
  156. local function resumer(...)
  157. local ok, result = coroutine.resume(co, ...)
  158. if not ok then
  159. error(result)
  160. elseif coroutine.status(co) == "dead" then
  161. callback(result)
  162. end
  163. end
  164. return resumer
  165. end
  166. local function get_raw_dependencies_async(package)
  167. local url_fmt = "/api/packages/%s/dependencies/?only_hard=1&protocol_version=%s&engine_version=%s"
  168. local version = core.get_version()
  169. local base_url = core.settings:get("contentdb_url")
  170. local url = base_url .. url_fmt:format(package.url_part, core.get_max_supp_proto(), core.urlencode(version.string))
  171. local http = core.get_http_api()
  172. local response = http.fetch_sync({ url = url })
  173. if not response.succeeded then
  174. return nil
  175. end
  176. return core.parse_json(response.data) or {}
  177. end
  178. local function get_raw_dependencies_co(package, resumer)
  179. if package.type ~= "mod" then
  180. return {}
  181. end
  182. if package.raw_deps then
  183. return package.raw_deps
  184. end
  185. core.handle_async(get_raw_dependencies_async, package, resumer)
  186. local data = coroutine.yield()
  187. if not data then
  188. return nil
  189. end
  190. for id, raw_deps in pairs(data) do
  191. local package2 = contentdb.package_by_id[id:lower()]
  192. if package2 and not package2.raw_deps then
  193. package2.raw_deps = raw_deps
  194. for _, dep in pairs(raw_deps) do
  195. local packages = {}
  196. for i=1, #dep.packages do
  197. packages[#packages + 1] = contentdb.package_by_id[dep.packages[i]:lower()]
  198. end
  199. dep.packages = packages
  200. end
  201. end
  202. end
  203. return package.raw_deps
  204. end
  205. local function has_hard_deps_co(package, resumer)
  206. local raw_deps = get_raw_dependencies_co(package, resumer)
  207. if not raw_deps then
  208. return nil
  209. end
  210. for i=1, #raw_deps do
  211. if not raw_deps[i].is_optional then
  212. return true
  213. end
  214. end
  215. return false
  216. end
  217. function contentdb.has_hard_deps(package, callback)
  218. local resumer = make_callback_coroutine(has_hard_deps_co, callback)
  219. resumer(package, resumer)
  220. end
  221. -- Recursively resolve dependencies, given the installed mods
  222. local function resolve_dependencies_2_co(raw_deps, installed_mods, out, resumer)
  223. local function resolve_dep(dep)
  224. -- Check whether it's already installed
  225. if installed_mods[dep.name] then
  226. return {
  227. is_optional = dep.is_optional,
  228. name = dep.name,
  229. installed = true,
  230. }
  231. end
  232. -- Find exact name matches
  233. local fallback
  234. for _, package in pairs(dep.packages) do
  235. if package.type ~= "game" then
  236. if package.name == dep.name then
  237. return {
  238. is_optional = dep.is_optional,
  239. name = dep.name,
  240. installed = false,
  241. package = package,
  242. }
  243. elseif not fallback then
  244. fallback = package
  245. end
  246. end
  247. end
  248. -- Otherwise, find the first mod that fulfills it
  249. if fallback then
  250. return {
  251. is_optional = dep.is_optional,
  252. name = dep.name,
  253. installed = false,
  254. package = fallback,
  255. }
  256. end
  257. return {
  258. is_optional = dep.is_optional,
  259. name = dep.name,
  260. installed = false,
  261. }
  262. end
  263. for _, dep in pairs(raw_deps) do
  264. if not dep.is_optional and not out[dep.name] then
  265. local result = resolve_dep(dep)
  266. out[dep.name] = result
  267. if result and result.package and not result.installed then
  268. local raw_deps2 = get_raw_dependencies_co(result.package, resumer)
  269. if raw_deps2 then
  270. resolve_dependencies_2_co(raw_deps2, installed_mods, out, resumer)
  271. end
  272. end
  273. end
  274. end
  275. return true
  276. end
  277. local function resolve_dependencies_co(package, game, resumer)
  278. assert(game)
  279. local raw_deps = get_raw_dependencies_co(package, resumer)
  280. local installed_mods = {}
  281. local mods = {}
  282. pkgmgr.get_game_mods(game, mods)
  283. for _, mod in pairs(mods) do
  284. installed_mods[mod.name] = true
  285. end
  286. for _, mod in pairs(pkgmgr.global_mods:get_list()) do
  287. installed_mods[mod.name] = true
  288. end
  289. local out = {}
  290. if not resolve_dependencies_2_co(raw_deps, installed_mods, out, resumer) then
  291. return nil
  292. end
  293. local retval = {}
  294. for _, dep in pairs(out) do
  295. retval[#retval + 1] = dep
  296. end
  297. table.sort(retval, function(a, b)
  298. return a.name < b.name
  299. end)
  300. return retval
  301. end
  302. -- Resolve dependencies for a package, calls the recursive version.
  303. function contentdb.resolve_dependencies(package, game, callback)
  304. local resumer = make_callback_coroutine(resolve_dependencies_co, callback)
  305. resumer(package, game, resumer)
  306. end
  307. local function fetch_pkgs(params)
  308. local version = core.get_version()
  309. local base_url = core.settings:get("contentdb_url")
  310. local url = base_url ..
  311. "/api/packages/?type=mod&type=game&type=txp&protocol_version=" ..
  312. core.get_max_supp_proto() .. "&engine_version=" .. core.urlencode(version.string)
  313. for _, item in pairs(core.settings:get("contentdb_flag_blacklist"):split(",")) do
  314. item = item:trim()
  315. if item ~= "" then
  316. url = url .. "&hide=" .. core.urlencode(item)
  317. end
  318. end
  319. local languages
  320. local current_language = core.get_language()
  321. if current_language ~= "" then
  322. languages = { current_language, "en;q=0.8" }
  323. else
  324. languages = { "en" }
  325. end
  326. local http = core.get_http_api()
  327. local response = http.fetch_sync({
  328. url = url,
  329. extra_headers = {
  330. "Accept-Language: " .. table.concat(languages, ", ")
  331. },
  332. })
  333. if not response.succeeded then
  334. return
  335. end
  336. local packages = core.parse_json(response.data)
  337. if not packages or #packages == 0 then
  338. return
  339. end
  340. local aliases = {}
  341. for _, package in pairs(packages) do
  342. local name_len = #package.name
  343. -- This must match what contentdb.update_paths() does!
  344. package.id = package.author:lower() .. "/"
  345. if package.type == "game" and name_len > 5 and package.name:sub(name_len - 4) == "_game" then
  346. package.id = package.id .. package.name:sub(1, name_len - 5)
  347. else
  348. package.id = package.id .. package.name
  349. end
  350. package.url_part = core.urlencode(package.author) .. "/" .. core.urlencode(package.name)
  351. if package.aliases then
  352. for _, alias in ipairs(package.aliases) do
  353. -- We currently don't support name changing
  354. local suffix = "/" .. package.name
  355. if alias:sub(-#suffix) == suffix then
  356. aliases[alias:lower()] = package.id
  357. end
  358. end
  359. end
  360. end
  361. return { packages = packages, aliases = aliases }
  362. end
  363. function contentdb.fetch_pkgs(callback)
  364. contentdb.loading = true
  365. core.handle_async(fetch_pkgs, nil, function(result)
  366. if result then
  367. contentdb.load_ok = true
  368. contentdb.load_error = false
  369. contentdb.packages = result.packages
  370. contentdb.packages_full = result.packages
  371. contentdb.packages_full_unordered = result.packages
  372. contentdb.aliases = result.aliases
  373. for _, package in ipairs(result.packages) do
  374. contentdb.package_by_id[package.id] = package
  375. end
  376. else
  377. contentdb.load_error = true
  378. end
  379. contentdb.loading = false
  380. callback(result)
  381. end)
  382. end
  383. function contentdb.update_paths()
  384. pkgmgr.load_all()
  385. local mod_hash = {}
  386. for _, mod in pairs(pkgmgr.global_mods:get_list()) do
  387. local cdb_id = pkgmgr.get_contentdb_id(mod)
  388. if cdb_id then
  389. mod_hash[contentdb.aliases[cdb_id] or cdb_id] = mod
  390. end
  391. end
  392. local game_hash = {}
  393. for _, game in pairs(pkgmgr.games) do
  394. local cdb_id = pkgmgr.get_contentdb_id(game)
  395. if cdb_id then
  396. game_hash[contentdb.aliases[cdb_id] or cdb_id] = game
  397. end
  398. end
  399. local txp_hash = {}
  400. for _, txp in pairs(pkgmgr.texture_packs) do
  401. local cdb_id = pkgmgr.get_contentdb_id(txp)
  402. if cdb_id then
  403. txp_hash[contentdb.aliases[cdb_id] or cdb_id] = txp
  404. end
  405. end
  406. for _, package in pairs(contentdb.packages_full) do
  407. local content
  408. if package.type == "mod" then
  409. content = mod_hash[package.id]
  410. elseif package.type == "game" then
  411. content = game_hash[package.id]
  412. elseif package.type == "txp" then
  413. content = txp_hash[package.id]
  414. end
  415. if content then
  416. package.path = content.path
  417. package.installed_release = content.release or 0
  418. else
  419. package.path = nil
  420. package.installed_release = nil
  421. end
  422. end
  423. end
  424. function contentdb.sort_packages()
  425. local ret = {}
  426. -- Add installed content
  427. for _, pkg in ipairs(contentdb.packages_full_unordered) do
  428. if pkg.path then
  429. ret[#ret + 1] = pkg
  430. end
  431. end
  432. -- Sort installed content first by "is there an update available?", then by title
  433. table.sort(ret, function(a, b)
  434. local a_updatable = a.installed_release < a.release
  435. local b_updatable = b.installed_release < b.release
  436. if a_updatable and not b_updatable then
  437. return true
  438. elseif b_updatable and not a_updatable then
  439. return false
  440. end
  441. return a.title < b.title
  442. end)
  443. -- Add uninstalled content
  444. for _, pkg in ipairs(contentdb.packages_full_unordered) do
  445. if not pkg.path then
  446. ret[#ret + 1] = pkg
  447. end
  448. end
  449. contentdb.packages_full = ret
  450. end
  451. function contentdb.filter_packages(query, by_type)
  452. if query == "" and by_type == nil then
  453. contentdb.packages = contentdb.packages_full
  454. return
  455. end
  456. local keywords = {}
  457. for word in query:lower():gmatch("%S+") do
  458. table.insert(keywords, word)
  459. end
  460. local function matches_keywords(package)
  461. for k = 1, #keywords do
  462. local keyword = keywords[k]
  463. if string.find(package.name:lower(), keyword, 1, true) or
  464. string.find(package.title:lower(), keyword, 1, true) or
  465. string.find(package.author:lower(), keyword, 1, true) or
  466. string.find(package.short_description:lower(), keyword, 1, true) then
  467. return true
  468. end
  469. end
  470. return false
  471. end
  472. contentdb.packages = {}
  473. for _, package in pairs(contentdb.packages_full) do
  474. if (query == "" or matches_keywords(package)) and
  475. (by_type == nil or package.type == by_type) then
  476. contentdb.packages[#contentdb.packages + 1] = package
  477. end
  478. end
  479. end