pdo = $pdo; } /** * Parse and validate a Playnite export file */ public function parsePlayniteFile(string $filePath): array { if (!file_exists($filePath)) { throw new \Exception("Playnite export file not found: {$filePath}"); } $jsonContent = file_get_contents($filePath); if ($jsonContent === false) { throw new \Exception("Failed to read Playnite export file: {$filePath}"); } $games = json_decode($jsonContent, true); if (json_last_error() !== JSON_ERROR_NONE) { throw new \Exception("Invalid JSON in Playnite export file: " . json_last_error_msg()); } if (!is_array($games)) { throw new \Exception("Playnite export file must contain an array of games"); } return $this->validateAndTransformGames($games); } /** * Validate and transform Playnite games data */ private function validateAndTransformGames(array $games): array { $transformedGames = []; $errors = []; $warnings = []; foreach ($games as $index => $game) { try { $transformedGame = $this->transformPlayniteGame($game, $index + 1); if ($transformedGame) { $transformedGames[] = $transformedGame; } } catch (\Exception $e) { $errors[] = "Game at index {$index}: " . $e->getMessage(); } } return [ 'games' => $transformedGames, 'errors' => $errors, 'warnings' => $warnings, 'total' => count($games), 'valid' => count($transformedGames), 'invalid' => count($errors) ]; } /** * Transform a single Playnite game to our internal format */ private function transformPlayniteGame(array $game, int $index): array { /* // Validate required fields if (empty($game['Name'])) { throw new \Exception("Missing game name"); } if (empty($game['GameId'])) { throw new \Exception("Missing GameId"); } */ // Find or create source $source = $this->findOrCreateSource($game); // Transform the game data $transformed = [ 'title' => $game['Name'], 'game_key' => $this->generateGameKey($game['Name'], $this->extractPlatformFromPlaynite($game)), 'description' => $this->cleanHtml($game['Description'] ?? ''), 'platform_game_id' => $game['GameId'], 'platform' => $this->extractPlatformFromPlaynite($game), 'source_id' => $source['id'], // Rich media 'background_image' => $game['BackgroundImage'] ?? null, 'cover_image' => $game['CoverImage'] ?? null, 'icon' => $game['Icon'] ?? null, // Multiple entities as JSON 'genres_json' => json_encode($game['Genres'] ?? []), 'developers_json' => json_encode($game['Developers'] ?? []), 'publishers_json' => json_encode($game['Publishers'] ?? []), 'tags_json' => json_encode($game['Tags'] ?? []), 'features_json' => json_encode($game['Features'] ?? []), 'links_json' => json_encode($game['Links'] ?? []), 'series_json' => json_encode($game['Series'] ?? []), 'age_ratings_json' => json_encode($game['AgeRatings'] ?? []), // Play statistics 'playtime_minutes' => $this->parsePlaytime($game['Playtime'] ?? 0), 'play_count' => $game['PlayCount'] ?? 0, 'install_size' => $this->parseInstallSize($game['InstallSize'] ?? null), 'completion_status' => $game['CompletionStatus']['Name'] ?? null, // Enhanced ratings 'rating' => $this->normalizeRating($game['CriticScore'] ?? null), 'critic_score' => $game['CriticScore'] ?? null, 'community_score' => $game['CommunityScore'] ?? null, 'user_score' => $game['UserScore'] ?? null, // Legacy single-value fields (take first from arrays if available) 'genre' => $this->getFirstItemName($game['Genres'] ?? []), 'developer' => $this->getFirstItemName($game['Developers'] ?? []), 'publisher' => $this->getFirstItemName($game['Publishers'] ?? []), // Platform-specific data 'steam_app_id' => $this->extractSteamAppId($game), // Playnite-specific metadata // 'is_installed' => $this->toBoolean($game['IsInstalled'] ?? false), //'is_favorite' => $this->toBoolean($game['Favorite'] ?? false), //'is_custom_game' => $this->toBoolean($game['IsCustomGame'] ?? false), //'installation_status' => $game['InstallationStatus'] ?? 0, // Timestamps 'added_at' => isset($game['Added']) ? date('Y-m-d H:i:s', strtotime($game['Added'])) : null, 'modified_at' => isset($game['Modified']) ? date('Y-m-d H:i:s', strtotime($game['Modified'])) : null, 'last_played_at' => isset($game['LastActivity']) ? date('Y-m-d H:i:s', strtotime($game['LastActivity'])) : null, 'release_date' => isset($game['ReleaseDate']['ReleaseDate']) ? date('Y-m-d', strtotime($game['ReleaseDate']['ReleaseDate'])) : null, // Playnite metadata 'metadata' => json_encode([ 'playnite_id' => $game['Id'] ?? null, 'version' => $game['Version'] ?? null, 'hidden' => $this->toBoolean($game['Hidden'] ?? false), 'notes' => $game['Notes'] ?? null, 'manual' => $game['Manual'] ?? null, 'pre_script' => $game['PreScript'] ?? null, 'post_script' => $game['PostScript'] ?? null, 'game_started_script' => $game['GameStartedScript'] ?? null, 'use_global_scripts' => [ 'pre' => $this->toBoolean($game['UseGlobalPreScript'] ?? true), 'post' => $this->toBoolean($game['UseGlobalPostScript'] ?? true), 'game_started' => $this->toBoolean($game['UseGlobalGameStartedScript'] ?? true) ] ]) ]; return $transformed; } /** * Find or create a source for the game */ private function findOrCreateSource(array $game): array { $sourceName = $game['Source']['Name'] ?? 'Playnite'; $sourceId = $game['Source']['Id'] ?? null; // Try to find existing source $stmt = $this->pdo->prepare("SELECT id, display_name FROM sources WHERE display_name = :name OR id = :source_id"); $stmt->execute([ 'name' => $sourceName, 'source_id' => $sourceId ]); $source = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$source) { // Create new source $stmt = $this->pdo->prepare("INSERT INTO sources (display_name, created_at, updated_at) VALUES (:name, NOW(), NOW())"); $stmt->execute(['name' => $sourceName]); $source = ['id' => $this->pdo->lastInsertId(), 'display_name' => $sourceName]; } return $source; } /** * Generate a consistent game key for grouping */ private function generateGameKey(string $title): string { return Game::generateGameKey($title); } /** * Extract platform from Playnite data */ private function extractPlatformFromPlaynite(array $game): string { if (isset($game['Platforms']) && is_array($game['Platforms'])) { $platformNames = array_map(function($platform) { return $platform['Name'] ?? 'Unknown'; }, $game['Platforms']); return implode(', ', $platformNames); } return 'PC'; // Default platform } /** * Extract Steam App ID from game links or metadata */ private function extractSteamAppId(array $game): ?string { if (isset($game['Links']) && is_array($game['Links'])) { foreach ($game['Links'] as $link) { if (isset($link['Name']) && strtolower($link['Name']) === 'steam' && preg_match('/\/app\/(\d+)/', $link['Url'], $matches)) { return $matches[1]; } } } return null; } /** * Parse playtime from Playnite format (usually in seconds) */ private function parsePlaytime($playtime): int { if (is_numeric($playtime)) { return (int)($playtime / 60); // Convert seconds to minutes } return 0; } /** * Parse install size */ private function parseInstallSize($installSize): ?int { if (is_numeric($installSize)) { return (int)$installSize; } return null; } /** * Normalize rating to 0-10 scale */ private function normalizeRating($rating): ?float { if (is_numeric($rating)) { $rating = (float)$rating; // If rating is 0-100 scale, convert to 0-10 if ($rating > 10) { return $rating / 10; } return $rating; } return null; } /** * Clean HTML from description */ private function cleanHtml(?string $html): ?string { if (!$html) { return null; } // Remove HTML tags but keep basic formatting $text = strip_tags($html); // Decode HTML entities $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8'); // Clean up extra whitespace $text = preg_replace('/\s+/', ' ', $text); return trim($text); } /** * Get first item name from an array of objects */ private function getFirstItemName(array $items): ?string { if (empty($items)) { return null; } $first = reset($items); return $first['Name'] ?? null; } /** * Import games to database */ public function importGames(array $games, bool $updateExisting = true): array { $results = [ 'imported' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => [] ]; foreach ($games as $gameData) { try { $existingGame = $this->findExistingGame($gameData); if ($existingGame && $updateExisting) { $this->updateGame($existingGame['id'], $gameData); $results['updated']++; } elseif (!$existingGame) { $this->insertGame($gameData); $results['imported']++; } else { $results['skipped']++; } } catch (\Exception $e) { $results['errors'][] = "Failed to import {$gameData['title']}: " . $e->getMessage(); } } return $results; } /** * Find existing game by platform_game_id and source_id */ private function findExistingGame(array $gameData): ?array { $stmt = $this->pdo->prepare(" SELECT id, title, platform_game_id, source_id FROM games WHERE platform_game_id = :platform_game_id AND source_id = :source_id "); $stmt->execute([ 'platform_game_id' => $gameData['platform_game_id'], 'source_id' => $gameData['source_id'] ]); return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; } /** * Insert new game */ private function insertGame(array $gameData): void { // Use the Game model's create method which respects fillable fields $gameModel = new Game($this->pdo); $gameModel->create($gameData); } /** * Update existing game */ private function updateGame(int $gameId, array $gameData): void { // Use the Game model's update method which respects fillable fields $gameModel = new Game($this->pdo); $gameModel->update($gameId, $gameData); } /** * Convert a value to boolean, handling empty strings properly */ private function toBoolean($value): bool { if ($value === null || $value === false || $value === 0 || $value === '0') { return false; } if ($value === true || $value === 1 || $value === '1') { return true; } if (is_string($value)) { return !empty(trim($value)); } return (bool) $value; } }