Skip to content

Instantly share code, notes, and snippets.

@airtonix
Last active August 10, 2024 07:36
Show Gist options
  • Save airtonix/e7e6935d1ec34f8f5fbc08d8c97e8fdb to your computer and use it in GitHub Desktop.
Save airtonix/e7e6935d1ec34f8f5fbc08d8c97e8fdb to your computer and use it in GitHub Desktop.
Steam Workshop Mod Manager for Linux Servers

Steam Mod Manager for Linux Servers

uses steamcmd. Yes we know. it sucks. cry me a river.

  • you need jq and steamcmd

Usage

To download mods in a collection

STEAM_API_KEY=abc123 \
    ./workshop.sh collection \
    "$YOUR_COLLECTION_ID" \
    --mod-files "$PWD/dayz/one/serverfiles/mods" \
    --key-files "$PWD/dayz/one/serverfiles/keys" \
    --version-file "$PWD/dayz/one/serverfiles/mod-versions.json" \
    --steam-cache "$PWD/.steamcache" \
    --game-id 221100 

To download your own list of mods

STEAM_API_KEY=abc123 \
    ./workshop.sh mods \
        "YOUR SPACE SEPARATED LIST OF MOD IDS" \
        --mod-files "$PWD/dayz/one/serverfiles/mods" \
        --key-files "$PWD/dayz/one/serverfiles/keys" \
        --version-file "$PWD/dayz/one/serverfiles/mod-versions.json" \
        --steam-cache "$PWD/.steamcache" \
        --game-id 221100 
#!/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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment