workshop_core.sh 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. #!/bin/bash
  2. # LinuxGSM workshop_core.sh module
  3. # Author: Daniel Gibbs
  4. # Contributors: https://linuxgsm.com/contrib
  5. # Website: https://linuxgsm.com
  6. # Description: Downloads and installs Steam Workshop mods for game servers that cannot download them.
  7. moduleselfname="$(basename "$(readlink -f "${BASH_SOURCE[0]}")")"
  8. workshopdir="${serverfiles}/workshop"
  9. workshopdldir="${tmpdir}/workshop"
  10. workshopmodlist="${datadir}/${selfname}-workshop.txt"
  11. workshopapi="https://api.steampowered.com/ISteamRemoteStorage"
  12. workshopapimax="100"
  13. workshopretries="5"
  14. # Converts workshopmods into an ordered list of IDs. Accepts space, comma or semicolon separators.
  15. fn_workshop_config_ids() {
  16. workshopconfigids=()
  17. local id
  18. for id in ${workshopmods//[,;]/ }; do
  19. if [[ "${id}" =~ ^[0-9]+$ ]]; then
  20. workshopconfigids+=("${id}")
  21. else
  22. fn_print_warn_nl "Ignoring invalid Workshop ID: ${id}"
  23. fn_script_log_warn "Ignoring invalid Workshop ID: ${id}"
  24. fi
  25. done
  26. }
  27. # Queries the Steam Web API. Usage: fn_workshop_api <GetCollectionDetails|GetPublishedFileDetails> <id>...
  28. fn_workshop_api() {
  29. local method="$1"
  30. shift
  31. local countparam="itemcount"
  32. if [ "${method}" == "GetCollectionDetails" ]; then
  33. countparam="collectioncount"
  34. fi
  35. local postdata=("--data-urlencode" "${countparam}=$#")
  36. local index=0
  37. local id
  38. for id in "$@"; do
  39. postdata+=("--data-urlencode" "publishedfileids[${index}]=${id}")
  40. index=$((index + 1))
  41. done
  42. curl -sSL --connect-timeout 10 --max-time 60 "${postdata[@]}" "${workshopapi}/${method}/v1/"
  43. }
  44. # Records which IDs are collections and their children, in batches.
  45. fn_workshop_get_collections() {
  46. local ids=("$@")
  47. local start response
  48. for ((start = 0; start < ${#ids[@]}; start += workshopapimax)); do
  49. if ! response="$(fn_workshop_api GetCollectionDetails "${ids[@]:start:workshopapimax}")" || ! jq -e '.response' <<< "${response}" > /dev/null 2>&1; then
  50. fn_print_fail_nl "Checking for update: ${remotelocation}: Unable to get collection details from Steam"
  51. fn_script_log_fail "Checking for update: ${remotelocation}: Unable to get collection details from Steam"
  52. core_exit.sh
  53. fi
  54. local id result children
  55. while IFS=$'\t' read -r id result children; do
  56. workshopchecked["${id}"]=1
  57. if [ "${result}" == "1" ]; then
  58. workshopchildren["${id}"]="${children}"
  59. fi
  60. done < <(jq -r '.response.collectiondetails[] | [.publishedfileid, (.result | tostring), ([.children[]? | "\(.publishedfileid):\(.filetype)"] | join(" "))] | @tsv' <<< "${response}")
  61. done
  62. }
  63. # Adds an ID to workshopitems, expanding collections in their sort order.
  64. fn_workshop_expand() {
  65. local id="$1"
  66. local depth="$2"
  67. if [ -n "${workshopseen[${id}]}" ]; then
  68. return
  69. fi
  70. workshopseen["${id}"]=1
  71. if [ -n "${workshopchildren[${id}]+set}" ]; then
  72. if [ "${depth}" -gt 3 ]; then
  73. fn_print_warn_nl "Skipping Workshop collection ${id}: collections are nested too deeply"
  74. fn_script_log_warn "Skipping Workshop collection ${id}: collections are nested too deeply"
  75. return
  76. fi
  77. local child
  78. for child in ${workshopchildren[${id}]}; do
  79. fn_workshop_expand "${child%%:*}" $((depth + 1))
  80. done
  81. else
  82. workshopitems+=("${id}")
  83. fi
  84. }
  85. # Builds workshopitems: the ordered list of Workshop items to install, with collections expanded.
  86. fn_workshop_resolve() {
  87. declare -gA workshopchecked=()
  88. declare -gA workshopchildren=()
  89. declare -gA workshopseen=()
  90. workshopitems=()
  91. fn_workshop_config_ids
  92. if [ "${#workshopconfigids[@]}" -eq 0 ]; then
  93. return
  94. fi
  95. local tocheck=("${workshopconfigids[@]}")
  96. local round id child
  97. for ((round = 0; round < 4 && ${#tocheck[@]} > 0; round++)); do
  98. fn_workshop_get_collections "${tocheck[@]}"
  99. local nested=()
  100. for id in "${tocheck[@]}"; do
  101. for child in ${workshopchildren[${id}]}; do
  102. if [ "${child##*:}" == "2" ] && [ -z "${workshopchecked[${child%%:*}]}" ]; then
  103. nested+=("${child%%:*}")
  104. fi
  105. done
  106. done
  107. tocheck=("${nested[@]}")
  108. done
  109. for id in "${workshopconfigids[@]}"; do
  110. fn_workshop_expand "${id}" 0
  111. done
  112. }
  113. # Gets title, game appid and last updated time for each item in workshopitems.
  114. fn_workshop_get_details() {
  115. declare -gA workshoptitle=()
  116. declare -gA workshopappid=()
  117. declare -gA workshoptimeupdated=()
  118. local validitems=()
  119. local start response
  120. for ((start = 0; start < ${#workshopitems[@]}; start += workshopapimax)); do
  121. if ! response="$(fn_workshop_api GetPublishedFileDetails "${workshopitems[@]:start:workshopapimax}")" || ! jq -e '.response' <<< "${response}" > /dev/null 2>&1; then
  122. fn_print_fail_nl "Checking for update: ${remotelocation}: Unable to get Workshop item details from Steam"
  123. fn_script_log_fail "Checking for update: ${remotelocation}: Unable to get Workshop item details from Steam"
  124. core_exit.sh
  125. fi
  126. local id result appid timeupdated title
  127. while IFS=$'\t' read -r id result appid timeupdated title; do
  128. if [ "${result}" != "1" ] || [ -z "${timeupdated}" ]; then
  129. fn_print_warn_nl "Skipping Workshop item ${id}: not found, or not public"
  130. fn_script_log_warn "Skipping Workshop item ${id}: not found, or not public"
  131. continue
  132. fi
  133. workshoptitle["${id}"]="${title}"
  134. workshopappid["${id}"]="${appid}"
  135. workshoptimeupdated["${id}"]="${timeupdated}"
  136. done < <(jq -r '.response.publishedfiledetails[] | [.publishedfileid, (.result | tostring), (.consumer_app_id // "" | tostring), (.time_updated // "" | tostring), (.title // "" | gsub("[\t\n\r]"; " "))] | @tsv' <<< "${response}")
  137. done
  138. local item
  139. for item in "${workshopitems[@]}"; do
  140. if [ -n "${workshoptimeupdated[${item}]}" ]; then
  141. validitems+=("${item}")
  142. fi
  143. done
  144. workshopitems=("${validitems[@]}")
  145. }
  146. # Reads a value from an installed mod's state file. Usage: fn_workshop_state <id> <key>
  147. fn_workshop_state() {
  148. local statefile="${workshopdir}/@${1}/.lgsm-workshop"
  149. if [ -f "${statefile}" ]; then
  150. sed -n "s/^${2}=//p" "${statefile}"
  151. fi
  152. }
  153. # Works out which mods need downloading and which installed mods are no longer used.
  154. fn_workshop_compare() {
  155. workshopupdates=()
  156. workshopremovals=()
  157. local item
  158. for item in "${workshopitems[@]}"; do
  159. if [ "$(fn_workshop_state "${item}" timeupdated)" != "${workshoptimeupdated[${item}]}" ]; then
  160. workshopupdates+=("${item}")
  161. fi
  162. done
  163. if [ -d "${workshopdir}" ]; then
  164. # Mods used by any instance sharing these serverfiles are kept.
  165. local inuse=" ${workshopitems[*]} "
  166. local listfile
  167. for listfile in "${datadir}"/*-workshop.txt; do
  168. if [ -f "${listfile}" ] && [ "${listfile}" != "${workshopmodlist}" ]; then
  169. inuse+="$(grep -v '^#' "${listfile}" | tr '\n' ' ') "
  170. fi
  171. done
  172. local moddir
  173. for moddir in "${workshopdir}"/@*; do
  174. item="${moddir##*/@}"
  175. if [ -d "${moddir}" ] && [[ "${inuse}" != *" ${item} "* ]]; then
  176. workshopremovals+=("${item}")
  177. fi
  178. done
  179. fi
  180. }
  181. # Downloads Workshop items with a single SteamCMD login. Failed items are retried.
  182. fn_workshop_download() {
  183. local pending=("$@")
  184. local attempt=0
  185. local item params
  186. steamcmdlog="${lgsmlogdir}/${selfname}-steamcmd.log"
  187. rm -rf "${workshopdldir:?}"
  188. mkdir -p "${workshopdldir}"
  189. if [ -d "${steamcmddir}" ]; then
  190. cd "${steamcmddir}" || exit
  191. fi
  192. unbuffercommand=()
  193. if [ "$(command -v unbuffer 2> /dev/null)" ]; then
  194. unbuffercommand=("unbuffer")
  195. fi
  196. while [ "${#pending[@]}" -gt 0 ] && [ "${attempt}" -lt "${workshopretries}" ]; do
  197. attempt=$((attempt + 1))
  198. params=(+force_install_dir "${workshopdldir}" +login "${steamuser}" "${steampass}")
  199. for item in "${pending[@]}"; do
  200. params+=(+workshop_download_item "${workshopappid[${item}]}" "${item}")
  201. done
  202. params+=(+quit)
  203. rm -f "${steamcmdlog:?}"
  204. "${unbuffercommand[@]}" "${steamcmdcommand}" "${params[@]}" | uniq | tee -a "${lgsmlog}" "${steamcmdlog}"
  205. if grep -q "FAILED (" "${steamcmdlog}" || grep -q "Two-factor code mismatch" "${steamcmdlog}"; then
  206. fn_print_failure_nl "${commandaction} ${selfname}: ${remotelocation}: Steam login failed"
  207. fn_print_nl "Check steamuser and steampass in ${configdirserver}"
  208. fn_script_log_fail "${commandaction} ${selfname}: ${remotelocation}: Steam login failed"
  209. core_exit.sh
  210. fi
  211. local failed=()
  212. for item in "${pending[@]}"; do
  213. if [ -z "$(find "${workshopdldir}/steamapps/workshop/content/${workshopappid[${item}]}/${item}" -mindepth 1 -print -quit 2> /dev/null)" ]; then
  214. failed+=("${item}")
  215. fi
  216. done
  217. pending=("${failed[@]}")
  218. if [ "${#pending[@]}" -gt 0 ] && [ "${attempt}" -lt "${workshopretries}" ]; then
  219. fn_print_error2_nl "${commandaction} ${selfname}: ${remotelocation}: ${#pending[@]} item(s) did not download, retrying"
  220. fn_script_log_error "${commandaction} ${selfname}: ${remotelocation}: ${#pending[@]} item(s) did not download, retrying"
  221. fi
  222. done
  223. workshopfailed=("${pending[@]}")
  224. if [ "${#workshopfailed[@]}" -gt 0 ]; then
  225. fn_print_failure_nl "${commandaction} ${selfname}: ${remotelocation}: ${#workshopfailed[@]} item(s) failed to download"
  226. for item in "${workshopfailed[@]}"; do
  227. fn_print_nl "* ${workshoptitle[${item}]} (${item})"
  228. fn_script_log_fail "${commandaction} ${selfname}: ${remotelocation}: ${workshoptitle[${item}]} (${item}) failed to download"
  229. done
  230. fn_print_nl "The Steam account must own the game for its Workshop items to download"
  231. fi
  232. }
  233. # Renames files and directories in a mod to lowercase, deepest first.
  234. fn_workshop_lowercase() {
  235. local path name lower
  236. while IFS= read -r -d '' path; do
  237. name="${path##*/}"
  238. lower="${name,,}"
  239. if [ -e "${path%/*}/${lower}" ]; then
  240. fn_script_log_warn "Lowercase name already exists, not renaming: ${path}"
  241. else
  242. mv "${path}" "${path%/*}/${lower}"
  243. fi
  244. done < <(find "$1" -mindepth 1 -depth -name '*[[:upper:]]*' -print0)
  245. }
  246. # Removes the BattlEye keys a mod installed, unless another installed mod also uses them.
  247. fn_workshop_remove_keys() {
  248. local item="$1"
  249. local key moddir
  250. for key in $(fn_workshop_state "${item}" keys); do
  251. local shared=""
  252. for moddir in "${workshopdir}"/@*; do
  253. if [ "${moddir}" != "${workshopdir}/@${item}" ] && [[ " $(fn_workshop_state "${moddir##*/@}" keys) " == *" ${key} "* ]]; then
  254. shared=1
  255. fi
  256. done
  257. if [ -z "${shared}" ]; then
  258. rm -f "${serverfiles:?}/keys/${key}"
  259. fi
  260. done
  261. }
  262. # Moves a downloaded item into serverfiles/workshop/@<id> and prepares it for the game.
  263. fn_workshop_install() {
  264. local item="$1"
  265. local src="${workshopdldir}/steamapps/workshop/content/${workshopappid[${item}]}/${item}"
  266. local dst="${workshopdir}/@${item}"
  267. local keys=()
  268. local key
  269. fn_print_dots "Installing ${workshoptitle[${item}]} (${item})"
  270. if [ -d "${dst}" ]; then
  271. fn_workshop_remove_keys "${item}"
  272. rm -rf "${dst:?}"
  273. fi
  274. mkdir -p "${workshopdir}"
  275. if ! mv "${src}" "${dst}"; then
  276. fn_print_fail_nl "Installing ${workshoptitle[${item}]} (${item})"
  277. fn_script_log_fail "Installing ${workshoptitle[${item}]} (${item})"
  278. core_exit.sh
  279. fi
  280. if [ "${shortname}" == "arma3" ] || [ "${shortname}" == "dayz" ]; then
  281. fn_workshop_lowercase "${dst}"
  282. mkdir -p "${serverfiles}/keys"
  283. while IFS= read -r -d '' key; do
  284. cp -f "${key}" "${serverfiles}/keys/"
  285. keys+=("${key##*/}")
  286. done < <(find "${dst}" -type f -name '*.bikey' -print0)
  287. fi
  288. {
  289. echo "title=${workshoptitle[${item}]}"
  290. echo "timeupdated=${workshoptimeupdated[${item}]}"
  291. echo "keys=${keys[*]}"
  292. } > "${dst}/.lgsm-workshop"
  293. fn_print_ok_nl "Installing ${workshoptitle[${item}]} (${item})"
  294. fn_script_log_pass "Installing ${workshoptitle[${item}]} (${item})"
  295. }
  296. # Removes an installed mod that is no longer in workshopmods.
  297. fn_workshop_remove() {
  298. local item="$1"
  299. local title
  300. title="$(fn_workshop_state "${item}" title)"
  301. fn_print_dots "Removing ${title} (${item})"
  302. fn_workshop_remove_keys "${item}"
  303. rm -rf "${workshopdir:?}/@${item}"
  304. fn_print_ok_nl "Removing ${title} (${item})"
  305. fn_script_log_pass "Removing ${title} (${item})"
  306. }
  307. # Installs downloaded items and removes unused ones. Downloads happen first, so the server only stops for this step.
  308. fn_workshop_apply() {
  309. local item
  310. for item in "${workshopinstalls[@]}"; do
  311. fn_workshop_install "${item}"
  312. done
  313. for item in "${workshopremovals[@]}"; do
  314. fn_workshop_remove "${item}"
  315. done
  316. rm -rf "${workshopdldir:?}"
  317. }
  318. # Saves the resolved mod order so start can build the mod list without querying Steam.
  319. fn_workshop_save_list() {
  320. {
  321. echo "# workshopmods=${workshopmods}"
  322. printf '%s\n' "${workshopitems[@]}"
  323. } > "${workshopmodlist}"
  324. }
  325. # Checks for Workshop mod updates and, for update commands, applies them.
  326. fn_workshop_update() {
  327. remotelocation="Steam Workshop"
  328. fn_print_dots "Checking for update: ${remotelocation}"
  329. fn_workshop_resolve
  330. if [ "${#workshopitems[@]}" -gt 0 ]; then
  331. fn_workshop_get_details
  332. fi
  333. fn_workshop_compare
  334. fn_print_ok_nl "Checking for update: ${remotelocation}"
  335. fn_script_log_info "Checking for update: ${remotelocation}"
  336. local item
  337. if [ "${#workshopupdates[@]}" -eq 0 ] && [ "${#workshopremovals[@]}" -eq 0 ]; then
  338. fn_print "\n"
  339. fn_print_nl "${bold}${underline}No update${default} available"
  340. fn_print_nl "* Workshop mods: ${#workshopitems[@]}"
  341. fn_print "\n"
  342. fn_script_log_info "No update available: ${remotelocation}"
  343. if [ "${commandname}" != "CHECK-UPDATE" ]; then
  344. fn_workshop_save_list
  345. fi
  346. return
  347. fi
  348. fn_print "\n"
  349. fn_print_nl "${bold}${underline}Update${default} available"
  350. for item in "${workshopupdates[@]}"; do
  351. if [ -d "${workshopdir}/@${item}" ]; then
  352. fn_print_nl "* Update: ${workshoptitle[${item}]} (${item})"
  353. else
  354. fn_print_nl "* Install: ${workshoptitle[${item}]} (${item})"
  355. fi
  356. fn_script_log_info "Update available: ${workshoptitle[${item}]} (${item})"
  357. done
  358. for item in "${workshopremovals[@]}"; do
  359. fn_print_nl "* Remove: $(fn_workshop_state "${item}" title) (${item})"
  360. fn_script_log_info "Remove: $(fn_workshop_state "${item}" title) (${item})"
  361. done
  362. fn_print "\n"
  363. if [ "${commandname}" == "CHECK-UPDATE" ]; then
  364. return
  365. fi
  366. fn_workshop_save_list
  367. date '+%s' > "${lockdir:?}/update.lock"
  368. workshopinstalls=()
  369. workshopfailed=()
  370. if [ "${#workshopupdates[@]}" -gt 0 ]; then
  371. fn_print_start_nl "${remotelocation}"
  372. fn_workshop_download "${workshopupdates[@]}"
  373. for item in "${workshopupdates[@]}"; do
  374. if [[ " ${workshopfailed[*]} " != *" ${item} "* ]]; then
  375. workshopinstalls+=("${item}")
  376. fi
  377. done
  378. fi
  379. if [ "${#workshopinstalls[@]}" -eq 0 ] && [ "${#workshopremovals[@]}" -eq 0 ]; then
  380. rm -rf "${workshopdldir:?}"
  381. return
  382. fi
  383. check_status.sh
  384. if [ "${status}" == "0" ]; then
  385. fn_workshop_apply
  386. else
  387. fn_print_restart_warning
  388. exitbypass=1
  389. command_stop.sh
  390. fn_firstcommand_reset
  391. fn_workshop_apply
  392. exitbypass=1
  393. command_start.sh
  394. fn_firstcommand_reset
  395. fi
  396. unset exitbypass
  397. }
  398. # Adds installed Workshop mods to the mods start parameter, in workshopmods order.
  399. fn_workshop_mods_startparameters() {
  400. if [ -n "${workshopmodsadded}" ]; then
  401. return
  402. fi
  403. workshopmodsadded=1
  404. local items=()
  405. if [ -f "${workshopmodlist}" ]; then
  406. mapfile -t items < <(grep -v '^#' "${workshopmodlist}")
  407. if [ "$(sed -n 's/^# workshopmods=//p' "${workshopmodlist}")" != "${workshopmods}" ]; then
  408. fn_print_warn_nl "workshopmods has changed. Run ./${selfname} workshop-update to apply it"
  409. fn_script_log_warn "workshopmods has changed. Run ./${selfname} workshop-update to apply it"
  410. fi
  411. else
  412. fn_workshop_config_ids
  413. items=("${workshopconfigids[@]}")
  414. fi
  415. local workshopmodsparam=""
  416. local missing=0
  417. local item
  418. for item in "${items[@]}"; do
  419. if [ -d "${workshopdir}/@${item}" ]; then
  420. workshopmodsparam+="${workshopmodsparam:+\\;}workshop/@${item}"
  421. else
  422. missing=$((missing + 1))
  423. fi
  424. done
  425. if [ "${missing}" -gt 0 ]; then
  426. fn_print_warn_nl "${missing} Workshop mod(s) not installed. Run ./${selfname} workshop-update to install them"
  427. fn_script_log_warn "${missing} Workshop mod(s) not installed"
  428. fi
  429. if [ -n "${workshopmodsparam}" ]; then
  430. mods="${mods:+${mods}\\;}${workshopmodsparam}"
  431. fi
  432. }