diff --git a/app/Controllers/ActorController.php b/app/Controllers/ActorController.php index c59ff73..2d4b7f6 100644 --- a/app/Controllers/ActorController.php +++ b/app/Controllers/ActorController.php @@ -127,6 +127,7 @@ class ActorController extends Controller LEFT JOIN tv_shows ts ON ats.tv_show_id = ts.id GROUP BY a.id ORDER BY total_media_count DESC, a.name ASC + LIMIT 50 "); $stmt->execute(); $actors = $stmt->fetchAll(PDO::FETCH_ASSOC); diff --git a/app/Controllers/Api/PlayniteController.php b/app/Controllers/Api/PlayniteController.php index e2c0395..d0bf106 100644 --- a/app/Controllers/Api/PlayniteController.php +++ b/app/Controllers/Api/PlayniteController.php @@ -7,6 +7,7 @@ use Psr\Http\Message\ServerRequestInterface as Request; use App\Controllers\Controller; use App\Models\Game; use App\Services\PlayniteImportService; +use PDO; class PlayniteController extends Controller { @@ -14,10 +15,54 @@ class PlayniteController extends Controller private \PDO $pdo; private PlayniteImportService $importService; + private string $logFile; + public function __construct(\PDO $pdo) { $this->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; } @@ -69,28 +114,565 @@ class PlayniteController extends Controller */ 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' - ]); - } + $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 { - $importResult = $this->importService->importGames($data['games'], true); + // 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, - 'result' => $importResult + 'request_id' => $requestId, + 'result' => $importResult, + 'execution_time_ms' => $executionTime ]); } catch (\Exception $e) { - return $this->jsonResponse($response->withStatus(500), [ - 'error' => $e->getMessage() + $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( @@ -142,7 +724,7 @@ class PlayniteController extends Controller ]); } - try { + //try { $importResult = $this->importService->importGames($data['games'], true); return $this->jsonResponse($response, [ @@ -150,11 +732,11 @@ class PlayniteController extends Controller 'result' => $importResult ]); - } catch (\Exception $e) { - return $this->jsonResponse($response->withStatus(500), [ - 'error' => $e->getMessage() - ]); - } + //} catch (\Exception $e) { + // return $this->jsonResponse($response->withStatus(500), [ + //// 'error' => $e->getMessage() + // ]); + //} } /** @@ -210,7 +792,7 @@ class PlayniteController extends Controller ]); } - try { + //try { $results = [ 'deleted' => 0, 'errors' => [] @@ -234,12 +816,12 @@ class PlayniteController extends Controller 'success' => true, 'result' => $results ]); - +/* } catch (\Exception $e) { return $this->jsonResponse($response->withStatus(500), [ 'error' => $e->getMessage() ]); - } + }*/ } /** @@ -297,41 +879,47 @@ class PlayniteController extends Controller */ 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)); - 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 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 + ]); } /** @@ -339,32 +927,117 @@ class PlayniteController extends Controller */ 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'] ?? 'Unnamed Game'; + $gameId = $gameData['name'] ?? uniqid('game_', true); + $pluginId = $gameData['plugin_id'] ?? 'default'; - $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"); + //$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}"); + } } - - // Here you would typically: - // 1. Decode base64 images - // 2. Save them to the filesystem - // 3. Update the game record with the image paths - + + 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; - - } catch (\Exception $e) { - error_log("Image upload failed for game {$name}: " . $e->getMessage()); - return false; - } } /** @@ -455,11 +1128,12 @@ class PlayniteController extends Controller */ private function findOrCreateSource(array $game): array { - $sourceName = $game['Source']['Name'] ?? $game['source'] ?? 'Playnite'; + $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 - $stmt = $this->pdo->prepare("SELECT id, display_name FROM sources WHERE display_name = :name OR id = :source_id"); + // 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 @@ -468,10 +1142,42 @@ class PlayniteController extends Controller $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]; + // 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; @@ -532,6 +1238,29 @@ class PlayniteController extends Controller 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 */ @@ -551,18 +1280,18 @@ class PlayniteController extends Controller } /** - * Convert a value to boolean + * Convert various input types to boolean */ private function toBoolean($value): bool { - if ($value === null || $value === false || $value === 0 || $value === '0') { + if ($value === '' || $value === null) { return false; } - if ($value === true || $value === 1 || $value === '1') { - return true; - } if (is_string($value)) { - return !empty(trim($value)); + $value = strtolower($value); + if ($value === 'false' || $value === 'no' || $value === '0' || $value === 'off') { + return false; + } } return (bool) $value; } diff --git a/app/Controllers/GameController.php b/app/Controllers/GameController.php index 2a830fc..90d3325 100644 --- a/app/Controllers/GameController.php +++ b/app/Controllers/GameController.php @@ -243,7 +243,9 @@ class GameController extends Controller 'playtime_desc' => 'Most Played', 'completion_desc' => 'Highest Completion', 'added_desc' => 'Recently Added', - 'last_played_desc' => 'Last Played' + 'last_played_desc' => 'Last Played', + 'platforms_desc' => 'Most Platforms', + 'platforms_asc' => 'Fewest Platforms' ] ]); } @@ -273,6 +275,7 @@ class GameController extends Controller $gameModel->game_key = $mainGame['game_key']; $platformVersions = $gameModel->getPlatformVersions(); + return $this->view->render($response, 'games/show.twig', [ 'title' => $mainGame['title'], 'main_game' => $mainGame, diff --git a/app/Controllers/PlayniteImportController.php b/app/Controllers/PlayniteImportController.php index 52d738d..ee0ad84 100644 --- a/app/Controllers/PlayniteImportController.php +++ b/app/Controllers/PlayniteImportController.php @@ -5,6 +5,7 @@ namespace App\Controllers; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use App\Services\PlayniteImportService; +use App\Services\PlayniteSyncService; use Slim\Views\Twig; class PlayniteImportController extends Controller @@ -133,10 +134,25 @@ class PlayniteImportController extends Controller $queryParams = $request->getQueryParams(); $updateExisting = ($queryParams['update_existing'] ?? 'false') === 'true'; - try { - // Execute the import - $importResult = $this->importService->importGames($previewData['games'], $updateExisting); + // Create a source entry for Playnite if it doesn't exist + $source = $this->getOrCreatePlayniteSource(); + + // Initialize the sync service with logging + $syncService = new PlayniteSyncService($this->pdo, $source); + try { + // Start the sync process (this will create a sync log entry) + $syncLogId = $syncService->startSync('import'); + + // Store the sync log ID in the session so we can update it later + $_SESSION['playnite_import']['sync_log_id'] = $syncLogId; + + // Execute the import through the sync service + $importResult = $syncService->importGames($previewData['games'], $updateExisting); + + // Get the log file path to show to the user + $logFilePath = $syncService->getLogFilePath(); + // Clean up temp file if (file_exists($tempPath)) { unlink($tempPath); @@ -148,10 +164,18 @@ class PlayniteImportController extends Controller return $this->view->render($response, 'admin/playnite/result.twig', [ 'title' => 'Import Complete', 'import_result' => $importResult, - 'preview_data' => $previewData + 'preview_data' => $previewData, + 'log_file' => $logFilePath, + 'sync_log_id' => $syncLogId ]); } catch (\Exception $e) { + // Log the error + if (isset($syncService)) { + $syncService->logProgress("ERROR: " . $e->getMessage()); + $logFilePath = $syncService->getLogFilePath(); + } + // Clean up temp file if (file_exists($tempPath)) { unlink($tempPath); @@ -163,11 +187,44 @@ class PlayniteImportController extends Controller return $this->view->render($response->withStatus(500), 'admin/playnite/import.twig', [ 'title' => 'Import Playnite Games', 'error' => 'Import failed: ' . $e->getMessage(), + 'log_file' => $logFilePath ?? null, 'csrf_token' => $this->generateCSRFToken() ]); } } + /** + * Get or create a source for Playnite + */ + private function getOrCreatePlayniteSource(): array + { + $stmt = $this->pdo->prepare(" + SELECT id, name, display_name + FROM sources + WHERE name = 'playnite' OR display_name = 'Playnite' + LIMIT 1 + "); + $stmt->execute(); + + $source = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$source) { + $stmt = $this->pdo->prepare(" + INSERT INTO sources (name, display_name, created_at, updated_at) + VALUES ('playnite', 'Playnite', NOW(), NOW()) + "); + $stmt->execute(); + + $source = [ + 'id' => $this->pdo->lastInsertId(), + 'name' => 'playnite', + 'display_name' => 'Playnite' + ]; + } + + return $source; + } + /** * Cancel the import (cleanup) */ diff --git a/app/Models/Game.php b/app/Models/Game.php index 0c6ecdc..c3d40b4 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -358,7 +358,9 @@ class Game extends Model 'added_asc' => 'added_at ASC NULLS LAST', 'added_desc' => 'added_at DESC NULLS LAST', 'last_played_asc' => 'last_played_at ASC NULLS LAST', - 'last_played_desc' => 'last_played_at DESC NULLS LAST' + 'last_played_desc' => 'last_played_at DESC NULLS LAST', + 'platforms_asc' => 'platform_count ASC', + 'platforms_desc' => 'platform_count DESC' ]; $sortClause = $sortOptions[$sort] ?? 'title ASC'; diff --git a/app/Services/PlayniteImportService.php b/app/Services/PlayniteImportService.php index 3bdfd12..0cb7dbb 100644 --- a/app/Services/PlayniteImportService.php +++ b/app/Services/PlayniteImportService.php @@ -304,8 +304,13 @@ class PlayniteImportService /** * Import games to database + * + * @param array $games Array of games to import + * @param bool $updateExisting Whether to update existing games + * @param callable|null $logCallback Optional callback for logging progress + * @return array Results of the import */ - public function importGames(array $games, bool $updateExisting = true): array + public function importGames(array $games, bool $updateExisting = true, ?callable $logCallback = null): array { $results = [ 'imported' => 0, @@ -314,24 +319,56 @@ class PlayniteImportService 'errors' => [] ]; - foreach ($games as $gameData) { + $totalGames = count($games); + $log = function(string $message) use ($logCallback) { + if (is_callable($logCallback)) { + $logCallback($message); + } + }; + + $log(sprintf("Starting import of %d games", $totalGames)); + + foreach ($games as $index => $gameData) { + $gameTitle = $gameData['title'] ?? 'Untitled'; + $log(sprintf("Processing game %d/%d: %s", $index + 1, $totalGames, $gameTitle)); + try { $existingGame = $this->findExistingGame($gameData); if ($existingGame && $updateExisting) { + $log(sprintf("Updating existing game: %s (ID: %d)", $gameTitle, $existingGame['id'])); $this->updateGame($existingGame['id'], $gameData); $results['updated']++; + $log(sprintf("Successfully updated game: %s", $gameTitle)); } elseif (!$existingGame) { + $log(sprintf("Importing new game: %s", $gameTitle)); $this->insertGame($gameData); $results['imported']++; + $log(sprintf("Successfully imported game: %s", $gameTitle)); } else { + $log(sprintf("Skipping unchanged game: %s", $gameTitle)); $results['skipped']++; } } catch (\Exception $e) { - $results['errors'][] = "Failed to import {$gameData['title']}: " . $e->getMessage(); + $errorMsg = sprintf("Failed to import %s: %s", $gameTitle, $e->getMessage()); + $results['errors'][] = $errorMsg; + $log("ERROR: " . $errorMsg); + } + + // Log progress every 10 games or if it's the last game + if (($index + 1) % 10 === 0 || ($index + 1) === $totalGames) { + $log(sprintf("Progress: %d/%d games processed", $index + 1, $totalGames)); } } + $log(sprintf( + "Import completed: %d imported, %d updated, %d skipped, %d errors", + $results['imported'], + $results['updated'], + $results['skipped'], + count($results['errors']) + )); + return $results; } diff --git a/app/Services/PlayniteSyncService.php b/app/Services/PlayniteSyncService.php new file mode 100644 index 0000000..2df246e --- /dev/null +++ b/app/Services/PlayniteSyncService.php @@ -0,0 +1,117 @@ + 0, + 'updated' => 0, + 'skipped' => 0, + 'errors' => [] + ]; + + public function __construct(\PDO $pdo, array $source, ?int $existingSyncLogId = null) + { + parent::__construct($pdo, $source, $existingSyncLogId); + $this->importService = new PlayniteImportService($pdo); + } + + /** + * Execute the sync process + */ + protected function executeSync(string $syncType): void + { + $this->logProgress("Starting Playnite import process"); + + try { + // Get the import data from session + if (!isset($_SESSION['playnite_import'])) { + $this->logProgress($_SESSION); + $this->logProgress("No Playnite import data found in session"); + throw new \Exception('No Playnite import data found in session'); + } + + $importData = $_SESSION['playnite_import']; + $games = $importData['preview_data']['games'] ?? []; + + $this->logProgress(sprintf('Found %d games to import', count($games))); + $this->totalItems = count($games); + + // Import the games + $this->importResults = $this->importService->importGames( + $games, + true, // Always update existing + function($message) { + $this->logProgress($message); + } + ); + + $this->logProgress(sprintf( + 'Import completed: %d imported, %d updated, %d skipped, %d errors', + $this->importResults['imported'] ?? 0, + $this->importResults['updated'] ?? 0, + $this->importResults['skipped'] ?? 0, + count($this->importResults['errors'] ?? []) + )); + + // Log any errors + foreach ($this->importResults['errors'] as $error) { + $this->logProgress("ERROR: $error"); + } + + } catch (\Exception $e) { + $this->logProgress("ERROR: " . $e->getMessage()); + throw $e; + } + } + + /** + * Get the number of processed items + */ + public function getProcessedCount(): int + { + return ($this->importResults['imported'] ?? 0) + + ($this->importResults['updated'] ?? 0) + + ($this->importResults['skipped'] ?? 0); + } + + /** + * Get the number of new items + */ + public function getNewCount(): int + { + return $this->importResults['imported'] ?? 0; + } + + /** + * Get the number of updated items + */ + public function getUpdatedCount(): int + { + return $this->importResults['updated'] ?? 0; + } + + /** + * Get the number of deleted items + */ + public function getDeletedCount(): int + { + return 0; // Playnite import doesn't handle deletions + } + + /** + * Get a completion message + */ + public function getCompletionMessage(): string + { + return sprintf( + 'Playnite import completed: %d imported, %d updated, %d skipped, %d errors', + $this->importResults['imported'] ?? 0, + $this->importResults['updated'] ?? 0, + $this->importResults['skipped'] ?? 0, + count($this->importResults['errors'] ?? []) + ); + } +} diff --git a/public/resources/views/games/index.twig b/public/resources/views/games/index.twig index 2324112..9e6a585 100644 --- a/public/resources/views/games/index.twig +++ b/public/resources/views/games/index.twig @@ -25,7 +25,7 @@