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 @@
{% if game.image_url %} - {{ game.title }} + {{ game.title }} {% else %}
diff --git a/public/resources/views/games/show.twig b/public/resources/views/games/show.twig index e259c8e..0cbda4c 100644 --- a/public/resources/views/games/show.twig +++ b/public/resources/views/games/show.twig @@ -8,7 +8,7 @@
{% if main_game.image_url %} - {{ main_game.title }} + {{ main_game.title }} {% else %}
diff --git a/resources/views/games/index.twig b/resources/views/games/index.twig index 0695c9a..b9d0a49 100644 --- a/resources/views/games/index.twig +++ b/resources/views/games/index.twig @@ -204,7 +204,7 @@
{% if game.image_url %} - {{ game.title }} + {{ game.title }} {% else %}
@@ -251,11 +251,11 @@
{% for game in games %} -
+
{% if game.image_url %}
- {{ game.title }} + {{ game.title }}
{% else %}
@@ -285,7 +285,7 @@
{% if game.image_url %} - {{ game.title }} + {{ game.title }} {% else %}
diff --git a/resources/views/games/show.twig b/resources/views/games/show.twig index aa0ec93..e2c7ddc 100644 --- a/resources/views/games/show.twig +++ b/resources/views/games/show.twig @@ -1,501 +1,1001 @@ {% extends "layouts/app.twig" %} {% block content %} - -
- -
-
-
-
- {% if main_game.image_url %} - {{ main_game.title }} + +
+
+
+ +
+
+ {% if platform_versions[0].cover_image_url %} + {{ main_game.title }} + {% elseif main_game.image_url %} + {{ main_game.title }} {% else %} -
- -
- {% endif %} -
-

{{ main_game.title }}

-
- - {{ platform_versions|length }} platform{{ platform_versions|length > 1 ? 's' : '' }} - - {% if main_game.genre %} - {{ main_game.genre }} - {% endif %} +
+
-
+ {% endif %}
- - Back to Games -
-
- - -
- - - -
- {% for version in platform_versions %} -
-
- -
-
-
-
Game Information
-
-
-
- {% if version.developer %} -
Developer
-
{{ version.developer }}
- {% endif %} - - {% if version.publisher %} -
Publisher
-
{{ version.publisher }}
- {% endif %} - - {% if version.release_date %} -
Release Date
-
{{ version.release_date|date('M j, Y') }}
- {% endif %} - -
Playtime
-
{{ version.playtime_minutes|format_duration }}
- - {% if version.rating %} -
Rating
-
-
-
-
-
- {{ version.rating }}/10 -
-
- {% endif %} - - {% if version.completion_percentage > 0 %} -
Completion
-
-
-
-
-
- {{ version.completion_percentage }}% -
-
- {% endif %} -
+ + +
+ + + +
+ + +
+
+
+
Game Stats
+
+
+ Last Played + {{ platform_versions[0].last_played_at ? platform_versions[0].last_played_at|date('M d, Y') : 'Never' }} +
+
+ Playtime + {{ platform_versions[0].playtime_minutes|format_duration }} +
+
+ Completion +
+
+
+
+
+ {{ platform_versions[0].completion_percentage }}%
+ + {% set metadata = platform_versions[0].metadata|json_decode %} + {% if metadata %} +
+
Platform Details
+ {% if metadata.appid %} +
+ App ID + {{ metadata.appid }} +
+ {% endif %} +
+ {% endif %} +
+
+
+
+
+
- -
-
-
-
Platform Statistics
+ +
+
+ +
+ +
+ {% for version in platform_versions %} + {% set safePlatformId = version.platform|lower|replace({' ': '-', '(': '', ')': ''}) ~ '-' ~ version.source_id %} +
+ + {% if version.description %} +
+
+
About
+
+ {{ version.description|nl2br }}
-
-
-
Source
-
{{ version.source_name }}
- - {% if version.last_played_at %} -
Last Played
-
- - {{ version.last_played_at|date('M j, Y') }} - -
- {% endif %} - - {% if version.is_installed %} -
Status
-
- - Installed - -
- {% endif %} - - {% if version.is_favorite %} -
Favorite
-
- - Yes - -
- {% endif %} -
+ +
+
+ {% endif %} + + + {% if main_game.screenshots is not empty %} +
+
+

Screenshots

+
+
+ + + {% if main_game.screenshots|length > 1 %} +
+
+ {% for screenshot in main_game.screenshots %} + + {% endfor %} +
+
+ {% endif %} +
+
+ {% endif %} + + +
+
+
Details
+
+ {# Developer and Publisher #} + {% if version.developer or (version.metadata and version.metadata.developers is defined and version.metadata.developers|length > 0) %} +
+
Developer
+

+ {% if version.developer %} + {{ version.developer }} + {% elseif version.metadata and version.metadata.developers is defined and version.metadata.developers|length > 0 %} + {{ version.metadata.developers|join(', ') }} + {% endif %} +

+
+ {% endif %} - - - {% set metadata = version.metadata|json_decode %} - {% if metadata %} -
-
Platform Details
-
- {% if metadata.appid %} -
App ID
-
- {{ metadata.appid }} -
+ {% if version.publisher or (version.metadata and version.metadata.publishers is defined and version.metadata.publishers|length > 0) %} +
+
Publisher
+

+ {% if version.publisher %} + {{ version.publisher }} + {% elseif version.metadata and version.metadata.publishers is defined and version.metadata.publishers|length > 0 %} + {{ version.metadata.publishers|join(', ') }} + {% endif %} +

+
+ {% endif %} + + {# Release Information #} + {% if version.release_date %} +
+
Release Date
+

{{ version.release_date|date('M d, Y') }}

+
+ {% endif %} + + {# Age Rating #} + {% if version.age_rating %} +
+
Age Rating
+

{{ version.age_rating }}

+
+ {% endif %} + + {# Region #} + {% if version.region %} +
+
Region
+

{{ version.region }}

+
+ {% endif %} + + {# Version #} + {% if version.version %} +
+
Version
+

{{ version.version }}

+
+ {% endif %} + + {# Play Time and Count #} + {% if version.playtime_minutes is defined and version.playtime_minutes > 0 %} +
+
Play Time
+

+ {% set hours = version.playtime_minutes // 60 %} + {% set minutes = version.playtime_minutes % 60 %} + {% if hours > 0 %}{{ hours }}h {% endif %}{% if minutes > 0 or hours == 0 %}{{ minutes }}m{% endif %} +

+
+ {% endif %} + + {% if version.play_count is not null %} +
+
Play Count
+

{{ version.play_count }}

+
+ {% endif %} + + {# Game Saves #} + {% if version.save_count is not null %} +
+
Save Files
+

{{ version.save_count }}

+
+ {% endif %} + + {# Installation and Size #} + {% if version.install_size %} +
+
Install Size
+

+ {% if version.install_size >= 1073741824 %} + {{ (version.install_size / 1073741824)|number_format(1) }} GB + {% else %} + {{ (version.install_size / 1048576)|number_format(0) }} MB + {% endif %} +

+
+ {% endif %} + + {# Completion Status #} + {% if version.completion_status %} +
+
Completion Status
+

+ {% set completionStatus = version.completion_status|lower %} + {% if completionStatus == 'completed' %} + Completed + {% elseif completionStatus == 'playing' %} + In Progress + {% elseif completionStatus == 'notplayed' %} + Not Played + {% else %} + {{ completionStatus|capitalize }} + {% endif %} + + {% if version.completion_percentage is not null %} + {{ version.completion_percentage }}% + {% endif %} +

+
+ {% endif %} + + {# Library Dates #} + {% if version.added_at %} +
+
Added to Library
+

{{ version.added_at|date('M d, Y') }}

+
+ {% endif %} + + {% if version.modified_at %} +
+
Last Modified
+

{{ version.modified_at|date('M d, Y') }}

+
+ {% endif %} + + {% if version.last_played_at %} +
+
Last Played
+

+ {{ version.last_played_at|date('M d, Y') }} + {% if version.last_activity %} + {{ version.last_activity }} + {% endif %} +

+
+ {% endif %} + + {# Metadata #} + {% if version.metadata is defined and version.metadata is not empty %} + {% if version.metadata is iterable %} + {% set metadata = version.metadata %} + {% else %} + {% set metadata = version.metadata|json_decode(true) %} + {% if metadata is null or metadata is not iterable %} + {% set metadata = [] %} + {% endif %} {% endif %} - {% if metadata.playtime_windows or metadata.playtime_mac or metadata.playtime_linux %} -
Platform Playtime
-
-
- {% if metadata.playtime_windows %} -
- - {{ metadata.playtime_windows|format_duration }} -
- {% endif %} - {% if metadata.playtime_mac %} -
- - {{ metadata.playtime_mac|format_duration }} -
- {% endif %} - {% if metadata.playtime_linux %} -
- - {{ metadata.playtime_linux|format_duration }} -
- {% endif %} + {# Genres #} + {% if metadata.genres is defined and metadata.genres is iterable and metadata.genres|length > 0 %} +
+
Genres
+
+ {% for genre in metadata.genres %} + {% if genre is not empty %} + {{ genre }} + {% endif %} + {% endfor %}
-
+
+ {% endif %} + + {# Tags (filtered) #} + {% if metadata.tags is defined and metadata.tags is iterable and metadata.tags|length > 0 %} + {% set filteredTags = metadata.tags|filter(tag => tag is not empty and not (tag starts with '[' and ']' in tag)) %} + {% if filteredTags|length > 0 %} +
+
Tags
+
+ {% for tag in filteredTags %} + {{ tag }} + {% endfor %} +
+
+ {% endif %} + {% endif %} + + {# Features #} + {% if metadata.features is defined and metadata.features is iterable and metadata.features|length > 0 %} +
+
Features
+
+ {% for feature in metadata.features %} + {% if feature is not empty %} + {{ feature }} + {% endif %} + {% endfor %} +
+
+ {% endif %} + + {# Platforms #} + {% if metadata.platforms is defined and metadata.platforms is iterable and metadata.platforms|length > 0 %} +
+
Available Platforms
+
+ {% for platform in metadata.platforms %} + {% if platform is not empty %} + {{ platform }} + {% endif %} + {% endfor %} +
+
+ {% endif %} + + {# Series #} + {% if metadata.series is defined and metadata.series is iterable and metadata.series|length > 0 %} +
+
Series
+
+ {% for series in metadata.series %} + {% if series is not empty %} + {{ series }} + {% endif %} + {% endfor %} +
+
+ {% endif %} + + {# Source #} + {% if metadata.source is defined and metadata.source is not empty %} +
+
Source
+

{{ metadata.source }}

+
+ {% endif %} + + {# User Score #} + {% if metadata.user_score is defined and metadata.user_score is not empty %} +
+
User Score
+
+
+
+
+
+ {{ metadata.user_score }}/10 +
+
+ {% endif %} + + {# Community Score #} + {% if metadata.community_score is defined and metadata.community_score is not empty %} +
+
Community Score
+
+
+
+
+
+ {{ metadata.community_score }}% +
+
+ {% endif %} + + {# Critic Score #} + {% if metadata.critic_score is defined and metadata.critic_score is not empty %} +
+
Critic Score
+
+
+
+
+
+ {{ metadata.critic_score }}% +
+
+ {% endif %} + + {# Rating #} + {% if metadata.rating is defined and metadata.rating is not empty %} +
+
Rating
+

+ {% if metadata.rating == 'RP' %} + Rating Pending + {% elseif metadata.rating == 'EC' %} + Early Childhood + {% elseif metadata.rating == 'E' %} + Everyone + {% elseif metadata.rating == 'E10+' %} + Everyone 10+ + {% elseif metadata.rating == 'T' %} + Teen + {% elseif metadata.rating == 'M' %} + Mature 17+ + {% elseif metadata.rating == 'AO' %} + Adults Only 18+ + {% else %} + {{ metadata.rating }} + {% endif %} + {% if metadata.rating_description is defined and metadata.rating_description is not empty %} + {{ metadata.rating_description }} + {% endif %} +

+
+ {% endif %} + + {# Release Date #} + {% if metadata.release_date is defined and metadata.release_date is not empty %} +
+
Original Release
+

+ {{ metadata.release_date }} + {% if metadata.release_year is defined and metadata.release_year is not empty %} + ({{ metadata.release_year }}) + {% endif %} +

+
+ {% endif %} + + {# Links #} + {% if metadata.links is defined and metadata.links is iterable and metadata.links|length > 0 %} +
+
Links
+
+ {% for link in metadata.links %} + {% if link.name is defined and link.url is defined %} + + {{ link.name }} + + {% endif %} + {% endfor %} +
+
{% endif %} - {% endif %}
- - {% if version.description %} -
-
-
-
Description
-
-
-

{{ version.description }}

-
-
-
- {% endif %} - - -
-
- {% set playniteGenres = version.genres_json|json_decode %} - {% if playniteGenres %} -
-
-
-
Genres
-
-
-
- {% for genre in playniteGenres %} - - {{ genre.Name }} - - {% endfor %} -
-
-
-
- {% endif %} - - {% set playniteTags = version.tags_json|json_decode %} - {% if playniteTags %} -
-
-
-
Tags
-
-
-
- {% for tag in playniteTags|slice(0, 20) %} - - {{ tag.Name }} - - {% endfor %} - {% if playniteTags|length > 20 %} - +{{ playniteTags|length - 20 }} more - {% endif %} -
-
-
-
- {% endif %} - - {% set playniteFeatures = version.features_json|json_decode %} - {% if playniteFeatures %} -
-
-
-
Features
-
-
-
- {% for feature in playniteFeatures %} - - {{ feature.Name }} - - {% endfor %} -
-
-
-
- {% endif %} - - {% set playniteLinks = version.links_json|json_decode %} - {% if playniteLinks %} -
-
-
-
Links
-
-
-
- {% for link in playniteLinks %} - - {% endfor %} -
-
-
-
- {% endif %} - - {% set playniteSeries = version.series_json|json_decode %} - {% if playniteSeries %} -
-
-
-
Series
-
-
-
- {% for series in playniteSeries %} - - {{ series.Name }} - - {% endfor %} -
-
-
-
- {% endif %} - - {% set playniteAgeRatings = version.age_ratings_json|json_decode %} - {% if playniteAgeRatings %} -
-
-
-
Age Ratings
-
-
-
- {% for ageRating in playniteAgeRatings %} - - {{ ageRating.Name }} - - {% endfor %} -
-
-
-
- {% endif %} - - - {% if version.critic_score or version.community_score or version.user_score %} -
-
-
-
Ratings
-
-
-
- {% if version.critic_score %} -
-
-
- -
-
-
Critic Score
-
{{ version.critic_score }}%
-
-
-
- {% endif %} - - {% if version.community_score %} -
-
-
- -
-
-
Community Score
-
{{ version.community_score }}%
-
-
-
- {% endif %} - - {% if version.user_score %} -
-
-
- -
-
-
Your Score
-
{{ version.user_score }}%
-
-
-
- {% endif %} -
-
-
-
- {% endif %} - - - {% if version.play_count or version.install_size or version.completion_status %} -
-
-
-
Playnite Statistics
-
-
-
- {% if version.play_count %} -
-
-
- -
-
-
Play Count
-
{{ version.play_count }}
-
-
-
- {% endif %} - - {% if version.install_size %} -
-
-
- -
-
-
Install Size
-
{{ (version.install_size / 1024 / 1024 / 1024)|round(1) }} GB
-
-
-
- {% endif %} - - {% if version.completion_status %} -
-
-
- -
-
-
Completion
-
{{ version.completion_status }}
-
-
-
- {% endif %} -
-
-
-
- {% endif %} -
-
-
{% endfor %}
+ + +
+ +
+
+
Available Platforms
+
+ {% for version in platform_versions %} +
+
+
{{ version.platform }}
+ {% if version.source_name %} + {{ version.source_name }} + {% endif %} +
+ {% if version.is_installed %} + Installed + {% endif %} +
+ {% endfor %} +
+
+
+ + + {% set playniteLinks = platform_versions[0].links_json|json_decode %} + {% if playniteLinks %} +
+
+
Links
+
+ {% for link in playniteLinks %} + + {{ link.Name }} + + {% endfor %} +
+
+
+ {% endif %} +
+ + + -{% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/resources/views/layouts/app.twig b/resources/views/layouts/app.twig index 97acc43..032fde8 100644 --- a/resources/views/layouts/app.twig +++ b/resources/views/layouts/app.twig @@ -4,6 +4,8 @@ {{ title }} - Media Collector + + @@ -18,6 +20,9 @@ {% endif %} + + + {# DebugBar Assets #} diff --git a/routes/api.php b/routes/api.php index 1f5a196..f0d4339 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,7 +10,8 @@ $app->group('/api', function (RouteCollectorProxy $apiGroup) { // Playnite API endpoints $apiGroup->group('/playnite', function (RouteCollectorProxy $playniteGroup) { // Game management - $playniteGroup->post('/media', 'App\Controllers\Api\PlayniteController:insertGames')->setName('api.playnite.games'); + $playniteGroup->post('/insert', 'App\Controllers\Api\PlayniteController:insertGames')->setName('api.playnite.insert'); + $playniteGroup->post('/media', 'App\Controllers\Api\PlayniteController:updateMedia')->setName('api.playnite.media'); $playniteGroup->put('/update/games/', 'App\Controllers\Api\PlayniteController:updateGames')->setName('api.playnite.update'); $playniteGroup->put('/v1/games/delete', 'App\Controllers\Api\PlayniteController:deleteGames')->setName('api.playnite.delete');