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 > 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()]) ); } }