Files
MediaCollectorLibary/app/Controllers/Api/PlayniteController.php
Lars Behrends 7a7977d8b0 yay
2025-11-01 22:00:30 +01:00

1299 lines
48 KiB
PHP

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