Panel/modules/steam_workshop/lib/WorkshopUpdater.php
copilot-swe-agent[bot] 8eff063a93
feat: add database-driven Steam Workshop system
- Create 3 new DB tables: workshop_game_profiles, workshop_cache, server_workshop_mods
- Add WorkshopRepository (DB access layer for all 3 tables)
- Add WorkshopInstaller (rsync/robocopy/custom_script copy logic, SteamCMD download via agent exec)
- Add WorkshopUpdater (scheduled cache update functions grouped by agent)
- Add WorkshopPreStart (pre-start mod sync helper)
- Add WorkshopProfileController (admin CRUD for profiles)
- Add WorkshopModController (user install/remove/toggle/load_order/sync)
- Add admin views: profiles list + profile_form
- Add user views: user_workshop_index + user_workshop_mods
- Add cron_update.php CLI entry point (--all/--agent-id/--home-id/--profile-id/--workshop-id)
- Add prestart_sync.php CLI helper for XML pre_start hook
- Update workshop_admin.php to route to profile management
- Update main.php to route to new mod management (legacy fallback preserved)
- Update module.php with DB migration SQL and version bump to 2.1
- Update lang/en_US.php with all new strings

Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/dbeebd0e-e7a5-469d-8a8c-e63193d1ebb0

Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
2026-04-30 18:01:33 +00:00

314 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
/*
* OGP / GSP Steam Workshop
* WorkshopUpdater: scheduled / background cache update functions.
*
* Design rules:
* - Do NOT copy into running servers during a scheduled update.
* - Do NOT restart servers automatically.
* - Log every attempt.
* - Group SteamCMD calls by (agent_id, workshop_app_id, workshop_id) to
* avoid redundant downloads when multiple servers share a mod.
*/
require_once __DIR__ . '/WorkshopRepository.php';
require_once __DIR__ . '/WorkshopInstaller.php';
class WorkshopUpdater
{
private WorkshopRepository $repo;
private WorkshopInstaller $installer;
private string $logDir;
private string $logFile;
public function __construct(WorkshopRepository $repo, WorkshopInstaller $installer)
{
$this->repo = $repo;
$this->installer = $installer;
$this->logDir = __DIR__ . '/../logs';
$this->logFile = $this->logDir . '/workshop_update.log';
if (!is_dir($this->logDir)) {
mkdir($this->logDir, 0775, true);
}
}
// ------------------------------------------------------------------
// Public API entry points called by cron_update.php
// ------------------------------------------------------------------
/**
* Update Workshop cache for all enabled installed mods across all agents.
*
* @return array<string,mixed>
*/
public function updateAll(): array
{
$this->log('=== updateAll start ===');
$rows = $this->repo->listDistinctEnabledWorkshopIds();
$results = $this->processBatch($rows);
$this->log('=== updateAll end: ' . count($results) . ' items processed ===');
return $results;
}
/**
* Update Workshop cache for all mods installed on a specific agent.
*
* @return array<string,mixed>
*/
public function updateWorkshopCacheForAgent(int $agentId): array
{
$this->log("=== updateWorkshopCacheForAgent agent={$agentId} start ===");
$rows = $this->repo->listDistinctEnabledWorkshopIdsForAgent($agentId);
$results = $this->processBatch($rows);
$this->log("=== updateWorkshopCacheForAgent agent={$agentId} end ===");
return $results;
}
/**
* Update Workshop cache for all mods installed on a specific home.
*
* @return array<string,mixed>
*/
public function updateWorkshopCacheForHome(int $homeId): array
{
$this->log("=== updateWorkshopCacheForHome home={$homeId} start ===");
$rows = $this->repo->listDistinctEnabledWorkshopIdsForHome($homeId);
$results = $this->processBatch($rows);
$this->log("=== updateWorkshopCacheForHome home={$homeId} end ===");
return $results;
}
/**
* Update Workshop cache for all mods associated with a specific profile.
*
* @return array<string,mixed>
*/
public function updateWorkshopCacheForProfile(int $profileId): array
{
$this->log("=== updateWorkshopCacheForProfile profile={$profileId} start ===");
$rows = $this->repo->listDistinctEnabledWorkshopIdsForProfile($profileId);
$results = $this->processBatch($rows);
$this->log("=== updateWorkshopCacheForProfile profile={$profileId} end ===");
return $results;
}
/**
* Update a single Workshop mod on a specific agent.
*
* @return array<string,mixed>
*/
public function updateSingleWorkshopMod(int $agentId, string $appId, string $workshopId): array
{
$workshopId = preg_replace('/[^0-9]/', '', $workshopId) ?? '';
if ($workshopId === '') {
return ['success' => false, 'error' => 'Workshop ID must be numeric.'];
}
$this->log("=== updateSingleWorkshopMod agent={$agentId} app={$appId} mod={$workshopId} ===");
$row = [
'agent_id' => $agentId,
'workshop_app_id' => $appId,
'workshop_id' => $workshopId,
'title' => '',
];
$results = $this->processBatch([$row]);
return $results[0] ?? ['success' => false, 'error' => 'No result.'];
}
// ------------------------------------------------------------------
// Internal batch processor
// ------------------------------------------------------------------
/**
* For each (agent_id, workshop_app_id, workshop_id) triplet, run a
* SteamCMD validate download and update the cache table.
*
* @param array<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
private function processBatch(array $rows): array
{
$results = [];
// Group by agent_id so we can build one connection per agent
$grouped = [];
foreach ($rows as $row) {
$aid = (int)($row['agent_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$grouped[$aid][] = $row;
}
foreach ((array)$grouped as $agentId => $agentRows) {
$home = $this->getAgentHome((int)$agentId);
if ($home === null) {
$this->log("Agent {$agentId}: cannot build remote skipping.");
foreach ((array)$agentRows as $row) {
$results[] = $this->buildResult($row, false, 'Agent home not found.');
}
continue;
}
$remote = $this->buildRemote($home);
if ($remote === null || $remote->status_chk() !== 1) {
$this->log("Agent {$agentId}: offline or unreachable skipping.");
foreach ((array)$agentRows as $row) {
$this->repo->upsertCacheEntry(
(int)$agentId,
$this->detectOsType($home),
(string)($row['workshop_app_id'] ?? ''),
(string)($row['workshop_id'] ?? ''),
'',
'failed',
null,
'Agent offline during scheduled update.'
);
$results[] = $this->buildResult($row, false, 'Agent offline.');
}
continue;
}
$osType = $this->detectOsType($home);
foreach ((array)$agentRows as $row) {
$appId = (string)($row['workshop_app_id'] ?? '');
$workshopId = (string)($row['workshop_id'] ?? '');
$result = $this->runSingleUpdate($remote, (int)$agentId, $osType, $appId, $workshopId, $home);
$results[] = $result;
}
}
return $results;
}
/**
* Run SteamCMD workshop_download_item validate for a single mod and
* update the cache table accordingly.
*
* @return array<string,mixed>
*/
private function runSingleUpdate(
object $remote,
int $agentId,
string $osType,
string $appId,
string $workshopId,
array $home
): array {
$this->log("Update: agent={$agentId} app={$appId} mod={$workshopId}");
// Build cache path from the profile (if available) or a sensible default
$profile = $this->repo->getProfileByAppId($appId);
$steamCmdPath = '/home/gameserver/steamcmd/steamcmd.sh';
$cachePath = '';
if ($profile !== null) {
$vars = $this->installer->buildTemplateVars($home, $profile, $workshopId, '', $steamCmdPath);
$cachePath = $this->installer->resolveTemplate((string)($profile['cache_path_template'] ?? ''), $vars);
$steamCmdPath = $vars['{steamcmd_path}'];
}
if ($cachePath === '') {
$cachePath = "/home/gameserver/steamcmd/steamapps/workshop/content/{$appId}/{$workshopId}";
}
// Run SteamCMD with validate flag
$cmd = implode(' ', [
escapeshellarg($steamCmdPath),
'+login', 'anonymous',
'+workshop_download_item', escapeshellarg($appId), escapeshellarg($workshopId),
'validate',
'+quit',
]);
$this->log("STEAMCMD CMD: {$cmd}");
$output = (string)$remote->exec($cmd);
$this->log('STEAMCMD OUTPUT: ' . substr($output, 0, 300));
// Verify by checking path existence
$exists = $remote->rfile_exists($cachePath);
$success = ($exists === 1);
if ($success) {
$this->log("STEAMCMD SUCCESS app={$appId} mod={$workshopId}");
$this->repo->upsertCacheEntry($agentId, $osType, $appId, $workshopId, $cachePath, 'cached');
} else {
$errorMsg = 'SteamCMD validate completed but cache path not found: ' . $cachePath;
$this->log("STEAMCMD FAILURE app={$appId} mod={$workshopId}: {$errorMsg}");
$this->repo->upsertCacheEntry($agentId, $osType, $appId, $workshopId, $cachePath, 'failed', null, $errorMsg);
}
return $this->buildResult(
['agent_id' => $agentId, 'workshop_app_id' => $appId, 'workshop_id' => $workshopId],
$success,
$success ? 'OK' : 'SteamCMD failed or cache path missing.'
);
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
/** Return a minimal home-like array for a given agent so we can build a remote. */
private function getAgentHome(int $agentId): ?array
{
// We just need ip/port/key/timeout for the remote library connection.
// Query the remote_servers table directly via the repository's db.
// Use the OGPDatabase instance stored inside WorkshopRepository.
$prefix = $this->repo->getPrefix();
$row = $this->repo->getAgentRow($agentId);
return $row;
}
private function buildRemote(array $home): ?object
{
if (!class_exists('OGPRemoteLibrary')) {
@require_once __DIR__ . '/../../../includes/lib_remote.php';
}
if (!class_exists('OGPRemoteLibrary')) {
return null;
}
$ip = (string)($home['agent_ip'] ?? '');
$port = (string)($home['agent_port'] ?? '');
$key = (string)($home['encryption_key'] ?? '');
$timeout = isset($home['timeout']) ? (int)$home['timeout'] : 30;
if ($ip === '' || $port === '') {
return null;
}
return new OGPRemoteLibrary($ip, $port, $key, $timeout);
}
private function detectOsType(array $home): string
{
$gameKey = strtolower((string)($home['game_key'] ?? ''));
if (preg_match('/win/', $gameKey)) {
return 'windows';
}
return 'linux';
}
/** @return array<string,mixed> */
private function buildResult(array $row, bool $success, string $message): array
{
return [
'agent_id' => $row['agent_id'] ?? 0,
'workshop_app_id' => $row['workshop_app_id'] ?? '',
'workshop_id' => $row['workshop_id'] ?? '',
'success' => $success,
'message' => $message,
];
}
private function log(string $message): void
{
$line = '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
}
}