Installer-method servers had full filenames as current_version (e.g. "DeceasedCraft_Beta_DH_Edition_5.10.16") which prevented reaching pending_calibration. Now only uses DB current_version if file_id is also set (validated) or detection method isn't installer. Migration clears existing stale rows → pending_calibration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
410 lines
16 KiB
PHP
410 lines
16 KiB
PHP
<?php
|
|
|
|
/**
|
|
* ModpackChecker Cron Command — hybrid "Magic & Manual" detection.
|
|
*
|
|
* 1. Egg variables (fastest)
|
|
* 2. File detection via DaemonFileRepository (manifest.json, modrinth.index.json)
|
|
* 3. API version check for detected packs
|
|
*
|
|
* USAGE: php artisan modpackchecker:check
|
|
* CRON: 0 0,6,12,18 * * * cd /var/www/pterodactyl && php artisan modpackchecker:check
|
|
*/
|
|
|
|
namespace Pterodactyl\Console\Commands;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Pterodactyl\Models\Server;
|
|
use Pterodactyl\Repositories\Wings\DaemonFileRepository;
|
|
use Pterodactyl\Services\ModpackApiService;
|
|
|
|
class CheckModpackUpdates extends Command
|
|
{
|
|
protected $signature = 'modpackchecker:check';
|
|
protected $description = 'Check all servers for modpack updates (hybrid detection)';
|
|
|
|
public function __construct(
|
|
private ModpackApiService $apiService,
|
|
private DaemonFileRepository $fileRepository
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
public function handle(): int
|
|
{
|
|
$this->info('Starting modpack update check (hybrid detection)...');
|
|
|
|
$servers = Server::all();
|
|
$this->info("Scanning {$servers->count()} servers");
|
|
|
|
foreach ($servers as $server) {
|
|
$this->processServer($server);
|
|
sleep(2);
|
|
}
|
|
|
|
$this->info('Modpack update check complete!');
|
|
return 0;
|
|
}
|
|
|
|
private function processServer(Server $server): void
|
|
{
|
|
$this->line("Checking: {$server->name} ({$server->uuid})");
|
|
|
|
// Skip user-overridden servers (manual config)
|
|
$existing = DB::table('modpackchecker_servers')
|
|
->where('server_uuid', $server->uuid)
|
|
->first();
|
|
|
|
if ($existing && $existing->is_user_overridden) {
|
|
if ($existing->platform && $existing->modpack_id) {
|
|
$this->checkVersion($server, $existing->platform, $existing->modpack_id, 'manual', null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Skip ignored servers
|
|
if ($existing && $existing->is_ignored) {
|
|
return;
|
|
}
|
|
|
|
// Step 1: modpack_installations table (fastest, most reliable)
|
|
// This is a Pterodactyl table — may not exist on all panels
|
|
try {
|
|
$installation = DB::table('modpack_installations')
|
|
->where('server_id', $server->id)
|
|
->first();
|
|
|
|
if ($installation && !empty($installation->provider) && !empty($installation->modpack_id)) {
|
|
$this->checkVersion($server, $installation->provider, (string) $installation->modpack_id, 'installer', null);
|
|
return;
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Table doesn't exist — skip this detection method
|
|
}
|
|
|
|
// Step 2: Egg variables
|
|
$platform = $this->getVariable($server, 'MODPACK_PLATFORM');
|
|
$modpackId = $this->getVariable($server, 'MODPACK_ID');
|
|
|
|
if (empty($modpackId) && !empty($platform)) {
|
|
$modpackId = match($platform) {
|
|
'curseforge' => $this->getVariable($server, 'CURSEFORGE_ID'),
|
|
'modrinth' => $this->getVariable($server, 'MODRINTH_PROJECT_ID'),
|
|
'ftb' => $this->getVariable($server, 'FTB_MODPACK_ID'),
|
|
'technic' => $this->getVariable($server, 'TECHNIC_SLUG'),
|
|
default => null
|
|
};
|
|
}
|
|
|
|
if (!empty($platform) && !empty($modpackId)) {
|
|
$this->checkVersion($server, $platform, $modpackId, 'egg', null);
|
|
return;
|
|
}
|
|
|
|
// Step 3: File-based detection via DaemonFileRepository (last resort)
|
|
$detected = $this->detectFromFiles($server);
|
|
if ($detected) {
|
|
$this->checkVersion($server, $detected['platform'], $detected['modpack_id'], 'file', $detected['installed_version'] ?? null);
|
|
return;
|
|
}
|
|
|
|
// Step 4: Nothing found
|
|
$this->warn(" No modpack detected");
|
|
$this->updateDatabase($server, [
|
|
'status' => 'unconfigured',
|
|
'detection_method' => 'unknown',
|
|
'last_checked' => now(),
|
|
]);
|
|
}
|
|
|
|
private function detectFromFiles(Server $server): ?array
|
|
{
|
|
try {
|
|
$this->fileRepository->setServer($server);
|
|
} catch (\Exception $e) {
|
|
$this->line(" [debug] Cannot connect to Wings: " . $e->getMessage());
|
|
return null;
|
|
}
|
|
|
|
// CurseForge: manifest.json
|
|
$cf = $this->detectCurseForge($server);
|
|
if ($cf) return $cf;
|
|
|
|
// Modrinth: modrinth.index.json
|
|
$mr = $this->detectModrinth($server);
|
|
if ($mr) return $mr;
|
|
|
|
// FTB: version.json
|
|
$ftb = $this->detectFtb($server);
|
|
if ($ftb) return $ftb;
|
|
|
|
// BCC: logs/latest.log
|
|
$bcc = $this->detectFromBccLog($server);
|
|
if ($bcc) return $bcc;
|
|
|
|
return null;
|
|
}
|
|
|
|
private function detectCurseForge(Server $server): ?array
|
|
{
|
|
$paths = ['manifest.json', 'minecraftinstance.json'];
|
|
|
|
foreach ($paths as $path) {
|
|
try {
|
|
$content = $this->fileRepository->getContent($path);
|
|
$data = json_decode($content, true);
|
|
|
|
if (!is_array($data)) continue;
|
|
|
|
// Standard manifest.json (CurseForge export)
|
|
if ($path === 'manifest.json' && ($data['manifestType'] ?? '') === 'minecraftModpack') {
|
|
$projectId = $data['projectID'] ?? null;
|
|
if ($projectId) {
|
|
$this->info(" Detected CurseForge via {$path} (projectID: {$projectId})");
|
|
return [
|
|
'platform' => 'curseforge',
|
|
'modpack_id' => (string) $projectId,
|
|
'name' => $data['name'] ?? null,
|
|
'installed_version' => $data['version'] ?? null,
|
|
];
|
|
}
|
|
}
|
|
|
|
// minecraftinstance.json (CurseForge launcher install)
|
|
if ($path === 'minecraftinstance.json' && isset($data['projectID'])) {
|
|
$this->info(" Detected CurseForge via {$path} (projectID: {$data['projectID']})");
|
|
return [
|
|
'platform' => 'curseforge',
|
|
'modpack_id' => (string) $data['projectID'],
|
|
'name' => $data['name'] ?? null,
|
|
'installed_version' => $data['version'] ?? null,
|
|
];
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->line(" [debug] {$path}: " . $e->getMessage());
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function detectModrinth(Server $server): ?array
|
|
{
|
|
try {
|
|
$content = $this->fileRepository->getContent('modrinth.index.json');
|
|
$data = json_decode($content, true);
|
|
|
|
if (is_array($data) && isset($data['formatVersion'])) {
|
|
$slug = isset($data['name'])
|
|
? preg_replace('/[^a-z0-9-]/', '', strtolower(str_replace(' ', '-', $data['name'])))
|
|
: null;
|
|
if ($slug) {
|
|
$this->info(" Detected Modrinth pack (slug: {$slug})");
|
|
return [
|
|
'platform' => 'modrinth',
|
|
'modpack_id' => $slug,
|
|
'name' => $data['name'] ?? null,
|
|
];
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->line(" [debug] modrinth.index.json: " . $e->getMessage());
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function detectFtb(Server $server): ?array
|
|
{
|
|
try {
|
|
$content = $this->fileRepository->getContent('version.json');
|
|
$data = json_decode($content, true);
|
|
|
|
if (is_array($data) && isset($data['id']) && isset($data['parent'])) {
|
|
$this->info(" Detected FTB pack (id: {$data['parent']})");
|
|
return [
|
|
'platform' => 'ftb',
|
|
'modpack_id' => (string) $data['parent'],
|
|
'name' => $data['name'] ?? null,
|
|
];
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->line(" [debug] version.json (FTB): " . $e->getMessage());
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function checkVersion(Server $server, string $platform, string $modpackId, string $method, ?string $installedVersion): void
|
|
{
|
|
try {
|
|
$latestData = $this->apiService->fetchLatestVersion($platform, $modpackId);
|
|
$latestVersion = $latestData['version'] ?? 'Unknown';
|
|
$latestFileId = $latestData['file_id'] ?? null;
|
|
|
|
// Get current_version: manifest arg > egg variable > existing DB
|
|
$currentVersion = $installedVersion;
|
|
$currentFileId = null;
|
|
|
|
if (empty($currentVersion)) {
|
|
$currentVersion = $this->getVariable($server, 'MODPACK_CURRENT_VERSION');
|
|
}
|
|
|
|
$existing = DB::table('modpackchecker_servers')
|
|
->where('server_uuid', $server->uuid)
|
|
->first();
|
|
|
|
// Only use DB value if file_id is set (validated) OR detection wasn't installer
|
|
if (empty($currentVersion) && $existing) {
|
|
if ($method !== 'installer' || !empty($existing->current_file_id)) {
|
|
$currentVersion = $existing->current_version ?? null;
|
|
}
|
|
}
|
|
$currentFileId = $existing->current_file_id ?? null;
|
|
|
|
// Truth File: read .modpack-checker.json from server filesystem
|
|
if (empty($currentFileId)) {
|
|
try {
|
|
$this->fileRepository->setServer($server);
|
|
$truthRaw = $this->fileRepository->getContent('.modpack-checker.json');
|
|
$truthFile = json_decode($truthRaw, true);
|
|
if (!empty($truthFile['file_id'])) {
|
|
$currentFileId = $truthFile['file_id'];
|
|
$currentVersion = $currentVersion ?: ($truthFile['version'] ?? null);
|
|
$this->line(" [truth] Read file_id {$currentFileId} from .modpack-checker.json");
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Truth File doesn't exist yet
|
|
}
|
|
}
|
|
|
|
// Legacy manifest.json: extract fileID, write Truth File
|
|
if (empty($currentFileId)) {
|
|
try {
|
|
$this->fileRepository->setServer($server);
|
|
$manifest = json_decode($this->fileRepository->getContent('manifest.json'), true);
|
|
if (!empty($manifest['files'][0]['fileID'])) {
|
|
$currentFileId = (string) $manifest['files'][0]['fileID'];
|
|
$this->writeTruthFile($server, $modpackId, $currentFileId, $manifest['version'] ?? null);
|
|
$currentVersion = $currentVersion ?: ($manifest['version'] ?? null);
|
|
$this->line(" [manifest] Extracted file_id {$currentFileId}, wrote Truth File");
|
|
}
|
|
} catch (\Exception $e) {
|
|
// No manifest
|
|
}
|
|
}
|
|
|
|
// NEVER seed from latest — if unknown, mark pending_calibration
|
|
if (empty($currentVersion) && empty($currentFileId)) {
|
|
$this->updateDatabase($server, [
|
|
'platform' => $platform,
|
|
'modpack_id' => $modpackId,
|
|
'modpack_name' => $latestData['name'],
|
|
'latest_version' => $latestVersion,
|
|
'latest_file_id' => $latestFileId,
|
|
'status' => 'pending_calibration',
|
|
'detection_method' => $method,
|
|
'error_message' => null,
|
|
'last_checked' => now(),
|
|
]);
|
|
$this->info(" ⏳ PENDING: {$latestData['name']} — calibration required");
|
|
return;
|
|
}
|
|
|
|
// Compare: prefer file ID, fall back to string
|
|
if ($currentFileId && $latestFileId) {
|
|
$updateAvailable = $latestFileId !== $currentFileId;
|
|
} else {
|
|
$updateAvailable = $currentVersion !== $latestVersion;
|
|
}
|
|
|
|
$this->updateDatabase($server, [
|
|
'platform' => $platform,
|
|
'modpack_id' => $modpackId,
|
|
'modpack_name' => $latestData['name'],
|
|
'current_version' => $currentVersion,
|
|
'latest_version' => $latestVersion,
|
|
'current_file_id' => $currentFileId,
|
|
'latest_file_id' => $latestFileId,
|
|
'status' => $updateAvailable ? 'update_available' : 'up_to_date',
|
|
'detection_method' => $method,
|
|
'error_message' => null,
|
|
'last_checked' => now(),
|
|
]);
|
|
|
|
$icon = $updateAvailable ? '🟠 UPDATE' : '🟢 OK';
|
|
$this->info(" {$icon}: {$latestData['name']} — current: {$currentVersion}, latest: {$latestVersion} [{$method}]");
|
|
|
|
} catch (\Exception $e) {
|
|
$this->error(" Error: {$e->getMessage()}");
|
|
$this->updateDatabase($server, [
|
|
'platform' => $platform,
|
|
'modpack_id' => $modpackId,
|
|
'detection_method' => $method,
|
|
'status' => 'error',
|
|
'error_message' => $e->getMessage(),
|
|
'last_checked' => now(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* BCC log parsing — detect modpack from BetterCompatibilityChecker output.
|
|
*/
|
|
private function detectFromBccLog(Server $server): ?array
|
|
{
|
|
try {
|
|
$log = $this->fileRepository->getContent('logs/latest.log');
|
|
if (preg_match('/Loaded BetterCompatibilityChecker - Modpack: (.+?) \| Version: (.+)/', $log, $m)) {
|
|
$name = trim($m[1]);
|
|
$version = trim($m[2]);
|
|
if ($name !== 'CHANGE_ME' && $version !== 'CHANGE_ME') {
|
|
$this->info(" Detected via BCC log: {$name} v{$version}");
|
|
return [
|
|
'platform' => 'unknown',
|
|
'modpack_id' => strtolower(str_replace(' ', '-', $name)),
|
|
'name' => $name,
|
|
'installed_version' => $version,
|
|
];
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->line(" [debug] BCC log: " . $e->getMessage());
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Write .modpack-checker.json Truth File to server filesystem.
|
|
*/
|
|
private function writeTruthFile(Server $server, string $projectId, string $fileId, ?string $version): void
|
|
{
|
|
try {
|
|
$this->fileRepository->setServer($server);
|
|
$this->fileRepository->putContent('.modpack-checker.json', json_encode([
|
|
'extension' => 'modpackchecker',
|
|
'project_id' => $projectId,
|
|
'file_id' => $fileId,
|
|
'version' => $version,
|
|
'calibrated_at' => now()->toIso8601String(),
|
|
], JSON_PRETTY_PRINT));
|
|
} catch (\Exception $e) {
|
|
\Log::warning('[MVC] Could not write Truth File: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
private function getVariable(Server $server, string $name): ?string
|
|
{
|
|
$variable = $server->variables()
|
|
->where('env_variable', $name)
|
|
->first();
|
|
return $variable?->server_value;
|
|
}
|
|
|
|
private function updateDatabase(Server $server, array $data): void
|
|
{
|
|
DB::table('modpackchecker_servers')->updateOrInsert(
|
|
['server_uuid' => $server->uuid],
|
|
array_merge($data, ['updated_at' => now()])
|
|
);
|
|
}
|
|
}
|