redo of steam workshop

This commit is contained in:
Frank Harris 2026-06-06 18:18:40 -05:00
parent e662415f36
commit e6541370b9
19 changed files with 610 additions and 38 deletions

View file

@ -56,6 +56,10 @@ define('LANG_server_content_notify_updates_only', 'Notify Updates Only');
define('LANG_server_content_update_all', 'Update All Server Content');
define('LANG_server_content_validate_files', 'Validate Server Content Files');
define('LANG_server_content_backup_before_update', 'Backup Before Server Content Update');
define('LANG_workshop_update', 'Update Workshop Items');
define('LANG_workshop_update_and_restart', 'Update Workshop Items And Restart');
define('LANG_workshop_download_only', 'Download Workshop Items Only');
define('LANG_workshop_install_pending_on_restart', 'Install Pending Workshop Items On Restart');
define('LANG_safe_restart', 'Safe Restart');
define('LANG_workshop_content', 'Workshop Content');
define('LANG_server_content', 'Server Content');

View file

@ -153,13 +153,15 @@ try:
workshop_app_id = str(extra.get('workshop_app_id') or manifest.get('workshop_app_id') or '').strip()
steam_app_id = str(extra.get('steam_app_id') or manifest.get('steam_app_id') or '').strip()
server_root = ensure_under_home(extra.get('server_root') or home_root)
steamcmd_path = ''
if action in ('install', 'update', 'check_updates', 'download_only', 'validate_files'):
steamcmd_path = resolve_steamcmd(extra.get('steamcmd_path') or '')
post_install_script = str(extra.get('post_install_script') or '').strip()
item_details = manifest.get('item_details') or extra.get('item_details') or {}
default_install_strategy = str(manifest.get('install_strategy') or extra.get('install_strategy') or 'copy_to_mod_folder').strip()
default_download_dir = extra.get('workshop_download_dir') or os.path.join(server_root, 'steamapps', 'workshop', 'content', workshop_app_id or steam_app_id)
action_label = 'Queued' if action in ('install', 'update', 'check_updates') else action
action_label = 'Queued' if action in ('install', 'update', 'check_updates', 'download_only', 'validate_files') else action
log(f"action={action} manifest={manifest_path} steam_app_id={steam_app_id or 'n/a'} workshop_app_id={workshop_app_id or 'n/a'}", action_label)
for workshop_id in items:
@ -187,7 +189,7 @@ try:
download_dir = ensure_under_home(render_template(default_download_dir, template_values))
source_path = os.path.join(download_dir, workshop_id)
if action in ('install', 'update', 'check_updates'):
if action in ('install', 'update', 'check_updates', 'download_only', 'validate_files'):
if not workshop_app_id:
fail(f"Workshop App ID is missing for Workshop item {workshop_id}.")
command = [
@ -207,7 +209,7 @@ try:
if not os.path.isdir(source_path):
fail(f"SteamCMD did not create the expected Workshop cache path: {source_path}")
if action != 'check_updates':
if action not in ('check_updates', 'download_only', 'validate_files'):
log(f"workshop_id={workshop_id} strategy={install_strategy} install_path={target_path}", 'Extracting/Copying')
sync_copy(source_path, target_path)
log(f"workshop_id={workshop_id} final_folder_path={target_path}", 'Applying Folder Name')
@ -221,6 +223,10 @@ try:
log(f"post_install[{workshop_id}] {line}")
if post_result.returncode != 0:
fail(f"Post-install script failed for Workshop item {workshop_id} with exit code {post_result.returncode}.")
elif action == 'download_only':
log(f"workshop_id={workshop_id} cached_path={source_path}", 'Downloaded Only')
elif action == 'validate_files':
log(f"workshop_id={workshop_id} cached_path={source_path}", 'Validated')
log(f"workshop_id={workshop_id} install_path={target_path}", 'Completed')
elif action == 'remove':
if os.path.exists(target_path):

View file

@ -154,13 +154,15 @@ try:
workshop_app_id = str(extra.get('workshop_app_id') or manifest.get('workshop_app_id') or '').strip()
steam_app_id = str(extra.get('steam_app_id') or manifest.get('steam_app_id') or '').strip()
server_root = ensure_under_home(extra.get('server_root') or home_root)
steamcmd_path = ''
if action in ('install', 'update', 'check_updates', 'download_only', 'validate_files'):
steamcmd_path = resolve_steamcmd(extra.get('steamcmd_path') or '')
post_install_script = str(extra.get('post_install_script') or '').strip()
item_details = manifest.get('item_details') or extra.get('item_details') or {}
default_install_strategy = str(manifest.get('install_strategy') or extra.get('install_strategy') or 'copy_to_mod_folder').strip()
default_download_dir = extra.get('workshop_download_dir') or os.path.join(server_root, 'steamapps', 'workshop', 'content', workshop_app_id or steam_app_id)
action_label = 'Queued' if action in ('install', 'update', 'check_updates') else action
action_label = 'Queued' if action in ('install', 'update', 'check_updates', 'download_only', 'validate_files') else action
log(f"action={action} manifest={manifest_path} steam_app_id={steam_app_id or 'n/a'} workshop_app_id={workshop_app_id or 'n/a'}", action_label)
for workshop_id in items:
@ -188,7 +190,7 @@ try:
download_dir = ensure_under_home(render_template(default_download_dir, template_values))
source_path = os.path.join(download_dir, workshop_id)
if action in ('install', 'update', 'check_updates'):
if action in ('install', 'update', 'check_updates', 'download_only', 'validate_files'):
if not workshop_app_id:
fail(f"Workshop App ID is missing for Workshop item {workshop_id}.")
command = [
@ -208,7 +210,7 @@ try:
if not os.path.isdir(source_path):
fail(f"SteamCMD did not create the expected Workshop cache path: {source_path}")
if action != 'check_updates':
if action not in ('check_updates', 'download_only', 'validate_files'):
log(f"workshop_id={workshop_id} strategy={install_strategy} install_path={target_path}", 'Extracting/Copying')
sync_copy(source_path, target_path)
log(f"workshop_id={workshop_id} final_folder_path={target_path}", 'Applying Folder Name')
@ -222,6 +224,10 @@ try:
log(f"post_install[{workshop_id}] {line}")
if post_result.returncode != 0:
fail(f"Post-install script failed for Workshop item {workshop_id} with exit code {post_result.returncode}.")
elif action == 'download_only':
log(f"workshop_id={workshop_id} cached_path={source_path}", 'Downloaded Only')
elif action == 'validate_files':
log(f"workshop_id={workshop_id} cached_path={source_path}", 'Validated')
log(f"workshop_id={workshop_id} install_path={target_path}", 'Completed')
elif action == 'remove':
if os.path.exists(target_path):

View file

@ -20,11 +20,15 @@ function server_content_get_home_info($home_id)
return $db->getGameHome($home_id);
}
function server_content_collect_workshop_ids($db, $home_id)
function server_content_collect_workshop_ids($db, $home_id, $pending_only = false)
{
$where = "home_id=".(int)$home_id." AND install_state<>'removed'";
if ($pending_only) {
$where .= " AND pending_action='install_on_restart'";
}
$rows = $db->resultQuery(
"SELECT workshop_item_id FROM `".OGP_DB_PREFIX."server_content_workshop`
WHERE home_id=".(int)$home_id." AND install_state<>'removed'"
WHERE {$where}"
);
$item_ids = array();
if (is_array($rows)) {
@ -237,7 +241,7 @@ function server_content_install_updates($home_id, $options = array())
if ($workshop_action === '') {
$workshop_action = !empty($options['check_only']) ? 'check_updates' : 'update';
}
$workshop_ids = server_content_collect_workshop_ids($db, (int)$home_info['home_id']);
$workshop_ids = server_content_collect_workshop_ids($db, (int)$home_info['home_id'], !empty($options['pending_only']));
$manifest_rows = server_content_collect_manifest_addon_rows($db, (int)$home_info['home_id']);
if (empty($workshop_ids) && empty($manifest_rows)) {
$result = server_content_result('no_updates', 'No installed server content records were found for this home.', array(
@ -251,12 +255,65 @@ function server_content_install_updates($home_id, $options = array())
return $result;
}
if (!empty($workshop_ids) && empty($options['check_only'])) {
if (!empty($workshop_ids)) {
$server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $home_info['home_cfg_file']);
if ($server_xml === false) {
return server_content_result('failed', 'Unable to load server XML configuration for Workshop update.');
}
$manifest_context = scm_workshop_build_manifest_context($db, $home_info, $server_xml, $workshop_ids, array());
if (empty($manifest_context['workshop_app_id'])) {
return server_content_result('failed', 'Workshop App ID is missing. Configure it in game XML or the Server Content template.');
}
if (empty($options['check_only'])) {
scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, 'installing', null, false, false);
}
$error = '';
$details = array();
$ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, $workshop_action, $workshop_ids, $error, $manifest_context, $details);
if (!$ok) {
if (empty($options['check_only'])) {
scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, 'failed', $error, false, false);
}
return server_content_result('failed', $error, $details);
}
if (empty($options['check_only'])) {
$final_state = ($workshop_action === 'download_only') ? 'downloaded' : 'installed';
scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, $final_state, null, false, true);
if (!empty($options['pending_only'])) {
scm_workshop_set_update_policy($db, (int)$home_info['home_id'], $workshop_ids, 'manual');
}
if ($workshop_action === 'download_only') {
scm_workshop_set_update_policy($db, (int)$home_info['home_id'], $workshop_ids, 'install_on_restart');
}
scm_workshop_record_catalog_items($db, (string)$manifest_context['workshop_app_id'], $workshop_ids, $home_info, isset($manifest_context['item_details']) ? $manifest_context['item_details'] : array(), true);
}
server_content_record_installed_content_state($home_info, array(
'home_id' => (int)$home_info['home_id'],
'last_action' => (string)$workshop_action,
'last_result' => 'success',
'last_manifest' => isset($details['manifest_path']) ? $details['manifest_path'] : '',
'last_updated_at' => date('Y-m-d H:i:s'),
'installed_workshop_ids' => $workshop_ids,
));
if (empty($manifest_rows) || !empty($options['workshop_only'])) {
if (!empty($options['check_only'])) {
return server_content_result('success', 'Workshop update check completed.', array(
'workshop_items' => count($workshop_ids),
'manifest_path' => isset($details['manifest_path']) ? $details['manifest_path'] : '',
'log_path' => isset($details['log_path']) ? $details['log_path'] : '',
));
}
return server_content_result('success', 'Workshop action completed.', array(
'workshop_action' => $workshop_action,
'workshop_items' => count($workshop_ids),
'manifest_path' => isset($details['manifest_path']) ? $details['manifest_path'] : '',
'log_path' => isset($details['log_path']) ? $details['log_path'] : '',
));
}
}
$manifest_items = array(
'workshop_item_ids' => $workshop_ids,
'workshop_item_ids' => array(),
'manifest_addons' => $manifest_rows,
);
$manifest_path = server_content_build_manifest($home_info['home_id'], 'server_content', $workshop_action, $manifest_items, $options);
@ -276,9 +333,6 @@ function server_content_install_updates($home_id, $options = array())
return $execute;
}
if (!empty($workshop_ids) && empty($options['check_only'])) {
scm_workshop_update_rows_state($db, (int)$home_info['home_id'], $workshop_ids, 'installed', null, false, true);
}
server_content_record_installed_content_state($home_info, array(
'home_id' => (int)$home_info['home_id'],
'last_action' => (string)$workshop_action,
@ -447,6 +501,10 @@ function server_content_run_scheduled_action($home_id, $action, $options = array
'server_content_notify_updates_only' => 'server_content_check_updates',
'server_content_validate_files' => 'server_content_update_workshop',
'server_content_backup_before_update' => 'server_content_install_updates',
'workshop_update' => 'server_content_update_workshop',
'workshop_update_and_restart' => 'server_content_install_updates_and_restart',
'workshop_download_only' => 'server_content_update_workshop',
'workshop_install_pending_on_restart' => 'server_content_install_updates',
);
if (!isset($handlers[$action]) || !function_exists($handlers[$action])) {
$result = server_content_result('failed', 'Unsupported scheduled server content action.', array(
@ -463,9 +521,22 @@ function server_content_run_scheduled_action($home_id, $action, $options = array
if ($action === 'server_content_backup_before_update') {
$options['backup_before_update'] = true;
}
if ($action === 'server_content_install_updates_and_restart' && !isset($options['safe_restart'])) {
if ($action === 'server_content_install_updates_and_restart' || $action === 'workshop_update_and_restart') {
$options['workshop_action'] = 'update';
}
if (in_array($action, array('workshop_update', 'workshop_update_and_restart', 'workshop_download_only', 'workshop_install_pending_on_restart'), true)) {
$options['workshop_only'] = true;
}
if (($action === 'server_content_install_updates_and_restart' || $action === 'workshop_update_and_restart') && !isset($options['safe_restart'])) {
$options['safe_restart'] = true;
}
if ($action === 'workshop_download_only') {
$options['workshop_action'] = 'download_only';
}
if ($action === 'workshop_install_pending_on_restart') {
$options['workshop_action'] = 'update';
$options['pending_only'] = true;
}
if ($action === 'server_content_notify_updates_only') {
$options['notify_only'] = true;
$options['check_only'] = true;

View file

@ -75,6 +75,8 @@ function scm_ensure_workshop_schema($db)
'install_strategy' => "VARCHAR(64) NULL AFTER `install_path`",
'enabled' => "TINYINT(1) NOT NULL DEFAULT 1 AFTER `install_strategy`",
'load_order' => "INT NOT NULL DEFAULT 0 AFTER `enabled`",
'update_policy' => "VARCHAR(64) NOT NULL DEFAULT 'manual' AFTER `load_order`",
'pending_action' => "VARCHAR(64) NULL AFTER `update_policy`",
);
foreach ($workshop_columns as $col => $definition) {
$escaped_col = $db->realEscapeSingle($col);
@ -89,6 +91,27 @@ function scm_ensure_workshop_schema($db)
}
}
$db->query(
"CREATE TABLE IF NOT EXISTS `".OGP_DB_PREFIX."server_content_workshop_catalog` (
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`workshop_id` VARCHAR(64) NOT NULL,
`app_id` VARCHAR(32) NOT NULL DEFAULT '',
`title` VARCHAR(255) NULL,
`install_count` INT NOT NULL DEFAULT 0,
`first_seen` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_installed` DATETIME NULL,
`last_updated` DATETIME NULL,
`published_date` DATETIME NULL,
`tags` TEXT NULL,
`game_key` VARCHAR(128) NULL,
`local_cache_path` VARCHAR(512) NULL,
UNIQUE KEY `uniq_workshop_app` (`workshop_id`, `app_id`),
KEY `idx_app_id` (`app_id`),
KEY `idx_install_count` (`install_count`),
KEY `idx_last_installed` (`last_installed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
return $ok;
}
@ -132,6 +155,144 @@ function scm_get_workshop_rows($db, $home_id)
return is_array($rows) ? $rows : array();
}
function scm_get_workshop_update_policies()
{
return array(
'manual' => 'Manual update only',
'scheduled' => 'Update on scheduled task',
'update_now' => 'Update immediately',
'update_and_restart' => 'Update immediately and restart',
'download_only' => 'Download now, install later',
'install_on_restart' => 'Install during next restart',
);
}
function scm_normalize_workshop_update_policy($policy)
{
$policy = trim((string)$policy);
$policies = scm_get_workshop_update_policies();
return isset($policies[$policy]) ? $policy : 'manual';
}
function scm_workshop_set_enabled($db, $home_id, array $item_ids, $enabled)
{
if (empty($item_ids)) {
return false;
}
$escaped_ids = array();
foreach ($item_ids as $item_id) {
$escaped_ids[] = "'" . $db->realEscapeSingle((string)$item_id) . "'";
}
return (bool)$db->query(
"UPDATE `".OGP_DB_PREFIX."server_content_workshop`
SET enabled=" . ((int)$enabled ? 1 : 0) . ", updated_at=NOW()
WHERE home_id=".(int)$home_id." AND workshop_item_id IN (".implode(",", $escaped_ids).")"
);
}
function scm_workshop_set_update_policy($db, $home_id, array $item_ids, $policy)
{
if (empty($item_ids)) {
return false;
}
$policy = scm_normalize_workshop_update_policy($policy);
$pending = '';
if ($policy === 'download_only') {
$pending = 'install_on_restart';
}
elseif ($policy === 'install_on_restart') {
$pending = 'install_on_restart';
}
$escaped_ids = array();
foreach ($item_ids as $item_id) {
$escaped_ids[] = "'" . $db->realEscapeSingle((string)$item_id) . "'";
}
return (bool)$db->query(
"UPDATE `".OGP_DB_PREFIX."server_content_workshop`
SET update_policy='" . $db->realEscapeSingle($policy) . "',
pending_action=" . ($pending === '' ? "NULL" : "'" . $db->realEscapeSingle($pending) . "'") . ",
updated_at=NOW()
WHERE home_id=".(int)$home_id." AND workshop_item_id IN (".implode(",", $escaped_ids).")"
);
}
function scm_workshop_catalog_sort_sql($sort)
{
$sort = trim((string)$sort);
$allowed = array(
'name' => 'title ASC, workshop_id ASC',
'install_count' => 'install_count DESC, last_installed DESC, workshop_id ASC',
'published_date' => 'published_date DESC, title ASC, workshop_id ASC',
'last_updated' => 'last_updated DESC, title ASC, workshop_id ASC',
'last_installed' => 'last_installed DESC, title ASC, workshop_id ASC',
);
return isset($allowed[$sort]) ? $allowed[$sort] : $allowed['last_installed'];
}
function scm_get_workshop_catalog_rows($db, $app_id = '', $sort = 'last_installed', $limit = 50)
{
if (!scm_ensure_workshop_schema($db)) {
return array();
}
$where = '';
$app_id = trim((string)$app_id);
if ($app_id !== '' && preg_match('/^[0-9]+$/', $app_id)) {
$where = "WHERE app_id='" . $db->realEscapeSingle($app_id) . "'";
}
$limit = (int)$limit;
if ($limit <= 0 || $limit > 200) {
$limit = 50;
}
$rows = $db->resultQuery(
"SELECT * FROM `".OGP_DB_PREFIX."server_content_workshop_catalog`
{$where}
ORDER BY " . scm_workshop_catalog_sort_sql($sort) . "
LIMIT {$limit}"
);
return is_array($rows) ? $rows : array();
}
function scm_workshop_record_catalog_items($db, $workshop_app_id, array $item_ids, array $home_info = array(), array $item_details = array(), $mark_update = false)
{
if (empty($item_ids) || !scm_ensure_workshop_schema($db)) {
return false;
}
$workshop_app_id = preg_match('/^[0-9]+$/', (string)$workshop_app_id) ? (string)$workshop_app_id : '';
$game_key = isset($home_info['game_key']) ? (string)$home_info['game_key'] : '';
foreach ($item_ids as $item_id) {
$item_id = (string)$item_id;
if (!preg_match('/^[0-9]+$/', $item_id)) {
continue;
}
$detail = isset($item_details[$item_id]) && is_array($item_details[$item_id]) ? $item_details[$item_id] : array();
$title = isset($detail['title']) ? (string)$detail['title'] : '';
$install_path = isset($detail['target_path_resolved']) ? (string)$detail['target_path_resolved'] : '';
$db->query(
"INSERT INTO `".OGP_DB_PREFIX."server_content_workshop_catalog`
(workshop_id, app_id, title, install_count, first_seen, last_installed, last_updated, game_key, local_cache_path)
VALUES (
'".$db->realEscapeSingle($item_id)."',
'".$db->realEscapeSingle($workshop_app_id)."',
".($title === '' ? "NULL" : "'".$db->realEscapeSingle($title)."'").",
1,
NOW(),
NOW(),
".($mark_update ? "NOW()" : "NULL").",
".($game_key === '' ? "NULL" : "'".$db->realEscapeSingle($game_key)."'").",
".($install_path === '' ? "NULL" : "'".$db->realEscapeSingle($install_path)."'")."
)
ON DUPLICATE KEY UPDATE
install_count=install_count+1,
title=IF(VALUES(title) IS NULL OR VALUES(title)='', title, VALUES(title)),
last_installed=NOW(),
last_updated=".($mark_update ? "NOW()" : "last_updated").",
game_key=IF(VALUES(game_key) IS NULL OR VALUES(game_key)='', game_key, VALUES(game_key)),
local_cache_path=IF(VALUES(local_cache_path) IS NULL OR VALUES(local_cache_path)='', local_cache_path, VALUES(local_cache_path))"
);
}
return true;
}
function scm_extract_workshop_item_id($value)
{
$value = trim((string)$value);

View file

@ -52,6 +52,9 @@ scm_workshop_test_assert(empty($invalid), 'valid mixed Workshop input has no inv
$ids = scm_parse_workshop_ids("abc, https://steamcommunity.com/sharedfiles/filedetails/?id=0, 123", $invalid);
scm_workshop_test_assert($ids === array('123'), 'keeps valid IDs when invalid values are present');
scm_workshop_test_assert(count($invalid) === 2, 'reports invalid Workshop entries');
scm_workshop_test_assert(scm_normalize_workshop_update_policy('download_only') === 'download_only', 'accepts download-only Workshop update policy');
scm_workshop_test_assert(scm_normalize_workshop_update_policy('bad_policy') === 'manual', 'invalid Workshop update policy falls back to manual');
scm_workshop_test_assert(strpos(scm_workshop_catalog_sort_sql('install_count'), 'install_count DESC') !== false, 'catalog sort supports install count');
$linuxHome = array(
'home_id' => 10,

View file

@ -243,7 +243,7 @@ function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml,
return true;
}
function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $raw_ids, array $selected_ids, &$message, &$is_error, $addon_id = 0)
function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $raw_ids, array $selected_ids, &$message, &$is_error, $addon_id = 0, array $options = array())
{
$message = '';
$is_error = true;
@ -341,6 +341,7 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
$ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'install', $item_ids, $error, $manifest_context, $details);
if ($ok) {
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installed', null, true, true);
scm_workshop_record_catalog_items($db, $resolved_app_id, $item_ids, $home_info, isset($manifest_context['item_details']) ? $manifest_context['item_details'] : array(), true);
scm_workshop_log_action($db, $home_id, $user_id, "install_new ids=".implode(',', $item_ids)." addon_id=".$addon_id." status=success");
$is_error = false;
$message = 'Workshop item(s) installed successfully. Manifest: '.scm_h(isset($details['manifest_path']) ? $details['manifest_path'] : '').' Log: '.scm_h(isset($details['log_path']) ? $details['log_path'] : '');
@ -352,13 +353,39 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
return false;
}
if ($action === 'update_selected' || $action === 'remove_selected') {
if ($action === 'enable_selected' || $action === 'disable_selected' || $action === 'save_update_policy') {
$item_ids = scm_workshop_filter_existing_ids($db, $home_id, scm_parse_selected_workshop_ids($selected_ids));
if (empty($item_ids)) {
$message = 'Select one or more saved Workshop IDs.';
return false;
}
$target_action = ($action === 'remove_selected') ? 'remove' : 'update';
if ($action === 'save_update_policy') {
$policy = isset($options['update_policy']) ? (string)$options['update_policy'] : 'manual';
if (!scm_workshop_set_update_policy($db, $home_id, $item_ids, $policy)) {
$message = 'Failed to save Workshop update policy.';
return false;
}
$is_error = false;
$message = 'Workshop update policy saved for selected item(s).';
return true;
}
$enabled = ($action === 'enable_selected') ? 1 : 0;
if (!scm_workshop_set_enabled($db, $home_id, $item_ids, $enabled)) {
$message = 'Failed to update Workshop enabled state.';
return false;
}
$is_error = false;
$message = $enabled ? 'Selected Workshop item(s) enabled.' : 'Selected Workshop item(s) disabled.';
return true;
}
if ($action === 'update_selected' || $action === 'remove_selected' || $action === 'download_selected') {
$item_ids = scm_workshop_filter_existing_ids($db, $home_id, scm_parse_selected_workshop_ids($selected_ids));
if (empty($item_ids)) {
$message = 'Select one or more saved Workshop IDs.';
return false;
}
$target_action = ($action === 'remove_selected') ? 'remove' : (($action === 'download_selected') ? 'download_only' : 'update');
$manifest_context = scm_workshop_build_manifest_context($db, $home_info, $server_xml, $item_ids, $template);
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installing', null, false, false);
$error = '';
@ -367,12 +394,24 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
if ($ok) {
if ($target_action === 'remove') {
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'removed', null, false, true);
} elseif ($target_action === 'download_only') {
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'downloaded', null, false, true);
scm_workshop_set_update_policy($db, $home_id, $item_ids, 'install_on_restart');
scm_workshop_record_catalog_items($db, isset($manifest_context['workshop_app_id']) ? (string)$manifest_context['workshop_app_id'] : '', $item_ids, $home_info, isset($manifest_context['item_details']) ? $manifest_context['item_details'] : array(), true);
} else {
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installed', null, false, true);
scm_workshop_record_catalog_items($db, isset($manifest_context['workshop_app_id']) ? (string)$manifest_context['workshop_app_id'] : '', $item_ids, $home_info, isset($manifest_context['item_details']) ? $manifest_context['item_details'] : array(), true);
}
scm_workshop_log_action($db, $home_id, $user_id, $action." ids=".implode(',', $item_ids)." addon_id=".$addon_id." status=success");
$is_error = false;
$message = (($target_action === 'remove') ? 'Selected Workshop item(s) removed.' : 'Selected Workshop item(s) updated successfully.') . ' Log: ' . scm_h(isset($details['log_path']) ? $details['log_path'] : '');
if ($target_action === 'remove') {
$message = 'Selected Workshop item(s) removed.';
} elseif ($target_action === 'download_only') {
$message = 'Selected Workshop item(s) downloaded and marked for install on next restart.';
} else {
$message = 'Selected Workshop item(s) updated successfully.';
}
$message .= ' Log: ' . scm_h(isset($details['log_path']) ? $details['log_path'] : '');
return true;
}
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'failed', $error, false, false);
@ -404,6 +443,7 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
$ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'update', $item_ids, $error, $manifest_context, $details);
if ($ok) {
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installed', null, false, true);
scm_workshop_record_catalog_items($db, isset($manifest_context['workshop_app_id']) ? (string)$manifest_context['workshop_app_id'] : '', $item_ids, $home_info, isset($manifest_context['item_details']) ? $manifest_context['item_details'] : array(), true);
scm_workshop_log_action($db, $home_id, $user_id, "update_all ids=".implode(',', $item_ids)." addon_id=".$addon_id." status=success");
$is_error = false;
$message = 'All saved Workshop item(s) updated successfully. Log: ' . scm_h(isset($details['log_path']) ? $details['log_path'] : '');

View file

@ -56,6 +56,7 @@ function exec_ogp_module() {
$message = '';
$is_error = false;
$entered_ids = '';
$catalog_sort = isset($_REQUEST['catalog_sort']) ? (string)$_REQUEST['catalog_sort'] : 'last_installed';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$posted_home_id = isset($_POST['home_id']) ? (int)$_POST['home_id'] : 0;
@ -64,6 +65,9 @@ function exec_ogp_module() {
$selected_ids = isset($_POST['selected_ids']) ? $_POST['selected_ids'] : array();
$action = isset($_POST['workshop_action']) ? (string)$_POST['workshop_action'] : '';
$posted_addon_id = isset($_POST['addon_id']) ? (int)$_POST['addon_id'] : 0;
$options = array(
'update_policy' => isset($_POST['update_policy']) ? (string)$_POST['update_policy'] : 'manual',
);
if ($posted_home_id !== $home_id) {
$is_error = true;
@ -74,11 +78,14 @@ function exec_ogp_module() {
$message = 'Invalid CSRF token for workshop action.';
}
else {
scm_workshop_handle_action($db, $home_info, $user_id, $action, $entered_ids, (array)$selected_ids, $message, $is_error, $posted_addon_id > 0 ? $posted_addon_id : $addon_id);
scm_workshop_handle_action($db, $home_info, $user_id, $action, $entered_ids, (array)$selected_ids, $message, $is_error, $posted_addon_id > 0 ? $posted_addon_id : $addon_id, $options);
}
}
$rows = scm_get_workshop_rows($db, $home_id);
$server_xml = read_server_config(SERVER_CONFIG_LOCATION . "/" . $home_info['home_cfg_file']);
$catalog_app_id = ($server_xml !== false) ? scm_extract_workshop_app_id($server_xml) : '';
$catalog_rows = scm_get_workshop_catalog_rows($db, $catalog_app_id, $catalog_sort, 50);
$csrf_token = scm_get_csrf_token();
echo "<h2>Workshop Mods: " . scm_h($home_info['home_name']) . "</h2>";
@ -135,6 +142,7 @@ function exec_ogp_module() {
<th>Title</th>
<th>Enabled</th>
<th>Order</th>
<th>Update Policy</th>
<th>State</th>
<th>Install Path</th>
<th>Last Installed</th>
@ -142,7 +150,7 @@ function exec_ogp_module() {
<th>Last Error</th>
</tr>
<?php if (empty($rows)): ?>
<tr><td colspan='10' class='info'>No Workshop items saved for this server yet.</td></tr>
<tr><td colspan='11' class='info'>No Workshop items saved for this server yet.</td></tr>
<?php else: ?>
<?php foreach ((array)$rows as $row): ?>
<tr>
@ -151,6 +159,7 @@ function exec_ogp_module() {
<td><?php echo scm_h($row['title']); ?></td>
<td><?php echo !empty($row['enabled']) ? 'Yes' : 'No'; ?></td>
<td><?php echo scm_h(isset($row['load_order']) ? $row['load_order'] : ''); ?></td>
<td><?php echo scm_h(isset($row['update_policy']) ? $row['update_policy'] : 'manual'); ?></td>
<td><?php echo scm_h($row['install_state']); ?></td>
<td><small><?php echo scm_h(isset($row['install_path']) ? $row['install_path'] : ''); ?></small></td>
<td><?php echo scm_h($row['last_installed_at']); ?></td>
@ -163,13 +172,51 @@ function exec_ogp_module() {
<br>
<table class='center'>
<tr>
<td>
<select name='update_policy'>
<?php foreach (scm_get_workshop_update_policies() as $policy_key => $policy_label): ?>
<option value='<?php echo scm_h($policy_key); ?>'><?php echo scm_h($policy_label); ?></option>
<?php endforeach; ?>
</select>
</td>
<td><button type='submit' name='workshop_action' value='save_update_policy'>Save Policy</button></td>
<td><button type='submit' name='workshop_action' value='update_selected'>Update Selected</button></td>
<td><button type='submit' name='workshop_action' value='download_selected'>Download Selected</button></td>
<td><button type='submit' name='workshop_action' value='remove_selected'>Remove Selected</button></td>
<td><button type='submit' name='workshop_action' value='enable_selected'>Enable Selected</button></td>
<td><button type='submit' name='workshop_action' value='disable_selected'>Disable Selected</button></td>
<td><button type='submit' name='workshop_action' value='update_all'>Update All</button></td>
</tr>
</table>
</form>
<h3>Known Workshop Items</h3>
<p class='info'>These are Workshop items previously installed through Server Content Manager. Metadata is optional; direct ID or URL install remains available even when Steam metadata has not been fetched yet.</p>
<table class='center'>
<tr>
<th>Workshop ID</th>
<th><a href='?m=addonsmanager&amp;p=workshop_content&amp;home_id=<?php echo (int)$home_id; ?>&amp;mod_id=<?php echo (int)$mod_id; ?>&amp;ip=<?php echo scm_h($ip); ?>&amp;port=<?php echo scm_h($port); ?>&amp;addon_id=<?php echo (int)$addon_id; ?>&amp;catalog_sort=name'>Name</a></th>
<th><a href='?m=addonsmanager&amp;p=workshop_content&amp;home_id=<?php echo (int)$home_id; ?>&amp;mod_id=<?php echo (int)$mod_id; ?>&amp;ip=<?php echo scm_h($ip); ?>&amp;port=<?php echo scm_h($port); ?>&amp;addon_id=<?php echo (int)$addon_id; ?>&amp;catalog_sort=install_count'>Install Count</a></th>
<th><a href='?m=addonsmanager&amp;p=workshop_content&amp;home_id=<?php echo (int)$home_id; ?>&amp;mod_id=<?php echo (int)$mod_id; ?>&amp;ip=<?php echo scm_h($ip); ?>&amp;port=<?php echo scm_h($port); ?>&amp;addon_id=<?php echo (int)$addon_id; ?>&amp;catalog_sort=published_date'>Published</a></th>
<th><a href='?m=addonsmanager&amp;p=workshop_content&amp;home_id=<?php echo (int)$home_id; ?>&amp;mod_id=<?php echo (int)$mod_id; ?>&amp;ip=<?php echo scm_h($ip); ?>&amp;port=<?php echo scm_h($port); ?>&amp;addon_id=<?php echo (int)$addon_id; ?>&amp;catalog_sort=last_updated'>Last Updated</a></th>
<th><a href='?m=addonsmanager&amp;p=workshop_content&amp;home_id=<?php echo (int)$home_id; ?>&amp;mod_id=<?php echo (int)$mod_id; ?>&amp;ip=<?php echo scm_h($ip); ?>&amp;port=<?php echo scm_h($port); ?>&amp;addon_id=<?php echo (int)$addon_id; ?>&amp;catalog_sort=last_installed'>Last Installed</a></th>
</tr>
<?php if (empty($catalog_rows)): ?>
<tr><td colspan='6' class='info'>No known Workshop items have been installed for this app yet.</td></tr>
<?php else: ?>
<?php foreach ((array)$catalog_rows as $catalog_row): ?>
<tr>
<td><?php echo scm_h($catalog_row['workshop_id']); ?></td>
<td><?php echo scm_h($catalog_row['title']); ?></td>
<td><?php echo scm_h($catalog_row['install_count']); ?></td>
<td><?php echo scm_h($catalog_row['published_date']); ?></td>
<td><?php echo scm_h($catalog_row['last_updated']); ?></td>
<td><?php echo scm_h($catalog_row['last_installed']); ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</table>
<form method='get' action=''>
<input type='hidden' name='m' value='addonsmanager' />
<input type='hidden' name='p' value='user_addons' />

View file

@ -120,6 +120,10 @@ function get_server_content_scheduled_actions() {
'server_content_update_all',
'server_content_validate_files',
'server_content_backup_before_update',
'workshop_update',
'workshop_update_and_restart',
'workshop_download_only',
'workshop_install_pending_on_restart',
);
}
@ -131,11 +135,20 @@ function build_cron_scheduler_command($panelURL, $token, $game_home, $action) {
if(in_array($action, get_server_content_scheduled_actions()))
{
$options = array('triggered_by' => 'scheduler');
if($action == 'server_content_install_updates_and_restart')
if($action == 'server_content_install_updates_and_restart' || $action == 'workshop_update_and_restart')
{
$options['safe_restart'] = true;
$options['restart_delay_seconds'] = 60;
}
if($action == 'workshop_download_only')
{
$options['workshop_action'] = 'download_only';
}
if($action == 'workshop_install_pending_on_restart')
{
$options['workshop_action'] = 'update';
$options['pending_only'] = true;
}
$options_json = urlencode(json_encode($options));
return "wget -qO- \"{$panelURL}/ogp_api.php?server_content/run_scheduled_action&token={$token}&home_id={$home_id}&action={$action}&options={$options_json}\" --no-check-certificate > /dev/null 2>&1";
}

View file

@ -60,7 +60,7 @@ Primary categories:
| updates | `steam_cmd`, `component_update`, `stop_update` |
| system | `exec`, `sudo_exec`, `rebootnow`, `what_os`, `discover_ips`, `mon_stats` |
| scheduler | `scheduler_add_task`, `scheduler_edit_task`, `scheduler_del_task`, `scheduler_list_tasks` |
| content | `steam_workshop`, `get_workshop_mods_info` |
| content | `writefile` + `exec` manifest/script orchestration; legacy `steam_workshop`, `get_workshop_mods_info` |
See the full command table in:
@ -82,7 +82,7 @@ Most Panel modules do not build XML-RPC directly. They call `OGPRemoteLibrary`.
| `remote_query()` | `remote_query` | `gamemanager`, dashboards |
| `component_update()` | `component_update` | update/admin pages |
| `scheduler_*()` | `scheduler_*` | `cron` |
| `steam_workshop()` | `steam_workshop` | legacy workshop path |
| `steam_workshop()` | `steam_workshop` | legacy workshop path only |
## External Panel API
@ -207,7 +207,25 @@ Common manifest fields:
- `items`
- `options`
For Workshop-specific manifests, the broader fields documented in `WORKSHOP_SYSTEM.md` also apply, including app IDs, install strategy, and target paths.
For Workshop-specific manifests, the broader fields documented in `WORKSHOP_SYSTEM.md` also apply, including app IDs, install strategy, update policy, pending action, and target paths.
Current Workshop actions:
- `install`
- `update`
- `check_updates`
- `download_only`
- `validate_files`
- `remove`
Current Scheduler/API action aliases:
- `workshop_update`
- `workshop_update_and_restart`
- `workshop_download_only`
- `workshop_install_pending_on_restart`
The legacy `steam_workshop/install` API route and agent `steam_workshop` RPC remain compatibility-only. New user-facing Workshop work should use `addonsmanager` / Server Content Manager.
## Status Contract

View file

@ -103,7 +103,9 @@ Current preferred implementation path:
- `addonsmanager` stages a manifest and helper script through `writefile`
- it executes the helper through `exec`
- it records per-server items and policies in Panel database tables
- it uses `steam_workshop` only as legacy compatibility, not as the primary workflow
- no new Workshop-specific business logic should be added to agents for the current design
## Shell And System Commands
@ -172,6 +174,10 @@ The built-in action names handled by the Panel-generated API URLs are:
- `server_content_update_all`
- `server_content_validate_files`
- `server_content_backup_before_update`
- `workshop_update`
- `workshop_update_and_restart`
- `workshop_download_only`
- `workshop_install_pending_on_restart`
## Panel Wrapper Map

View file

@ -2,7 +2,7 @@
## Status
Accepted, Phase 1 implementation active
Accepted, Panel-side orchestration active
## Decision
@ -10,6 +10,8 @@ Accepted, Phase 1 implementation active
Phase 1 implements this decision by routing the user-facing Workshop install flow through `addonsmanager/workshop_content.php` and suppressing the standalone `steam_workshop` monitor button.
The current implementation keeps Workshop business logic in the Panel. Agents are generic executors: the Panel writes manifests, stages scripts, invokes `exec`, reads logs/results, and updates database state. New first-class Workshop subsystems should not be added to `Agent-Windows` or `Agent_Linux` unless a future decision explicitly changes this.
## Reasoning
- `addonsmanager` already has the richer schema and more complete product direction.
@ -31,8 +33,12 @@ Phase 1 implements this decision by routing the user-facing Workshop install flo
- Workshop input accepts numeric IDs or Steam URLs, then stores numeric IDs only.
- Manifests are written under the server home in `gsp_server_content`.
- Bundled Linux/Cygwin scripts are copied from the Panel module to an agent-managed folder under the server home before execution.
- These scripts are Panel-owned deployment artifacts, not persistent agent features.
- Default script names are treated as bundled handlers, not as existing agent paths.
- Missing custom scripts fall back to bundled handlers and log the fallback.
- Known Workshop items are cataloged in `server_content_workshop_catalog`.
- Per-server item state, enablement, load order, update policy, and pending action are stored in `server_content_workshop`.
- Scheduler integrates via `workshop_update`, `workshop_update_and_restart`, `workshop_download_only`, and `workshop_install_pending_on_restart`.
- Generic content installs under `{SERVER_ROOT}/workshop/{MOD_FOLDER}` by default.
- DayZ/Arma-style installs default to `@<workshop_id>` folders and copy `.bikey` files into `keys` when present.
- Startup parameter generation remains a later phase.
@ -40,6 +46,21 @@ Phase 1 implements this decision by routing the user-facing Workshop install flo
- The Panel helpers read `workshop_support` first, then tolerate older direct tags only as compatibility fallbacks.
- Arma 3 Linux and Windows configs declare Workshop app ID `107410` through `workshop_support`.
## Reference Module Findings
The old `reference/Module-Steam_Workshop` module stored per-game profile XML under its own module folder, required admin-managed Workshop mod lists for some workflows, called dedicated agent RPCs such as `steam_workshop`, and used regex/profile rules to mutate game config values. Useful lessons retained:
- Customers should be able to paste one or more Workshop IDs or URLs.
- The Panel should store per-server installed Workshop rows.
- SteamCMD command generation should be controlled by admin/game configuration, not customer shell text.
Rejected legacy behavior:
- making `steam_workshop` the primary user module
- requiring admins to pre-enter every Workshop item ID
- mutating base game XML per customer install
- adding new Workshop-specific agent business logic
## Validation
Current validation commands:

View file

@ -40,6 +40,10 @@ The agent stores the cron entry and executes it locally.
| `server_content_update_all` | same | same | `cron`, `addonsmanager` | aggregate update flow |
| `server_content_validate_files` | same | same | `cron`, `addonsmanager` | validation flow |
| `server_content_backup_before_update` | same | same | `cron`, `addonsmanager`, backup-related helpers | backup hook before content update |
| `workshop_update` | same | same | `cron`, `addonsmanager` | Server Content Manager Workshop update flow |
| `workshop_update_and_restart` | same | same | `cron`, `addonsmanager`, `gamemanager` | Workshop update plus safe restart request |
| `workshop_download_only` | same | same | `cron`, `addonsmanager` | SteamCMD download/cache without copying into live mod folders |
| `workshop_install_pending_on_restart` | same | same | `cron`, `addonsmanager` | Install Workshop items marked `pending_action=install_on_restart` |
## Agent Scheduler RPCs

View file

@ -33,3 +33,35 @@ Important references:
- clear logs and results
- no customer raw shell commands by default
## Server Content / Workshop Actions
The Scheduler must use Server Content Manager actions for Workshop automation. Do not build a separate Workshop scheduler.
Current Server Content actions:
| Action Key | Purpose |
|---|---|
| `server_content_check_updates` | Check content update state. |
| `server_content_check_workshop_updates` | Check Workshop update state. |
| `server_content_install_updates_if_stopped` | Install only when the server is stopped. |
| `server_content_install_updates_next_restart` | Queue updates for the next restart. |
| `server_content_install_updates_now` | Install available updates immediately. |
| `server_content_install_updates_and_restart` | Install updates, wait, then restart. |
| `server_content_update_all` | Update all server content records. |
| `server_content_validate_files` | Validate server content files. |
| `server_content_backup_before_update` | Backup before applying updates. |
Workshop-specific aliases:
| Action Key | Purpose |
|---|---|
| `workshop_update` | Update installed Workshop items through Server Content Manager. |
| `workshop_update_and_restart` | Update Workshop items and request a safe restart. |
| `workshop_download_only` | Download/cache Workshop items without installing into live folders. |
| `workshop_install_pending_on_restart` | Install items marked as pending during the restart/maintenance window. |
Implementation references:
- `Panel/modules/cron/shared_cron_functions.php`
- `Panel/modules/addonsmanager/server_content_actions.php`
- `Panel/modules/addonsmanager/workshop_action.php`

View file

@ -26,7 +26,8 @@ Active workflow:
3. Open the `Steam Workshop Mods` category.
4. Paste one or more Steam Workshop URLs or numeric Workshop IDs.
5. Click `Install / Queue`.
6. The Panel validates input, stores numeric Workshop IDs, writes a manifest, syncs the install script, executes it through the agent, and shows the result.
6. The Panel validates input, stores numeric Workshop IDs, writes a manifest, syncs the install script, executes it through the agent's existing `exec` primitive, and shows the result.
7. Installed items can be enabled/disabled, updated, removed, downloaded without immediate install, assigned an update policy, and later used by Scheduler actions.
Accepted input examples:
@ -57,7 +58,7 @@ The admin template defines capability and policy. The customer supplies only Wor
- or `{SERVER_HOME}/gsp_server_content/scripts/workshop/generic_steam_workshop_windows_cygwin.sh`
6. The agent runs:
- `bash <script> <manifest>`
7. The script runs SteamCMD, copies downloaded content into the target mod folder, logs output, and copies `.bikey` files for DayZ/Arma-style strategies.
7. The script runs SteamCMD, copies downloaded content into the target mod folder when the action requires install, logs output, and copies `.bikey` files for DayZ/Arma-style strategies.
Current repair:
@ -65,6 +66,8 @@ Current repair:
- Missing custom scripts fall back to the bundled generic handler and log a warning.
- Generic installs default to `{SERVER_ROOT}/workshop/{MOD_FOLDER}`.
- DayZ/Arma installs keep `{SERVER_ROOT}/{MOD_FOLDER}` for `@<workshop_id>` compatibility.
- `download_only` and `validate_files` are accepted script actions and do not copy into live mod folders.
- `remove` does not require SteamCMD.
## Manifest Fields
@ -121,6 +124,8 @@ Key-copy behavior:
- `install_strategy`
- `enabled`
- `load_order`
- `update_policy`
- `pending_action`
- `install_state`
- `last_installed_at`
- `last_updated_at`
@ -131,9 +136,40 @@ Phase 1 states:
- `queued`
- `installing`
- `installed`
- `downloaded`
- `failed`
- `removed`
`server_content_workshop_catalog` tracks known/common items:
- `workshop_id`
- `app_id`
- `title`
- `install_count`
- `first_seen`
- `last_installed`
- `last_updated`
- `published_date`
- `tags`
- `game_key`
- `local_cache_path`
Supported update policies:
- `manual`
- `scheduled`
- `update_now`
- `update_and_restart`
- `download_only`
- `install_on_restart`
Scheduler action keys:
- `workshop_update`
- `workshop_update_and_restart`
- `workshop_download_only`
- `workshop_install_pending_on_restart`
## Security Notes
- Customer input is reduced to numeric Workshop IDs only.

View file

@ -19,7 +19,15 @@ Important files:
- `Panel/modules/steam_workshop/module.php`
- `Panel/modules/steam_workshop/agent_update_workshop.php`
## Phase 1 Implemented Behavior
## Current Implemented Behavior
Workshop is Panel-side orchestration. The active workflow lives in Server Content Manager and uses existing agent primitives only:
- Panel writes manifests with validated numeric Workshop IDs.
- Panel deploys an OS-appropriate bundled handler script into the server home.
- Panel invokes the handler through the existing authenticated agent `exec` RPC.
- Agents do not need new Workshop-specific business logic.
- Legacy agent RPCs from the old `steam_workshop` module remain compatibility-only and are not the primary path.
The active user workflow is now `addonsmanager` -> `workshop_content`.
@ -39,7 +47,7 @@ The Panel syncs the bundled install script to:
- `{SERVER_HOME}/gsp_server_content/scripts/workshop/generic_steam_workshop_linux.sh`
- `{SERVER_HOME}/gsp_server_content/scripts/workshop/generic_steam_workshop_windows_cygwin.sh`
The agent executes the synced script with the manifest path. Customers do not need to place scripts manually on the agent.
The agent executes the synced script with the manifest path by using the existing generic command execution path. Customers do not need to place scripts manually on the agent.
Script selection rules:
@ -108,6 +116,8 @@ The Panel helper parser reads `workshop_support` first. Older direct tags are to
- `install_strategy`
- `enabled`
- `load_order`
- `update_policy`
- `pending_action`
- `install_state`
- `last_installed_at`
- `last_updated_at`
@ -118,9 +128,26 @@ Current install states used by Phase 1:
- `queued`
- `installing`
- `installed`
- `downloaded`
- `failed`
- `removed`
`server_content_workshop_catalog` tracks known/common Workshop items seen through Server Content Manager:
- `workshop_id`
- `app_id`
- `title`
- `install_count`
- `first_seen`
- `last_installed`
- `last_updated`
- `published_date`
- `tags`
- `game_key`
- `local_cache_path`
The catalog is Panel-side and does not require Steam Web API metadata. Metadata can be added later.
## What Exists Today
The current direction already supports:
@ -136,12 +163,39 @@ The current direction already supports:
## Main Limitations
- Workshop metadata is still incomplete.
- load order is tracked but not yet a full drag-and-drop or startup-param UX concept.
- enable/disable is stored but does not yet regenerate startup parameters.
- Load order is tracked but not yet a full drag-and-drop or startup-param UX concept.
- Enable/disable is exposed and stored but does not yet regenerate startup parameters.
- update/remove are synchronous and should become background jobs.
- caching and cleanup policy need product-level design, not just ad hoc scripts.
- `-mod=` / `-serverMod=` generation still needs a safe structured implementation.
## Scheduler Integration
Workshop updates use the existing `cron` / Scheduler system. No second Workshop scheduler should be created.
Supported scheduler action keys:
- `workshop_update`
- `workshop_update_and_restart`
- `workshop_download_only`
- `workshop_install_pending_on_restart`
Compatibility Server Content keys remain available:
- `server_content_check_workshop_updates`
- `server_content_update_workshop`
- `server_content_install_updates_next_restart`
- `server_content_install_updates_and_restart`
Per-item update policy values stored on `server_content_workshop.update_policy`:
- `manual`
- `scheduled`
- `update_now`
- `update_and_restart`
- `download_only`
- `install_on_restart`
## Troubleshooting
| Symptom | Meaning | Fix |
@ -192,3 +246,12 @@ Important manifest fields:
- `extra.keys_target_path`
Both bundled handlers validate numeric item IDs, keep writes under the server home, use SteamCMD, copy files into the resolved target path, and copy `.bikey` files for DayZ/Arma strategies when enabled.
Bundled handler actions:
- `install` - download with SteamCMD, copy/install into target path.
- `update` - validate/download with SteamCMD, copy/install into target path.
- `check_updates` - validate/download only; does not alter live mod folders.
- `download_only` - download/cache only and leave install pending.
- `validate_files` - SteamCMD validate/download only.
- `remove` - move the installed target folder into `gsp_server_content/workshop/removed/`; this does not require SteamCMD.

View file

@ -94,6 +94,8 @@ XML definitions also feed:
Workshop-enabled games must use the canonical `workshop_support` block. Loose top-level tags such as `workshop_app_id` are compatibility parser fallbacks only and should not be used in new game XML because schema validation is intentionally strict.
The `workshop_support` block is a capability declaration only. It does not install mods by itself and it does not create an agent-side Workshop subsystem. Server Content Manager reads these values, writes a per-server manifest, stages the Panel-bundled handler, and calls the agent's existing generic execution primitives.
Example:
```xml
@ -128,6 +130,12 @@ Supported `install_strategy` values:
`workshop_app_id` is the Steam Workshop app ID used by `steamcmd +workshop_download_item`. It is not automatically the same as a dedicated server installer app ID. For Arma 3, Workshop content uses `107410` while the dedicated server installer remains defined on the normal mod installer entry.
Ordering rule:
- `workshop_support` belongs after `game_name` and before `server_exec_name` in the current schema sequence.
- New XML files should not add top-level Workshop tags.
- If `install_path` is omitted, Server Content Manager defaults to `{SERVER_ROOT}/workshop/{MOD_FOLDER}` or `{SERVER_ROOT}/{MOD_FOLDER}` for DayZ/Arma strategies.
The current XML schema is validated by:
```bash

View file

@ -22,6 +22,7 @@ Known tables used by the module:
- `addons`
- `server_content_workshop`
- `server_content_workshop_catalog`
- `server_content_manifest`
- `server_content_install_history`
@ -35,7 +36,7 @@ The module can already represent several content types, including:
- config packs
- future profile-type content
For Workshop items, the current flow lets users enter Workshop IDs or full Steam Workshop URLs and routes the install through module pages and agent-side scripts.
For Workshop items, the current flow lets users enter Workshop IDs or full Steam Workshop URLs and routes the install through module pages, staged manifests, and Panel-bundled scripts executed through existing agent primitives.
## Workshop Phase 1 Flow
@ -47,9 +48,11 @@ For Workshop items, the current flow lets users enter Workshop IDs or full Steam
6. Panel parses IDs, rejects invalid entries, and records rows in `server_content_workshop`.
7. Panel writes a manifest to `{SERVER_HOME}/gsp_server_content/workshop_manifest.json`.
8. Panel syncs the bundled Linux or Cygwin script into `{SERVER_HOME}/gsp_server_content/scripts/workshop/`.
9. Agent executes the script with the manifest path.
9. Agent executes the script with the manifest path through the existing authenticated `exec` RPC.
10. Script runs SteamCMD, copies Workshop content into the configured target path, copies DayZ/Arma `.bikey` files when applicable, and writes a log under `gsp_server_content`.
The agents are intentionally generic executors in this design. New Workshop business logic should not be added to `Agent-Windows` or `Agent_Linux`; use `remote_writefile`, `exec`, log reads, and normal start/stop/restart primitives instead.
Current script fallback behavior:
- Admin-defined custom scripts are supported when they exist on the agent.
@ -92,10 +95,34 @@ SteamCMD requirements:
The legacy `steam_workshop` monitor button is intentionally suppressed so users are not sent to the deprecated standalone module.
## Current User Controls
The `workshop_content.php` page supports:
- direct install by numeric ID or Steam Workshop URL
- installed item list
- enable/disable selected items
- update selected items
- remove selected items
- download selected items without installing immediately
- update all saved Workshop items
- per-item update policy storage
- known/common item catalog sorted by name, install count, published date, last updated, or last installed
Update policies are stored as data for Scheduler/automation:
- `manual`
- `scheduled`
- `update_now`
- `update_and_restart`
- `download_only`
- `install_on_restart`
## Current Limitations
- Workshop and content metadata is still partial.
- Load order and enable/disable are tracked but not wired into startup-parameter generation yet.
- Load order is tracked but not yet reorderable through a polished drag-and-drop UI.
- Enable/disable is tracked but not wired into startup-parameter generation yet.
- Async install job progress should be more visible.
- Install strategies are still being broadened and need consistent game-specific rules.
- DayZ/Arma style key-copy is implemented for Phase 1; startup-param behavior still needs a stronger canonical implementation.

View file

@ -80,6 +80,12 @@ Validate changes with:
php Panel/modules/config_games/tests/validate_server_configs.php
```
Important rules:
- Place `workshop_support` after `game_name` and before `server_exec_name`.
- Do not add loose top-level tags such as `workshop_app_id`; helper code may tolerate them for old configs, but schema-valid XML should use the canonical block.
- XML declares capability only. Server Content Manager owns the Panel-side install orchestration and uses agents only for generic file/command execution.
## Suggested Future Improvements
- extend XML capability model