pdo = $pdo; $this->importService = new PlayniteImportService($pdo); // Set up log file $logDir = __DIR__ . '/../../../logs'; if (!is_dir($logDir)) { mkdir($logDir, 0755, true); } $this->logFile = $logDir . '/playnite_api_' . date('Y-m-d') . '.log'; // Create media directory if it doesn't exist $mediaDir = __DIR__ . '/../../../public/media/playnite'; if (!is_dir($mediaDir)) { mkdir($mediaDir, 0755, true); } } /** * Log a message to the API log file */ private function log(string $message, array $context = []): void { $timestamp = date('Y-m-d H:i:s'); $logLine = "[$timestamp] $message"; if (!empty($context)) { $logLine .= "\n" . json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } $logLine .= "\n" . str_repeat('-', 80) . "\n"; error_log($logLine, 3, $this->logFile); } /** * Get the raw request body */ private function getRawBody(Request $request): string { $body = $request->getBody(); $body->rewind(); $content = $body->getContents(); $body->rewind(); // Reset the stream position for potential later use return $content; } /** * @OA\Post( * path="/playnite/insert", * summary="Insert or update games from Playnite", * tags={"Playnite"}, * operationId="insertGames", * @OA\RequestBody( * required=true, * @OA\JsonContent( * required={"games"}, * @OA\Property( * property="games", * type="array", * @OA\Items(type="object") * ), * @OA\Property( * property="update_existing", * type="boolean", * default=true, * description="Whether to update existing games" * ) * ) * ), * @OA\Response( * response=200, * description="Games successfully imported/updated", * @OA\JsonContent( * @OA\Property(property="success", type="boolean"), * @OA\Property(property="result", type="object") * ) * ), * @OA\Response( * response=400, * description="Invalid input" * ), * @OA\Response( * response=500, * description="Server error" * ) * ) * * @param Request $request * @param Response $response * @param array $args * @return Response */ public function insertGames(Request $request, Response $response, $args) { $requestId = uniqid('req_'); $startTime = microtime(true); // Log request details $rawBody = $this->getRawBody($request); $parsedBody = $request->getParsedBody(); $this->log("[$requestId] Received Playnite API request", [ 'method' => $request->getMethod(), 'uri' => (string)$request->getUri(), 'headers' => $request->getHeaders(), 'raw_body' => $rawBody, 'parsed_body' => $parsedBody, 'content_type' => $request->getHeaderLine('Content-Type'), 'client_ip' => $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown' ]); try { // Validate request if (!isset($parsedBody['games']) || !is_array($parsedBody['games'])) { $error = 'Games data is required'; $this->log("[$requestId] Validation failed: $error"); return $this->jsonResponse($response->withStatus(400), [ 'success' => false, 'error' => $error, 'request_id' => $requestId ]); } $this->log("[$requestId] Starting import of " . count($parsedBody['games']) . " games"); // Process the import with detailed logging $importResult = $this->importService->importGames( $parsedBody['games'], true, // Always update existing function($message) use ($requestId) { $this->log("[$requestId] $message"); } ); $executionTime = round((microtime(true) - $startTime) * 1000, 2); $this->log("[$requestId] Import completed in {$executionTime}ms", [ 'imported' => $importResult['imported'] ?? 0, 'updated' => $importResult['updated'] ?? 0, 'skipped' => $importResult['skipped'] ?? 0, 'errors' => count($importResult['errors'] ?? []), 'execution_time_ms' => $executionTime ]); return $this->jsonResponse($response, [ 'success' => true, 'request_id' => $requestId, 'result' => $importResult, 'execution_time_ms' => $executionTime ]); } catch (\Exception $e) { $errorDetails = [ 'error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString(), 'request_body' => $rawBody, 'execution_time_ms' => round((microtime(true) - $startTime) * 1000, 2) ]; $this->log("[$requestId] Exception during import", $errorDetails); // Don't expose sensitive error details in production $errorResponse = [ 'success' => false, 'error' => 'An error occurred while processing your request', 'request_id' => $requestId ]; // Include more details in development if (getenv('APP_ENV') === 'development') { $errorResponse['debug'] = [ 'message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine() ]; } return $this->jsonResponse($response->withStatus(500), $errorResponse); } } /** * @OA\Post( * path="/playnite/media", * summary="Update game media from Playnite", * tags={"Playnite"}, * operationId="updateMedia", * @OA\RequestBody( * required=true, * @OA\JsonContent( * required={"game_id", "plugin_id"}, * @OA\Property(property="game_id", type="string", example="1486920"), * @OA\Property(property="plugin_id", type="string", example="cb91dfc9-b977-43bf-8e70-55f46e410fab"), * @OA\Property(property="cover_image", type="string", nullable=true), * @OA\Property(property="background_image", type="string", nullable=true), * @OA\Property(property="icon", type="string", nullable=true) * ) * ), * @OA\Response( * response=200, * description="Media updated successfully" * ), * @OA\Response( * response=400, * description="Invalid input" * ) * ) */ public function updateMedia(Request $request, Response $response, $args) { $requestId = uniqid('req_'); $startTime = microtime(true); // Log request details $rawBody = $this->getRawBody($request); $data = json_decode($rawBody, true) ?: []; $this->log("[$requestId] Received Playnite media update request", [ 'method' => $request->getMethod(), 'uri' => (string)$request->getUri(), 'headers' => $request->getHeaders(), //'data' => $data, 'client_ip' => $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown' ]); $this->log("[$requestId]: " . $data['name']); try { // Validate required fields if (empty($data['game_id'])) { $error = 'game_id and plugin_id are required'; $this->log("[$requestId] Validation failed: $error"); return $this->jsonResponse($response->withStatus(400), [ 'success' => false, 'error' => $error, 'request_id' => $requestId ]); } $gameId = $data['game_id']; $pluginId = 1; // Process media files if provided $mediaUpdates = []; // Check if a transaction is already active $isInTransaction = $this->pdo->inTransaction(); try { if (!$isInTransaction) { $this->pdo->beginTransaction(); } // Double-check if the game exists within the transaction $gameExists = $this->gameExists($gameId); $this->log("[$requestId] Game {$gameId} exists check: " . ($gameExists ? 'true' : 'false')); if (!$gameExists) { $this->log("[$requestId] Game {$gameId} not found, creating new game record"); // Add the game_id and plugin_id to the data array for the createGame method $data['game_id'] = $gameId; $data['plugin_id'] = $pluginId; if ($this->createGame($data)) { $this->log("[$requestId] Successfully created game {$gameId}"); } else { throw new \Exception("Failed to create game {$gameId}"); } } // Update the game record in the database if we have any media to update if (!empty($mediaUpdates)) { $this->updateGameMedia($gameId, $pluginId, $mediaUpdates); $this->log("[$requestId] Updated media for game {$gameId}", $mediaUpdates); } else { $this->log("[$requestId] No media updates found for game {$gameId}"); } if (!$isInTransaction) { $this->pdo->commit(); } } catch (\Exception $e) { if (!$isInTransaction) { $this->pdo->rollBack(); } throw $e; // Re-throw to be caught by the outer try-catch } $executionTime = round((microtime(true) - $startTime) * 1000, 2); $this->log("[$requestId] Media update completed in {$executionTime}ms", [ 'game_id' => $gameId, 'plugin_id' => $pluginId, 'updates' => array_keys($mediaUpdates), 'execution_time_ms' => $executionTime ]); return $this->jsonResponse($response, [ 'success' => true, 'request_id' => $requestId, 'updates' => $mediaUpdates, 'execution_time_ms' => $executionTime ]); } catch (\Exception $e) { $errorDetails = [ 'error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString(), 'request_body' => $rawBody, 'execution_time_ms' => round((microtime(true) - $startTime) * 1000, 2) ]; $this->log("[$requestId] Exception during media update", $errorDetails); $errorResponse = [ 'success' => false, 'error' => 'An error occurred while processing your request', 'request_id' => $requestId ]; if (getenv('APP_ENV') === 'development') { $errorResponse['debug'] = [ 'message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine() ]; } return $this->jsonResponse($response->withStatus(500), $errorResponse); } } /** * Update game media in the database */ /** * Check if a game exists in the database */ private function gameExists(string $gameId): bool { //try { $db = $this->pdo; // Debug: Log the SQL query and parameters $sql = "SELECT COUNT(*) as count FROM games WHERE platform_game_id = :game_id"; $this->log("Checking if game exists - SQL: $sql, game_id: $gameId"); $stmt = $db->prepare($sql); $stmt->execute([':game_id' => $gameId]); $result = $stmt->fetch(\PDO::FETCH_ASSOC); $exists = $result && $result['count'] > 0; $this->log(sprintf( 'Game ID %s %s in database', $gameId, $exists ? 'exists' : 'does not exist' )); return $exists; //} catch (\Exception $e) { // $this->log("Error checking if game exists: " . $e->getMessage()); // return false; // } } /** * Create a new game with basic information */ private function createGame(array $data): bool { $isInTransaction = $this->pdo->inTransaction(); //try { if (!$isInTransaction) { $this->pdo->beginTransaction(); } $this->log(sprintf( 'Game ID %s', $data['name'] )); // Get or create source /*$source = $this->findOrCreateSource($data); $this->log(sprintf( 'Source %s', $source )); */ $cover_image = end(explode('\\', $data['cover_image'])); $background_image = end(explode('\\', $data['background_image'])); // Prepare game data according to the database schema $gameData = [ 'title' => $data['name'] ?? 'Unknown Game', 'description' => $this->cleanHtml($data['description'] ?? ''), 'game_key' => $this->generateGameKey($data['name']), 'platform_game_id' => $data['game_id'] ?? null, 'platform' => !empty($data['platforms']) ? $data['platforms'][0]['name'] : 'PC', 'image_url' => $cover_image, 'banner_url' => $background_image, 'playtime_minutes' => !empty($data['playtime']) ? floor($data['playtime'] / 60) : 0, /* 'genre' => !empty($data['genres']) ? implode(', ', array_column($data['genres'], 'name')) : null, 'developer' => !empty($data['developers']) ? $data['developers'][0]['name'] : null, 'publisher' => !empty($data['publishers']) ? $data['publishers'][0]['name'] : null, 'release_date' => !empty($data['release_date']['ReleaseDate']) ? date('Y-m-d', strtotime($data['release_date']['ReleaseDate'])) : null, 'platform' => !empty($data['platforms']) ? $data['platforms'][0]['name'] : 'PC', 'image_url' => $data['cover_image'] ?? null, 'banner_url' => $data['background_image'] ?? null, 'rating' => $this->normalizeRating($data['user_score_rating'] ?? $data['critic_score_rating'] ?? null),*/ //'is_installed' => $this->toBoolean($data['is_installed'] ?? false), //'is_favorite' => $this->toBoolean($data['favorite'] ?? false), 'source_id' => 1, 'last_played_at' => !empty($data['last_activity']) ? date('Y-m-d H:i:s', strtotime($data['last_activity'])) : null, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), // Enhanced ratings 'rating' => $this->normalizeRating($data['critic_score'] ?? null), 'critic_score' => $data['critic_score'] ?? null, 'community_score' => $data['community_score'] ?? null, 'user_score' => $data['user_score'] ?? null, // Timestamps 'added_at' => isset($data['added']) ? date('Y-m-d H:i:s', strtotime($data['added'])) : null, 'modified_at' => isset($data['modified']) ? date('Y-m-d H:i:s', strtotime($data['modified'])) : null, // Playnite metadata 'metadata' => json_encode([ 'playnite_id' => $data['Id'] ?? $data['playnite_id'] ?? null, 'version' => $data['Version'] ?? $data['version'] ?? null, 'hidden' => $this->toBoolean($data['Hidden'] ?? $data['hidden'] ?? false), 'notes' => $data['Notes'] ?? $data['notes'] ?? null, 'manual' => $data['Manual'] ?? $data['manual'] ?? null, 'pre_script' => $data['PreScript'] ?? $data['pre_script'] ?? null, 'post_script' => $data['PostScript'] ?? $data['post_script'] ?? null, 'game_started_script' => $data['GameStartedScript'] ?? $data['game_started_script'] ?? null, 'use_global_scripts' => [ 'pre' => $this->toBoolean($data['UseGlobalPreScript'] ?? $data['use_global_pre_script'] ?? true), 'post' => $this->toBoolean($data['UseGlobalPostScript'] ?? $data['use_global_post_script'] ?? true), 'game_started' => $this->toBoolean($data['UseGlobalGameStartedScript'] ?? $data['use_global_game_started_script'] ?? true) ] ]) ]; //throw new \Exception('Game created'); /* $this->log($data['name'] . " Received Playnite media update request", [ 'data' => $gameData, ]); */ // Store all related data in metadata $metadata = []; if (!empty($data['genres']) && is_array($data['genres'])) { $metadata['genres'] = array_column($data['genres'], 'name'); } if (!empty($data['platforms']) && is_array($data['platforms'])) { $metadata['platforms'] = array_column($data['platforms'], 'name'); } if (!empty($data['developers']) && is_array($data['developers'])) { $metadata['developers'] = array_column($data['developers'], 'name'); } if (!empty($data['publishers']) && is_array($data['publishers'])) { $metadata['publishers'] = array_column($data['publishers'], 'name'); } if (!empty($data['tags']) && is_array($data['tags'])) { $metadata['tags'] = array_column($data['tags'], 'name'); } if (!empty($data['features']) && is_array($data['features'])) { $metadata['features'] = array_column($data['features'], 'name'); } if (!empty($metadata)) { $gameData['metadata'] = json_encode($metadata, JSON_PRETTY_PRINT); } $this->log(sprintf( 'Metadata %s', $metadata )); // Insert game $columns = implode(', ', array_keys($gameData)); $placeholders = ':' . implode(', :', array_keys($gameData)); $stmt = $this->pdo->prepare( "INSERT INTO games ($columns) VALUES ($placeholders)" ); $stmt->execute($gameData); $gameId = $this->pdo->lastInsertId(); // All related data is already stored in metadata if (!$isInTransaction) { $this->pdo->commit(); } return true; /*} catch (\Exception $e) { if (!$isInTransaction) { $this->pdo->rollBack(); } $this->log("Error creating game: " . $e->getMessage()); throw $e; // Re-throw to be handled by the caller }*/ } /** * Handle game genres */ private function handleGameGenres(int $gameId, array $genres): void { $stmt = $this->pdo->prepare( "INSERT IGNORE INTO game_genres (game_id, genre_id) SELECT :game_id, id FROM genres WHERE name = :name" ); foreach ($genres as $genre) { $stmt->execute([ ':game_id' => $gameId, ':name' => $genre['name'] ]); } } /** * Handle game platforms */ private function handleGamePlatforms(int $gameId, array $platforms): void { $stmt = $this->pdo->prepare( "INSERT IGNORE INTO game_platforms (game_id, platform_id) SELECT :game_id, id FROM platforms WHERE name = :name" ); foreach ($platforms as $platform) { $stmt->execute([ ':game_id' => $gameId, ':name' => $platform['name'] ]); } } /** * Handle game developers */ private function handleGameDevelopers(int $gameId, array $developers): void { $stmt = $this->pdo->prepare( "INSERT IGNORE INTO game_developers (game_id, developer_id) SELECT :game_id, id FROM developers WHERE name = :name" ); foreach ($developers as $developer) { $stmt->execute([ ':game_id' => $gameId, ':name' => $developer['name'] ]); } } /** * Handle game publishers */ private function handleGamePublishers(int $gameId, array $publishers): void { $stmt = $this->pdo->prepare( "INSERT IGNORE INTO game_publishers (game_id, publisher_id) SELECT :game_id, id FROM publishers WHERE name = :name" ); foreach ($publishers as $publisher) { $stmt->execute([ ':game_id' => $gameId, ':name' => $publisher['name'] ]); } } /** * Handle game tags */ private function handleGameTags(int $gameId, array $tags): void { $stmt = $this->pdo->prepare( "INSERT IGNORE INTO game_tags (game_id, tag_id) SELECT :game_id, id FROM tags WHERE name = :name" ); foreach ($tags as $tag) { $stmt->execute([ ':game_id' => $gameId, ':name' => $tag['name'] ]); } } /** * Update game media in the database */ private function updateGameMedia(string $gameId, string $pluginId, array $updates): void { if (empty($updates)) { return; } //try { $setClauses = []; $params = [':game_id' => $gameId, ':plugin_id' => $pluginId]; foreach ($updates as $field => $value) { $param = ":{$field}"; $setClauses[] = "{$field} = {$param}"; $params[$param] = $value; } $sql = "UPDATE games SET " . implode(', ', $setClauses) . " WHERE platform_game_id = :game_id AND platform_plugin_id = :plugin_id"; $stmt = $this->pdo->prepare($sql); $stmt->execute($params); //} catch (\Exception $e) { // $this->log("Error updating game media in database: " . $e->getMessage()); // throw $e; //} } /** * @OA\Put( * path="/playnite/update", * summary="Update existing games from Playnite", * tags={"Playnite"}, * operationId="updateGames", * @OA\RequestBody( * required=true, * @OA\JsonContent( * required={"games"}, * @OA\Property( * property="games", * type="array", * @OA\Items(type="object") * ) * ) * ), * @OA\Response( * response=200, * description="Games successfully updated", * @OA\JsonContent( * @OA\Property(property="success", type="boolean"), * @OA\Property(property="result", type="object") * ) * ), * @OA\Response( * response=400, * description="Invalid input" * ), * @OA\Response( * response=500, * description="Server error" * ) * ) * * @param Request $request * @param Response $response * @param array $args * @return Response */ public function updateGames(Request $request, Response $response, $args) { $data = $request->getParsedBody(); if (!isset($data['games']) || !is_array($data['games'])) { return $this->jsonResponse($response->withStatus(400), [ 'error' => 'Games data is required' ]); } //try { $importResult = $this->importService->importGames($data['games'], true); return $this->jsonResponse($response, [ 'success' => true, 'result' => $importResult ]); //} catch (\Exception $e) { // return $this->jsonResponse($response->withStatus(500), [ //// 'error' => $e->getMessage() // ]); //} } /** * @OA\Delete( * path="/playnite/delete", * summary="Delete games from Playnite", * tags={"Playnite"}, * operationId="deleteGames", * @OA\RequestBody( * required=true, * @OA\JsonContent( * required={"games"}, * @OA\Property( * property="games", * type="array", * @OA\Items(type="object") * ) * ) * ), * @OA\Response( * response=200, * description="Games successfully deleted", * @OA\JsonContent( * @OA\Property(property="success", type="boolean"), * @OA\Property(property="result", type="object", * @OA\Property(property="deleted", type="integer"), * @OA\Property(property="errors", type="array", @OA\Items(type="string")) * ) * ) * ), * @OA\Response( * response=400, * description="Invalid input" * ), * @OA\Response( * response=500, * description="Server error" * ) * ) * * @param Request $request * @param Response $response * @param array $args * @return Response */ public function deleteGames(Request $request, Response $response, $args) { $data = $request->getParsedBody(); if (!isset($data['games']) || !is_array($data['games'])) { return $this->jsonResponse($response->withStatus(400), [ 'error' => 'Games data is required' ]); } //try { $results = [ 'deleted' => 0, 'errors' => [] ]; foreach ($data['games'] as $gameData) { try { // Find the game by platform_game_id and source_id $existingGame = $this->findExistingGame($gameData); if ($existingGame) { $this->deleteGame($existingGame['id']); $results['deleted']++; } } catch (\Exception $e) { $results['errors'][] = "Failed to delete {$gameData['title']}: " . $e->getMessage(); } } return $this->jsonResponse($response, [ 'success' => true, 'result' => $results ]); /* } catch (\Exception $e) { return $this->jsonResponse($response->withStatus(500), [ 'error' => $e->getMessage() ]); }*/ } /** * @OA\Post( * path="/playnite/upload-images", * summary="Upload game images from Playnite", * tags={"Playnite"}, * operationId="uploadImages", * @OA\RequestBody( * required=true, * @OA\JsonContent( * oneOf={ * @OA\Schema( * @OA\Property(property="name", type="string"), * @OA\Property(property="cover", type="string", format="byte"), * @OA\Property(property="icon", type="string", format="byte"), * @OA\Property(property="background", type="string", format="byte") * ), * @OA\Schema( * @OA\Property( * property="games", * type="array", * @OA\Items( * @OA\Property(property="name", type="string"), * @OA\Property(property="cover", type="string", format="byte"), * @OA\Property(property="icon", type="string", format="byte"), * @OA\Property(property="background", type="string", format="byte") * ) * ) * ) * } * ) * ), * @OA\Response( * response=200, * description="Images successfully uploaded", * @OA\JsonContent( * @OA\Property(property="success", type="boolean"), * @OA\Property(property="result", type="object", * @OA\Property(property="uploaded", type="integer"), * @OA\Property(property="errors", type="array", @OA\Items(type="string")) * ) * ) * ), * @OA\Response( * response=500, * description="Server error" * ) * ) * * @param Request $request * @param Response $response * @param array $args * @return Response */ public function uploadImages(Request $request, Response $response, $args) { // Initialize results array $results = [ 'uploaded' => 0, 'errors' => [] ]; // Log raw request body $rawBody = (string)$request->getBody(); $this->log("[/playnite/image/base64] Raw request received. Length: " . strlen($rawBody) . " bytes"); // Log headers $headers = $request->getHeaders(); $this->log("[/playnite/image/base64] Headers: " . json_encode($headers, JSON_PRETTY_PRINT)); // Get parsed body $data = $request->getParsedBody(); // Log parsed data structure $this->log("[/playnite/image/base64] Parsed request data: " . json_encode([ 'has_games' => isset($data['games']) ? 'yes' : 'no', 'has_direct_fields' => isset($data['name']) ? 'yes' : 'no', 'data_keys' => array_keys($data) ], JSON_PRETTY_PRINT)); // Handle image uploads based on the format expected by the plugin if (isset($data['name']) && isset($data['cover'])) { // Handle single game upload $this->log('[/playnite/image/base64] Processing single game upload'); $this->log('[/playnite/image/base64] Upload data: ' . json_encode([ 'has_name' => isset($data['name']) ? 'yes' : 'no', 'has_cover' => isset($data['cover']) ? 'yes, ' . strlen($data['cover']) . ' chars' : 'no', 'has_icon' => isset($data['icon']) ? 'yes, ' . strlen($data['icon']) . ' chars' : 'no', 'has_background' => isset($data['background']) ? 'yes, ' . strlen($data['background']) . ' chars' : 'no' ])); $result = $this->handleImageUpload($data); } return $this->jsonResponse($response, [ 'success' => true, 'result' => $result ]); } /** * Handle individual image upload */ private function handleImageUpload(array $gameData): bool { $name = $gameData['name'] ?? 'Unnamed Game'; $gameId = $gameData['name'] ?? uniqid('game_', true); $pluginId = $gameData['plugin_id'] ?? 'default'; //$this->log("[/playnite/image/base64] Game data: " . json_encode($gameData)); $this->log("[/playnite/image/base64] Processing images for game: {$name} (ID: {$gameId})"); // Define media types and their corresponding data $mediaTypes = [ 'cover' => [ 'data' => $gameData['cover'] ?? null, 'filename' => end(explode('\\', $gameData['coverName'] ?? null)), 'field' => 'cover_image' ], //'icon' => [ // 'data' => $gameData['icon'] ?? null, // 'filename' => $gameData['iconName'] ?? null, // 'field' => 'icon_image' //], 'background' => [ 'data' => $gameData['background'] ?? null, 'filename' => end(explode('\\', $gameData['backgroundName'] ?? null)), 'field' => 'background_image' ] ]; $updates = []; $mediaDir = __DIR__ . '/../../../storage/images/playnite'; // Ensure media directory exists if (!is_dir($mediaDir)) { if (!mkdir($mediaDir, 0777, true) && !is_dir($mediaDir)) { throw new \RuntimeException("Failed to create directory: {$mediaDir}"); } } foreach ($mediaTypes as $type => $media) { $base64Data = $media['data']; $fileName = $media['filename']; if (empty($base64Data) || empty($fileName)) { $this->log("[/playnite/image/base64] No {$type} image data or filename provided for game ID: {$gameId}"); continue; } // Check if the data is URL-encoded if (strpos($base64Data, 'data:image/') === 0) { // Standard base64 data URL if (!preg_match('/^data:image\/(jpeg|png|gif|webp);base64,/', $base64Data, $matches)) { $this->log("[/playnite/image/base64] Invalid {$type} image format for game ID: {$gameId}"); continue; } $extension = $matches[1]; $base64Image = explode(',', $base64Data, 2)[1] ?? ''; } else { // Raw base64 data $extension = pathinfo($fileName, PATHINFO_EXTENSION); $base64Image = $base64Data; // If no valid extension found, try to determine from base64 data if (empty($extension)) { $signature = substr($base64Data, 0, 20); if (strpos($signature, '/9j/') === 0) { $extension = 'jpg'; } elseif (strpos($signature, 'iVBORw') === 0) { $extension = 'png'; } elseif (strpos($signature, 'R0lGOD') === 0) { $extension = 'gif'; } elseif (strpos($signature, 'UklGR') === 0) { $extension = 'webp'; } else { $extension = 'bin'; // Default extension if can't determine } // Update filename with determined extension $fileName = $media['filename'] . '.' . $extension; } } $filePath = "{$mediaDir}/{$fileName}"; // Check if file already exists and has content if (file_exists($filePath) && filesize($filePath) > 0) { $this->log("[/playnite/image/base64] Skipping {$type} image for game ID: {$gameId} - file already exists at {$filePath}"); $updates[$media['field']] = "/storage/images/playnite/{$fileName}"; continue; } // Extract base64 data $imageData = base64_decode($base64Image, true); if ($imageData === false) { $this->log("[/playnite/image/base64] Failed to decode {$type} image for game ID: {$gameId}"); continue; } // Save the image file if (file_put_contents($filePath, $imageData) === false) { $this->log("[/playnite/image/base64] Failed to save {$type} image for game ID: {$gameId}"); continue; } // Add to updates for database $updates[$media['field']] = "/storage/images/playnite/{$fileName}"; $this->log("[/playnite/image/base64] Successfully saved {$type} image for game ID: {$gameId} at {$filePath}"); // Log the first 100 characters of the image data for debugging $sampleData = substr($base64Image, 0, 100); $this->log("[/playnite/image/base64] Sample image data (first 100 chars): {$sampleData}..."); } return true; } /** * 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; } /** * Delete game */ private function deleteGame(int $gameId): void { $stmt = $this->pdo->prepare("DELETE FROM games WHERE id = :id"); $stmt->execute(['id' => $gameId]); } /** * Transform Playnite game data to internal format */ private function transformPlayniteGame(array $game): array { // Find or create source $source = $this->findOrCreateSource($game); // Transform the game data similar to PlayniteImportService return [ 'title' => $game['Name'] ?? $game['name'] ?? '', 'game_key' => $this->generateGameKey($game['Name'] ?? $game['name'] ?? ''), 'description' => $this->cleanHtml($game['Description'] ?? $game['description'] ?? ''), 'platform_game_id' => $game['GameId'] ?? $game['game_id'] ?? '', 'platform' => $this->extractPlatformFromPlaynite($game), 'source_id' => $source['id'], // Rich media 'background_image' => $game['BackgroundImage'] ?? $game['background'] ?? null, 'cover_image' => $game['CoverImage'] ?? $game['cover'] ?? null, 'icon' => $game['Icon'] ?? $game['icon'] ?? null, // Play statistics 'playtime_minutes' => $this->parsePlaytime($game['Playtime'] ?? $game['playtime'] ?? 0), 'play_count' => $game['PlayCount'] ?? $game['play_count'] ?? 0, // Enhanced ratings 'rating' => $this->normalizeRating($game['CriticScore'] ?? $game['critic_score'] ?? null), 'critic_score' => $game['CriticScore'] ?? $game['critic_score'] ?? null, 'community_score' => $game['CommunityScore'] ?? $game['community_score'] ?? null, 'user_score' => $game['UserScore'] ?? $game['user_score'] ?? null, // 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, // Playnite metadata 'metadata' => json_encode([ 'playnite_id' => $game['Id'] ?? $game['playnite_id'] ?? null, 'version' => $game['Version'] ?? $game['version'] ?? null, 'hidden' => $this->toBoolean($game['Hidden'] ?? $game['hidden'] ?? false), 'notes' => $game['Notes'] ?? $game['notes'] ?? null, 'manual' => $game['Manual'] ?? $game['manual'] ?? null, 'pre_script' => $game['PreScript'] ?? $game['pre_script'] ?? null, 'post_script' => $game['PostScript'] ?? $game['post_script'] ?? null, 'game_started_script' => $game['GameStartedScript'] ?? $game['game_started_script'] ?? null, 'use_global_scripts' => [ 'pre' => $this->toBoolean($game['UseGlobalPreScript'] ?? $game['use_global_pre_script'] ?? true), 'post' => $this->toBoolean($game['UseGlobalPostScript'] ?? $game['use_global_post_script'] ?? true), 'game_started' => $this->toBoolean($game['UseGlobalGameStartedScript'] ?? $game['use_global_game_started_script'] ?? true) ] ]) ]; } /** * Find or create a source for the game */ private function findOrCreateSource(array $game): array { $displayName = $game['Source']['Name'] ?? $game['source'] ?? 'Playnite'; $sourceId = $game['Source']['Id'] ?? $game['source_id'] ?? null; $sourceName = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '_', $displayName)); // Try to find existing source by name or ID $stmt = $this->pdo->prepare("SELECT id, name, display_name FROM sources WHERE name = :name OR id = :source_id"); $stmt->execute([ 'name' => $sourceName, 'source_id' => $sourceId ]); $source = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$source) { // Check if we're already in a transaction $isInTransaction = $this->pdo->inTransaction(); try { if (!$isInTransaction) { $this->pdo->beginTransaction(); } // Create new source with required fields $stmt = $this->pdo->prepare(" INSERT INTO sources (name, display_name, created_at, updated_at) VALUES (:name, :display_name, NOW(), NOW()) ") or die(print_r($this->pdo->errorInfo(), true)); $stmt->execute([ 'name' => $sourceName, 'display_name' => $displayName ]); $source = [ 'id' => $this->pdo->lastInsertId(), 'name' => $sourceName, 'display_name' => $displayName ]; if (!$isInTransaction) { $this->pdo->commit(); } } catch (\Exception $e) { if (!$isInTransaction) { $this->pdo->rollBack(); } $this->log("Error in findOrCreateSource: " . $e->getMessage()); throw $e; } } return $source; } /** * Generate a consistent game key for grouping */ private function generateGameKey(string $title): string { return \App\Models\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); } if (isset($game['Platform']) && is_array($game['Platform'])) { return $game['Platform']['Name'] ?? 'PC'; } return $game['platform'] ?? 'PC'; } /** * 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; } /** * 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; } /** * Extract file extension from URL */ private function getExtensionFromUrl(string $url): ?string { $path = parse_url($url, PHP_URL_PATH); if ($path === false || $path === null) { return null; } $extension = pathinfo($path, PATHINFO_EXTENSION); if (empty($extension)) { return null; } // Remove query string if present $extension = strtok($extension, '?'); // Return only the first part if there are multiple dots $parts = explode('.', $extension); return strtolower($parts[0]); } /** * 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); } /** * Convert various input types to boolean */ private function toBoolean($value): bool { if ($value === '' || $value === null) { return false; } if (is_string($value)) { $value = strtolower($value); if ($value === 'false' || $value === 'no' || $value === '0' || $value === 'off') { return false; } } return (bool) $value; } }