|
#!/bin/bash |
|
set -o errexit -o pipefail -o noclobber -o nounset |
|
|
|
# option --output/-o requires 1 argument |
|
LONGOPTS=help,verbose,no-dry-run,dry-run,mod-files:,key-files:,steam-cache:,version-file:,mod-ids:,game-id:,force-download |
|
SHORTOPTS=dv |
|
DEFAULTS='{ |
|
"dryRun": true, |
|
"noDryRun": false, |
|
"verbose": false, |
|
"force_download": false, |
|
"steam_cache": "~/.steam/" |
|
}' |
|
OPTIONS="${DEFAULTS}" |
|
|
|
|
|
# |
|
# Ui |
|
# |
|
|
|
colour() { |
|
local colour |
|
local message |
|
|
|
colour="$1" |
|
message="$2" |
|
|
|
case "$colour" in |
|
"red") |
|
printf "\033[0;31m %s \033[0m \n" "$message" |
|
;; |
|
"green") |
|
printf "\033[0;32m %s \033[0m \n" "$message" |
|
;; |
|
"yellow") |
|
printf "\033[0;33m %s \033[0m \n" "$message" |
|
;; |
|
"cyan") |
|
printf "\033[0;36m %s \033[0m \n" "$message" |
|
;; |
|
*) |
|
printf "%s" "$message" |
|
;; |
|
esac |
|
} |
|
|
|
log() { |
|
local level |
|
local message |
|
|
|
level="$1" |
|
message="$2" |
|
|
|
case "$level" in |
|
"error") |
|
echo "🧟 🔥 $(colour red "${message}")" |
|
;; |
|
"success") |
|
# print message in green text color |
|
echo "🧟 🟢 $(colour green "${message}")" |
|
;; |
|
"warn") |
|
echo "🧟 ⛈️ $(colour yellow "${message}")" |
|
;; |
|
*) |
|
echo "🧟 $(colour cyan "${message}")" |
|
;; |
|
|
|
esac |
|
} |
|
|
|
info() { |
|
echo "" |
|
log info "$@" |
|
echo "" |
|
} |
|
|
|
warn() { |
|
echo "" |
|
log warn "$@" |
|
echo "" |
|
} |
|
|
|
success() { |
|
echo "" |
|
log success "$@" |
|
echo "" |
|
} |
|
|
|
error() { |
|
echo "" |
|
log error "$@" |
|
echo "" |
|
} |
|
|
|
# |
|
# Config |
|
# |
|
|
|
json_set() { |
|
local json |
|
local key |
|
local value |
|
local type |
|
|
|
json="$1" |
|
key="$2" |
|
value="$3" |
|
type="${4:-string}" |
|
|
|
case "$type" in |
|
"bool" | "number" | "array" | "object") |
|
jq --arg key "$key" --argjson value "$value" ".$key = \$value" <<<"$json" |
|
;; |
|
*) |
|
jq --arg key "$key" --arg value "$value" ".$key = \$value" <<<"$json" |
|
;; |
|
esac |
|
} |
|
|
|
json_get() { |
|
local json |
|
local key |
|
local default |
|
|
|
json="$1" |
|
key="$2" |
|
default="${3:-}" |
|
value=$(jq -r ".$key" <<<"$json") |
|
|
|
# if value is null, return the default |
|
if [[ "$value" == "null" ]]; then |
|
echo "$default" |
|
else |
|
echo "$value" |
|
fi |
|
} |
|
|
|
try_grep() { grep "$@" || test $? = 1; } |
|
|
|
get_option() { |
|
json_get "$OPTIONS" "$1" |
|
} |
|
|
|
set_option() { |
|
OPTIONS=$(json_set "$OPTIONS" "$1" "$2") |
|
} |
|
|
|
parse_options() { |
|
# ignore errexit with `&& true` |
|
getopt --test >/dev/null && true |
|
|
|
if [[ $? -ne 4 ]]; then |
|
echo "I'm sorry, 'getopt --test' failed in this environment." |
|
exit 1 |
|
fi |
|
|
|
# -temporarily store output to be able to check for errors |
|
# -activate quoting/enhanced mode (e.g. by writing out “--options”) |
|
# -pass arguments only via -- "$@" to separate them correctly |
|
# -if getopt fails, it complains itself to stdout |
|
PARSED=$(getopt --options=$SHORTOPTS --longoptions=$LONGOPTS --name "$0" -- "$@") || exit 2 |
|
# read getopt’s output this way to handle the quoting right: |
|
eval set -- "$PARSED" |
|
|
|
# PARSED is a space separated string of the form --key value --key value |
|
# turn it into an array of the form (--key value, --key value) |
|
# now enjoy the options in order and nicely split until we see -- |
|
while true; do |
|
case "$1" in |
|
--no-dry-run) |
|
OPTIONS=$(json_set "$OPTIONS" "dryRun" false bool) |
|
shift |
|
;; |
|
-d | --dry-run) |
|
OPTIONS=$(json_set "$OPTIONS" "dryRun" true bool) |
|
shift |
|
;; |
|
-v | --verbose) |
|
OPTIONS=$(json_set "$OPTIONS" "verbose" true bool) |
|
shift |
|
;; |
|
--help) |
|
exit 0 |
|
;; |
|
--steam-cache) |
|
OPTIONS=$(json_set "$OPTIONS" "steam_cache" "$2") |
|
shift 2 |
|
;; |
|
--version-file) |
|
OPTIONS=$(json_set "$OPTIONS" "version_file" "$2") |
|
shift 2 |
|
;; |
|
--mod-files) |
|
OPTIONS=$(json_set "$OPTIONS" "mod_files" "$2") |
|
shift 2 |
|
;; |
|
--key-files) |
|
OPTIONS=$(json_set "$OPTIONS" "key_files" "$2") |
|
shift 2 |
|
;; |
|
--mod-ids) |
|
OPTIONS=$(json_set "$OPTIONS" "mod_ids" "$2" "array") |
|
shift 2 |
|
;; |
|
--game-id) |
|
OPTIONS=$(json_set "$OPTIONS" "game_id" "$2") |
|
shift 2 |
|
;; |
|
--force-download) |
|
OPTIONS=$(json_set "$OPTIONS" "force_download" true bool) |
|
shift |
|
;; |
|
--) |
|
shift |
|
break |
|
;; |
|
*) |
|
echo "Programming error" |
|
echo "($1)" |
|
|
|
exit 3 |
|
;; |
|
esac |
|
done |
|
|
|
} |
|
|
|
|
|
# |
|
# OS Functions |
|
# |
|
|
|
# Check if a command is installed |
|
does_command_exist() { |
|
command -v "$1" &>/dev/null |
|
} |
|
|
|
# Check if a command is installed |
|
requires_arg() { |
|
local arg_name |
|
local arg_value |
|
|
|
arg_name="$1" |
|
arg_value="$2" |
|
|
|
if [ -z "$arg_value" ]; then |
|
error "$arg_name is required." |
|
exit 1 |
|
fi |
|
|
|
} |
|
|
|
# Check if an option is set |
|
requires_option() { |
|
local option_name |
|
local option_value |
|
|
|
option_name="$1" |
|
option_value=$(get_option "$option_name") |
|
|
|
if [ -z "$option_value" ]; then |
|
error "$option_name is required." |
|
exit 1 |
|
fi |
|
} |
|
|
|
|
|
# Check if a command is installed |
|
requires_command() { |
|
local input |
|
local name |
|
local pattern |
|
|
|
|
|
# accept from stdin but don't expect it |
|
name="$1" |
|
pattern="${2:-""}" |
|
input="${3:-""}" |
|
|
|
info "Checking for command '$name'." |
|
|
|
# first use command to test if the command exists |
|
command -v "$name" > /dev/null || { |
|
error "Command '$name' not found. Please install it." |
|
exit 1 |
|
} |
|
|
|
info "Command '$name' found." |
|
|
|
# if not input, then we're good |
|
[ -z "$input" ] && return 0 |
|
|
|
# then use grep to test the output |
|
echo "$input" | grep -q "$pattern" || { |
|
error "Command '$name' exists, but didn't match expected output." |
|
exit 1 |
|
} |
|
|
|
info "Command '$name' output matched expected pattern." |
|
|
|
} |
|
|
|
# Check if an environment variable is set |
|
requires_env(){ |
|
local env_var |
|
|
|
env_var="$1" |
|
|
|
if [ -z "${!env_var}" ]; then |
|
error "$env_var is required but not set." |
|
exit 1 |
|
fi |
|
} |
|
|
|
# |
|
# Workshop Functions |
|
# |
|
|
|
# get the details of a collection |
|
get_collection() { |
|
local collection_id |
|
|
|
collection_id="$1" |
|
|
|
curl -s "https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/" \ |
|
-d "key=${STEAM_API_KEY}&collectioncount=1&publishedfileids[0]=${collection_id}" |
|
} |
|
|
|
# get the mod IDs from a collection |
|
get_collection_mods() { |
|
local collection_id |
|
|
|
collection_id="$1" |
|
|
|
collection_response=$(get_collection "${collection_id}") |
|
|
|
# Check response |
|
result=$(jq -r '.response.collectiondetails[0].result' <<< "${collection_response}") |
|
if [ "${result}" != "1" ]; then |
|
error "fetching collection details: ${collection_response}" |
|
exit 1 |
|
fi |
|
|
|
# Extract mod IDs from the collection |
|
echo "${collection_response}" | jq -r '.response.collectiondetails[0].children[].publishedfileid' |
|
} |
|
|
|
# get the current version of a mod |
|
read_local_mod_version() { |
|
local version_file |
|
local mod_id |
|
|
|
requires_option "version_file" |
|
|
|
version_file=$(get_option "version_file") |
|
|
|
mod_id="$1" |
|
|
|
if [ -f "$version_file" ]; then |
|
jq -r ".$mod_id" "$version_file" |
|
else |
|
echo "0" |
|
fi |
|
} |
|
# write the current version of a mod |
|
write_local_mod_version() { |
|
local version_file |
|
local mod_id |
|
local mod_version |
|
|
|
requires_option "version_file" |
|
|
|
version_file=$(get_option "version_file") |
|
mod_id="$1" |
|
mod_version="$2" |
|
|
|
# if the version file doesn't exist, create it |
|
if [ ! -f "$version_file" ]; then |
|
echo "{}" > "$version_file" |
|
fi |
|
|
|
# write the mod and version using jq |
|
jq \ |
|
--arg mod_id "$mod_id" \ |
|
--arg mod_version "$mod_version" \ |
|
'. + {($mod_id): $mod_version}' \ |
|
"$version_file" > "$version_file.tmp" |
|
|
|
mv "$version_file.tmp" "$version_file" |
|
} |
|
|
|
# Extract keys from the mod directory and copy them to the key_files directory |
|
extract_keys() { |
|
local mod_path |
|
local keys_path |
|
|
|
mod_path="$1" |
|
|
|
key_files=$(get_option "key_files") |
|
|
|
# Check for 'keys' and 'Keys' directories |
|
if [ -d "$mod_path/keys" ]; then |
|
keys_path="$mod_path/keys" |
|
elif [ -d "$mod_path/Keys" ]; then |
|
keys_path="$mod_path/Keys" |
|
else |
|
warning "No keys directory found in $mod_path" |
|
return |
|
fi |
|
|
|
# Copy contents of keys to KEYS_DEST_DIR |
|
info "Copying keys from $keys_path to $key_files" |
|
cp -r "$keys_path"/* "$key_files" |
|
} |
|
|
|
# get the details of a mod |
|
get_mod_details() { |
|
local mod_id |
|
|
|
mod_id="$1" |
|
|
|
mod_details=$( |
|
curl -s "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/" \ |
|
-d "itemcount=1&publishedfileids[0]=$mod_id&key=${STEAM_API_KEY}" |
|
) |
|
|
|
mod_name=$(jq -r '.response.publishedfiledetails[0].title' <<< "$mod_details") |
|
mod_updated=$(jq -r '.response.publishedfiledetails[0].time_updated' <<< "$mod_details") |
|
|
|
# create empty JQ object |
|
output=$(jq -n "{}") |
|
|
|
# add mod_id, mod_name, and mod_updated to the object |
|
output=$(jq --arg mod_id "$mod_id" '. + {mod_id: $mod_id}' <<< "$output") |
|
output=$(jq --arg mod_name "$mod_name" '. + {mod_name: $mod_name}' <<< "$output") |
|
output=$(jq --arg mod_updated "$mod_updated" '. + {mod_updated: $mod_updated}' <<< "$output") |
|
|
|
# output object on one line |
|
echo "$output" | jq -c |
|
} |
|
|
|
# prepare a mod for installation |
|
prepare_mod() { |
|
local game_id |
|
local mod_id |
|
local mod_name |
|
|
|
mod_id="$1" |
|
mod_name="$2" |
|
|
|
game_id=$(get_option "game_id") |
|
mod_files=$(get_option "mod_files") |
|
download_cache=$(get_option "steam_cache") |
|
|
|
mod_path="$download_cache/steamapps/workshop/content/$game_id/$mod_id" |
|
mod_version="$(read_local_mod_version "$mod_id")" |
|
|
|
|
|
# if the mod_path doesn't exist, it wasn't downloaded, so skip |
|
if [ ! -d "$mod_path" ]; then |
|
warn "Skipping mod @$mod_name [$mod_id] as it wasn't downloaded." |
|
warn "Expected path: $mod_path" |
|
return 0 |
|
fi |
|
|
|
info "Preparing mod @$mod_name [$mod_id] from $mod_path." |
|
|
|
target_path="$mod_files/@$mod_name" |
|
# remove the target path if it exists |
|
rm -rf "$target_path" |
|
# ensure the parent of the target path exists |
|
mkdir -p "$(dirname "$target_path")" |
|
# copy the mod to the target path |
|
cp -r "$mod_path" "$target_path" |
|
|
|
extract_keys "$mod_path" |
|
write_local_mod_version "$mod_id" "$mod_updated" |
|
|
|
success "Mod @$mod_name [$mod_id] prepared." |
|
} |
|
|
|
# prepare a list of mods for installation |
|
prepare_mods() { |
|
local game_id |
|
local mod_ids |
|
|
|
mod_ids="$1" # space separated list of mod IDs |
|
mod_count=$(echo "$mod_ids" | wc -w) |
|
|
|
info "Preparing $mod_count mods." |
|
|
|
for mod_id in $mod_ids; do |
|
mod_details=$(get_mod_details "$mod_id") |
|
mod_name=$(json_get "$mod_details" "mod_name") |
|
mod_updated=$(json_get "$mod_details" "mod_updated") |
|
|
|
info "Preparing mod: $mod_name (ID: $mod_id, Last Updated: $mod_updated)" >&2 |
|
|
|
prepare_mod "$mod_id" "$mod_name" |
|
done |
|
} |
|
|
|
# create a download queue for a list of mods |
|
download_mod() { |
|
local game_id |
|
local mod_id |
|
|
|
mod_id="$1" |
|
force_download=$(get_option "force_download") |
|
game_id=$(get_option "game_id") |
|
download_cache=$(get_option "steam_cache") |
|
|
|
mod_details=$(get_mod_details "$mod_id") |
|
mod_version=$(read_local_mod_version "$mod_id") |
|
mod_name=$(json_get "$mod_details" "mod_name") |
|
mod_updated=$(json_get "$mod_details" "mod_updated") |
|
|
|
# if the mod timestamp isn't newer or we're not forcing all mods, skip |
|
if [ "$mod_updated" = "$mod_version" ] && [ "$force_download" = false ]; then |
|
warn "Skipping mod @$mod_name [$mod_id] as it's up to date." |
|
return 0 |
|
fi |
|
|
|
# more steamcmd vars and actions here: https://github.com/dgibbs64/SteamCMD-Commands-List/blob/main/steamcmd_commands.txt |
|
directives=() |
|
directives=("@csecCSJobSuccessfulRequestTimeWindow = \"60\"") |
|
directives=("@csecCSRequestProcessorTimeOut = \"1200000\"") |
|
directives=("@nClientDownloadEnableHTTP2PlatformLinux") |
|
directives=("@DepotDownloadProgressTimeout = \"120000\"") |
|
directives+=("+force_install_dir $download_cache") |
|
directives+=("+login anonymous") |
|
directives+=("+workshop_download_item $game_id $mod_id validate") |
|
directives+=("+quit") |
|
|
|
# steamcmd is shit. it still fails when the download takes too long. |
|
# there's no way to override this timeout. |
|
# so our only option is to lmao... keep trying with +validate. |
|
# Here, we initially hope it works in one go. But if it fails, |
|
# we keep trying until it stops being shit. |
|
until steamcmd "${directives[@]}"; do |
|
warn "SteamCMD failed, retrying in 1 seconds..." |
|
sleep 1 |
|
done |
|
|
|
} |
|
|
|
# download a list of mods |
|
download_mods() { |
|
local mod_ids |
|
|
|
mod_ids="$1" |
|
|
|
mod_count=$(echo "$mod_ids" | wc -w) |
|
|
|
info "Downloading $mod_count mods." |
|
for mod_id in $mod_ids; do |
|
download_mod "$mod_id" |
|
done |
|
|
|
success "Mods downloaded." |
|
} |
|
|
|
|
|
parse_options "${@:2}" |
|
|
|
requires_command 'jq' 'jq-1\.[0-9]\+' "$(jq --version)" |
|
requires_command "steamcmd" |
|
|
|
requires_env "STEAM_API_KEY" |
|
|
|
case "$1" in |
|
collection) |
|
requires_option "version_file" |
|
requires_option "mod_files" |
|
requires_option "key_files" |
|
requires_option "game_id" |
|
|
|
requires_arg "[1] collection id" "$2" |
|
|
|
collection_id="$2" |
|
mod_ids="$(get_collection_mods "$collection_id")" |
|
|
|
download_mods "$mod_ids" |
|
prepare_mods "$mod_ids" |
|
;; |
|
mods) |
|
requires_option "version_file" |
|
requires_option "mod_files" |
|
requires_option "key_files" |
|
requires_option "game_id" |
|
|
|
requires_arg "[1] mod ids" "$2" |
|
|
|
mod_ids="$2" |
|
download_mods "$mod_ids" |
|
;; |
|
prepare) |
|
requires_option "version_file" |
|
requires_option "mod_files" |
|
requires_option "key_files" |
|
requires_option "game_id" |
|
|
|
requires_arg "[1] mod ids" "$2" |
|
|
|
mod_ids="$2" |
|
|
|
prepare_mods "$mod_ids" |
|
;; |
|
collection-prepare) |
|
requires_option "version_file" |
|
requires_option "mod_files" |
|
requires_option "key_files" |
|
requires_option "game_id" |
|
|
|
requires_arg "[1] collection id" "$2" |
|
|
|
collection_id="$2" |
|
mod_ids="$(get_collection_mods "$collection_id")" |
|
|
|
prepare_mods "$mod_ids" |
|
;; |
|
options) |
|
echo "$OPTIONS" | jq -r |
|
;; |
|
*) |
|
info "Usage: $0 {mods|collection|prepare}" |
|
|
|
info "Options:" |
|
info " --server-files (default: $(json_get "$DEFAULTS" "server_files"))" |
|
info " --mod-ids (default: $(json_get "$DEFAULTS" "mod_ids"))" |
|
info " --game-id (default: $(json_get "$DEFAULTS" "game_id"))" |
|
info " --force-download (default: $(json_get "$DEFAULTS" "force_download"))" |
|
|
|
info "Commands:" |
|
info " mods Download mods" |
|
info " collection Download mods from a collection" |
|
info " prepare Prepare mods" |
|
|
|
exit 1 |
|
;; |
|
esac |
|
|