pdo = $pdo; $this->importService = new PlayniteImportService($pdo); } /** * @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) { $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\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) { $data = $request->getParsedBody(); try { $results = [ 'uploaded' => 0, 'errors' => [] ]; // Handle image uploads based on the format expected by the plugin if (isset($data['name']) && isset($data['cover'])) { // Single game image upload $result = $this->handleImageUpload($data); if ($result) { $results['uploaded']++; } } elseif (isset($data['games']) && is_array($data['games'])) { // Multiple games with images foreach ($data['games'] as $gameData) { $result = $this->handleImageUpload($gameData); if ($result) { $results['uploaded']++; } } } return $this->jsonResponse($response, [ 'success' => true, 'result' => $results ]); } catch (\Exception $e) { return $this->jsonResponse($response->withStatus(500), [ 'error' => $e->getMessage() ]); } } /** * Handle individual image upload */ private function handleImageUpload(array $gameData): bool { try { // For now, we'll just validate the data format // In a real implementation, you might want to save the images to disk // and update the game records with the image paths $name = $gameData['name'] ?? ''; $cover = $gameData['cover'] ?? ''; $icon = $gameData['icon'] ?? ''; $background = $gameData['background'] ?? ''; // Validate base64 images if ($cover && !preg_match('/^data:image\/(jpeg|png|gif|webp);base64,/', $cover)) { throw new \Exception("Invalid cover image format"); } // Here you would typically: // 1. Decode base64 images // 2. Save them to the filesystem // 3. Update the game record with the image paths return true; } catch (\Exception $e) { error_log("Image upload failed for game {$name}: " . $e->getMessage()); return false; } } /** * 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 { $sourceName = $game['Source']['Name'] ?? $game['source'] ?? 'Playnite'; $sourceId = $game['Source']['Id'] ?? $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 \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; } /** * 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 a value to boolean */ 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; } }