contentdb.lua 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. --Luanti
  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. function contentdb.calculate_package_id(type, author, name)
  153. local id = author:lower() .. "/"
  154. if (type == nil or type == "game") and #name > 5 and name:sub(#name - 4) == "_game" then
  155. id = id .. name:sub(1, #name - 5)
  156. else
  157. id = id .. name
  158. end
  159. return id
  160. end
  161. function contentdb.get_package_by_info(author, name)
  162. local id = contentdb.calculate_package_id(nil, author, name)
  163. return contentdb.package_by_id[id]
  164. end
  165. -- Create a coroutine from `fn` and provide results to `callback` when complete (dead).
  166. -- Returns a resumer function.
  167. local function make_callback_coroutine(fn, callback)
  168. local co = coroutine.create(fn)
  169. local function resumer(...)
  170. local ok, result = coroutine.resume(co, ...)
  171. if not ok then
  172. error(result)
  173. elseif coroutine.status(co) == "dead" then
  174. callback(result)
  175. end
  176. end
  177. return resumer
  178. end
  179. local function get_raw_dependencies_async(package)
  180. local url_fmt = "/api/packages/%s/dependencies/?only_hard=1&protocol_version=%s&engine_version=%s"
  181. local version = core.get_version()
  182. local base_url = core.settings:get("contentdb_url")
  183. local url = base_url .. url_fmt:format(package.url_part, core.get_max_supp_proto(), core.urlencode(version.string))
  184. local http = core.get_http_api()
  185. local response = http.fetch_sync({ url = url })
  186. if not response.succeeded then
  187. return nil
  188. end
  189. return core.parse_json(response.data) or {}
  190. end
  191. local function get_raw_dependencies_co(package, resumer)
  192. if package.type ~= "mod" then
  193. return {}
  194. end
  195. if package.raw_deps then
  196. return package.raw_deps
  197. end
  198. core.handle_async(get_raw_dependencies_async, package, resumer)
  199. local data = coroutine.yield()
  200. if not data then
  201. return nil
  202. end
  203. for id, raw_deps in pairs(data) do
  204. local package2 = contentdb.package_by_id[id:lower()]
  205. if package2 and not package2.raw_deps then
  206. package2.raw_deps = raw_deps
  207. for _, dep in pairs(raw_deps) do
  208. local packages = {}
  209. for i=1, #dep.packages do
  210. packages[#packages + 1] = contentdb.package_by_id[dep.packages[i]:lower()]
  211. end
  212. dep.packages = packages
  213. end
  214. end
  215. end
  216. return package.raw_deps
  217. end
  218. local function has_hard_deps_co(package, resumer)
  219. local raw_deps = get_raw_dependencies_co(package, resumer)
  220. if not raw_deps then
  221. return nil
  222. end
  223. for i=1, #raw_deps do
  224. if not raw_deps[i].is_optional then
  225. return true
  226. end
  227. end
  228. return false
  229. end
  230. function contentdb.has_hard_deps(package, callback)
  231. local resumer = make_callback_coroutine(has_hard_deps_co, callback)
  232. resumer(package, resumer)
  233. end
  234. -- Recursively resolve dependencies, given the installed mods
  235. local function resolve_dependencies_2_co(raw_deps, installed_mods, out, resumer)
  236. local function resolve_dep(dep)
  237. -- Check whether it's already installed
  238. if installed_mods[dep.name] then
  239. return {
  240. is_optional = dep.is_optional,
  241. name = dep.name,
  242. installed = true,
  243. }
  244. end
  245. -- Find exact name matches
  246. local fallback
  247. for _, package in pairs(dep.packages) do
  248. if package.type ~= "game" then
  249. if package.name == dep.name then
  250. return {
  251. is_optional = dep.is_optional,
  252. name = dep.name,
  253. installed = false,
  254. package = package,
  255. }
  256. elseif not fallback then
  257. fallback = package
  258. end
  259. end
  260. end
  261. -- Otherwise, find the first mod that fulfills it
  262. if fallback then
  263. return {
  264. is_optional = dep.is_optional,
  265. name = dep.name,
  266. installed = false,
  267. package = fallback,
  268. }
  269. end
  270. return {
  271. is_optional = dep.is_optional,
  272. name = dep.name,
  273. installed = false,
  274. }
  275. end
  276. for _, dep in pairs(raw_deps) do
  277. if not dep.is_optional and not out[dep.name] then
  278. local result = resolve_dep(dep)
  279. out[dep.name] = result
  280. if result and result.package and not result.installed then
  281. local raw_deps2 = get_raw_dependencies_co(result.package, resumer)
  282. if raw_deps2 then
  283. resolve_dependencies_2_co(raw_deps2, installed_mods, out, resumer)
  284. end
  285. end
  286. end
  287. end
  288. return true
  289. end
  290. local function resolve_dependencies_co(package, game, resumer)
  291. assert(game)
  292. local raw_deps = get_raw_dependencies_co(package, resumer)
  293. local installed_mods = {}
  294. local mods = {}
  295. pkgmgr.get_game_mods(game, mods)
  296. for _, mod in pairs(mods) do
  297. installed_mods[mod.name] = true
  298. end
  299. for _, mod in pairs(pkgmgr.global_mods:get_list()) do
  300. installed_mods[mod.name] = true
  301. end
  302. local out = {}
  303. if not resolve_dependencies_2_co(raw_deps, installed_mods, out, resumer) then
  304. return nil
  305. end
  306. local retval = {}
  307. for _, dep in pairs(out) do
  308. retval[#retval + 1] = dep
  309. end
  310. table.sort(retval, function(a, b)
  311. return a.name < b.name
  312. end)
  313. return retval
  314. end
  315. -- Resolve dependencies for a package, calls the recursive version.
  316. function contentdb.resolve_dependencies(package, game, callback)
  317. local resumer = make_callback_coroutine(resolve_dependencies_co, callback)
  318. resumer(package, game, resumer)
  319. end
  320. local function fetch_pkgs()
  321. local version = core.get_version()
  322. local base_url = core.settings:get("contentdb_url")
  323. local url = base_url ..
  324. "/api/packages/?type=mod&type=game&type=txp&protocol_version=" ..
  325. core.get_max_supp_proto() .. "&engine_version=" .. core.urlencode(version.string)
  326. for _, item in pairs(core.settings:get("contentdb_flag_blacklist"):split(",")) do
  327. item = item:trim()
  328. if item ~= "" then
  329. url = url .. "&hide=" .. core.urlencode(item)
  330. end
  331. end
  332. local languages
  333. local current_language = core.get_language()
  334. if current_language ~= "" then
  335. languages = { current_language, "en;q=0.8" }
  336. else
  337. languages = { "en" }
  338. end
  339. local http = core.get_http_api()
  340. local response = http.fetch_sync({
  341. url = url,
  342. extra_headers = {
  343. "Accept-Language: " .. table.concat(languages, ", ")
  344. },
  345. })
  346. if not response.succeeded then
  347. return
  348. end
  349. local packages = core.parse_json(response.data)
  350. if not packages or #packages == 0 then
  351. return
  352. end
  353. return packages
  354. end
  355. function contentdb.set_packages_from_api(packages)
  356. contentdb.package_by_id = {}
  357. contentdb.aliases = {}
  358. for _, package in pairs(packages) do
  359. package.id = contentdb.calculate_package_id(package.type, package.author, package.name)
  360. package.url_part = core.urlencode(package.author) .. "/" .. core.urlencode(package.name)
  361. contentdb.package_by_id[package.id] = package
  362. if package.aliases then
  363. for _, alias in ipairs(package.aliases) do
  364. -- We currently don't support name changing
  365. local suffix = "/" .. package.name
  366. if alias:sub(-#suffix) == suffix then
  367. contentdb.aliases[alias:lower()] = package.id
  368. end
  369. end
  370. end
  371. end
  372. contentdb.load_ok = true
  373. contentdb.load_error = false
  374. contentdb.packages = packages
  375. contentdb.packages_full = packages
  376. contentdb.packages_full_unordered = packages
  377. end
  378. function contentdb.fetch_pkgs(callback)
  379. contentdb.loading = true
  380. core.handle_async(fetch_pkgs, nil, function(result)
  381. if result then
  382. contentdb.set_packages_from_api(result)
  383. else
  384. contentdb.load_error = true
  385. end
  386. contentdb.loading = false
  387. callback(result)
  388. end)
  389. end
  390. function contentdb.update_paths()
  391. pkgmgr.load_all()
  392. local mod_hash = {}
  393. for _, mod in pairs(pkgmgr.global_mods:get_list()) do
  394. local cdb_id = pkgmgr.get_contentdb_id(mod)
  395. if cdb_id then
  396. mod_hash[contentdb.aliases[cdb_id] or cdb_id] = mod
  397. end
  398. end
  399. local game_hash = {}
  400. for _, game in pairs(pkgmgr.games) do
  401. local cdb_id = pkgmgr.get_contentdb_id(game)
  402. if cdb_id then
  403. game_hash[contentdb.aliases[cdb_id] or cdb_id] = game
  404. end
  405. end
  406. local txp_hash = {}
  407. for _, txp in pairs(pkgmgr.texture_packs) do
  408. local cdb_id = pkgmgr.get_contentdb_id(txp)
  409. if cdb_id then
  410. txp_hash[contentdb.aliases[cdb_id] or cdb_id] = txp
  411. end
  412. end
  413. for _, package in pairs(contentdb.packages_full) do
  414. local content
  415. if package.type == "mod" then
  416. content = mod_hash[package.id]
  417. elseif package.type == "game" then
  418. content = game_hash[package.id]
  419. elseif package.type == "txp" then
  420. content = txp_hash[package.id]
  421. end
  422. if content then
  423. package.path = content.path
  424. package.installed_release = content.release or 0
  425. else
  426. package.path = nil
  427. package.installed_release = nil
  428. end
  429. end
  430. end
  431. function contentdb.sort_packages()
  432. local ret = {}
  433. -- Add installed content
  434. for _, pkg in ipairs(contentdb.packages_full_unordered) do
  435. if pkg.path then
  436. ret[#ret + 1] = pkg
  437. end
  438. end
  439. -- Sort installed content first by "is there an update available?", then by title
  440. table.sort(ret, function(a, b)
  441. local a_updatable = a.installed_release < a.release
  442. local b_updatable = b.installed_release < b.release
  443. if a_updatable and not b_updatable then
  444. return true
  445. elseif b_updatable and not a_updatable then
  446. return false
  447. end
  448. return a.title < b.title
  449. end)
  450. -- Add uninstalled content
  451. for _, pkg in ipairs(contentdb.packages_full_unordered) do
  452. if not pkg.path then
  453. ret[#ret + 1] = pkg
  454. end
  455. end
  456. contentdb.packages_full = ret
  457. end
  458. function contentdb.filter_packages(query, by_type)
  459. if query == "" and by_type == nil then
  460. contentdb.packages = contentdb.packages_full
  461. return
  462. end
  463. local keywords = {}
  464. for word in query:gmatch("%S+") do
  465. table.insert(keywords, word:lower())
  466. end
  467. local function contains_all_keywords(str)
  468. str = str:lower()
  469. for _, keyword in ipairs(keywords) do
  470. if not str:find(keyword, 1, true) then
  471. return false
  472. end
  473. end
  474. return true
  475. end
  476. local function matches_keywords(package)
  477. return contains_all_keywords(package.name) or
  478. contains_all_keywords(package.title) or
  479. contains_all_keywords(package.author) or
  480. contains_all_keywords(package.short_description)
  481. end
  482. contentdb.packages = {}
  483. for _, package in pairs(contentdb.packages_full) do
  484. if (query == "" or matches_keywords(package)) and
  485. (by_type == nil or package.type == by_type) then
  486. table.insert(contentdb.packages, package)
  487. end
  488. end
  489. end
  490. function contentdb.get_full_package_info(package, callback)
  491. assert(package)
  492. if package.full_info then
  493. callback(package.full_info)
  494. return
  495. end
  496. local function fetch(params)
  497. local version = core.get_version()
  498. local base_url = core.settings:get("contentdb_url")
  499. local languages
  500. local current_language = core.get_language()
  501. if current_language ~= "" then
  502. languages = { current_language, "en;q=0.8" }
  503. else
  504. languages = { "en" }
  505. end
  506. local url = base_url ..
  507. "/api/packages/" .. params.package.url_part .. "/for-client/?" ..
  508. "protocol_version=" .. core.urlencode(core.get_max_supp_proto()) ..
  509. "&engine_version=" .. core.urlencode(version.string) ..
  510. "&formspec_version=" .. core.urlencode(core.get_formspec_version()) ..
  511. "&include_images=false"
  512. local http = core.get_http_api()
  513. local response = http.fetch_sync({
  514. url = url,
  515. extra_headers = {
  516. "Accept-Language: " .. table.concat(languages, ", ")
  517. },
  518. })
  519. if not response.succeeded then
  520. return nil
  521. end
  522. return core.parse_json(response.data)
  523. end
  524. local function my_callback(value)
  525. package.full_info = value
  526. callback(value)
  527. end
  528. if not core.handle_async(fetch, { package = package }, my_callback) then
  529. core.log("error", "ERROR: async event failed")
  530. callback(nil)
  531. end
  532. end
  533. function contentdb.get_formspec_padding()
  534. -- Padding is increased on Android to account for notches
  535. -- TODO: use Android API to determine size of cut outs
  536. return { x = PLATFORM == "Android" and 1 or 0.5, y = PLATFORM == "Android" and 0.25 or 0.5 }
  537. end
  538. function contentdb.get_formspec_size()
  539. local window = core.get_window_info()
  540. local size = { x = window.max_formspec_size.x, y = window.max_formspec_size.y }
  541. -- Minimum formspec size
  542. local min_x = 15.5
  543. local min_y = 10
  544. if size.x < min_x or size.y < min_y then
  545. local scale = math.max(min_x / size.x, min_y / size.y)
  546. size.x = size.x * scale
  547. size.y = size.y * scale
  548. end
  549. return size
  550. end