Files
firefrost-services/services/modpack-version-checker/blueprint-extension/app/Console/Commands/CheckModpackUpdates.php
Claude (Chronicler #83 - The Compiler) b84958c0ee v1.1.0 Priorities 2-5: date seeding, new endpoints, BCC detection
Priority 2 — Date-time seeding:
- fetchFileHistory() for CurseForge, Modrinth, FTB
- seedCurrentVersion() matches release closest to server install date
- Falls back to latest if no history or no install date

Priority 3 — New endpoints:
- GET /servers/{server}/status — zero-click cached status
- GET /servers/{server}/releases — recalibrate dropdown (10 releases)
- POST /servers/{server}/calibrate — save user's version selection
- POST /servers/{server}/ignore — toggle is_ignored flag

Priority 5 — BCC log parsing:
- detectFromBccLog() reads logs/latest.log for BetterCompatibilityChecker
- Extracts modpack name + version from BCC output line
- Skips CHANGE_ME values

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:42:34 -05:00

378 lines
14 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)
$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;
}
// 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 > egg variable > existing DB > seed with latest
$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();
if (empty($currentVersion)) {
$currentVersion = $existing->current_version ?? null;
}
$currentFileId = $existing->current_file_id ?? null;
// First time — try date-time seeding, fall back to latest
if (empty($currentVersion)) {
$seeded = $this->seedCurrentVersion($server, $platform, $modpackId, $latestVersion, $latestFileId);
$currentVersion = $seeded['version'];
$currentFileId = $seeded['file_id'];
}
// 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;
}
/**
* Seed current version using date-time heuristic.
* Finds the release closest to (but not after) the server's created_at date.
*/
private function seedCurrentVersion(Server $server, string $platform, string $modpackId, string $fallbackVersion, ?string $fallbackFileId): array
{
try {
$installDate = $server->created_at?->toISOString();
if (!$installDate) {
return ['version' => $fallbackVersion, 'file_id' => $fallbackFileId];
}
$history = $this->apiService->fetchFileHistory($platform, $modpackId);
if (empty($history)) {
return ['version' => $fallbackVersion, 'file_id' => $fallbackFileId];
}
// Find the release closest to but not after install date
$matched = collect($history)
->filter(fn($f) => !empty($f['release_date']) && $f['release_date'] <= $installDate)
->sortByDesc('release_date')
->first();
if ($matched) {
$this->line(" [seed] Matched version {$matched['version']} (released {$matched['release_date']}) to install date {$installDate}");
return [
'version' => $matched['version'],
'file_id' => $matched['file_id'] ?? $fallbackFileId,
];
}
} catch (\Exception $e) {
$this->line(" [seed] Heuristic failed: " . $e->getMessage());
}
return ['version' => $fallbackVersion, 'file_id' => $fallbackFileId];
}
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()])
);
}
}