mirror of
https://github.com/ceratic/MediaCollectorLibary.git
synced 2026-05-13 23:56:46 +02:00
430 lines
14 KiB
PHP
430 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Game;
|
|
use App\Models\Source;
|
|
|
|
class PlayniteImportService
|
|
{
|
|
private \PDO $pdo;
|
|
|
|
public function __construct(\PDO $pdo)
|
|
{
|
|
$this->pdo = $pdo;
|
|
}
|
|
|
|
/**
|
|
* Parse and validate a Playnite export file
|
|
*/
|
|
public function parsePlayniteFile(string $filePath): array
|
|
{
|
|
if (!file_exists($filePath)) {
|
|
throw new \Exception("Playnite export file not found: {$filePath}");
|
|
}
|
|
|
|
$jsonContent = file_get_contents($filePath);
|
|
if ($jsonContent === false) {
|
|
throw new \Exception("Failed to read Playnite export file: {$filePath}");
|
|
}
|
|
|
|
$games = json_decode($jsonContent, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
throw new \Exception("Invalid JSON in Playnite export file: " . json_last_error_msg());
|
|
}
|
|
|
|
if (!is_array($games)) {
|
|
throw new \Exception("Playnite export file must contain an array of games");
|
|
}
|
|
|
|
return $this->validateAndTransformGames($games);
|
|
}
|
|
|
|
/**
|
|
* Validate and transform Playnite games data
|
|
*/
|
|
private function validateAndTransformGames(array $games): array
|
|
{
|
|
$transformedGames = [];
|
|
$errors = [];
|
|
$warnings = [];
|
|
|
|
foreach ($games as $index => $game) {
|
|
try {
|
|
$transformedGame = $this->transformPlayniteGame($game, $index + 1);
|
|
if ($transformedGame) {
|
|
$transformedGames[] = $transformedGame;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$errors[] = "Game at index {$index}: " . $e->getMessage();
|
|
}
|
|
}
|
|
|
|
return [
|
|
'games' => $transformedGames,
|
|
'errors' => $errors,
|
|
'warnings' => $warnings,
|
|
'total' => count($games),
|
|
'valid' => count($transformedGames),
|
|
'invalid' => count($errors)
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Transform a single Playnite game to our internal format
|
|
*/
|
|
private function transformPlayniteGame(array $game, int $index): array
|
|
{
|
|
/*
|
|
// Validate required fields
|
|
if (empty($game['Name'])) {
|
|
throw new \Exception("Missing game name");
|
|
}
|
|
|
|
if (empty($game['GameId'])) {
|
|
throw new \Exception("Missing GameId");
|
|
}
|
|
*/
|
|
// Find or create source
|
|
$source = $this->findOrCreateSource($game);
|
|
|
|
// Transform the game data
|
|
$transformed = [
|
|
'title' => $game['Name'],
|
|
'game_key' => $this->generateGameKey($game['Name'], $this->extractPlatformFromPlaynite($game)),
|
|
'description' => $this->cleanHtml($game['Description'] ?? ''),
|
|
'platform_game_id' => $game['GameId'],
|
|
'platform' => $this->extractPlatformFromPlaynite($game),
|
|
'source_id' => $source['id'],
|
|
|
|
// Rich media
|
|
'background_image' => $game['BackgroundImage'] ?? null,
|
|
'cover_image' => $game['CoverImage'] ?? null,
|
|
'icon' => $game['Icon'] ?? null,
|
|
|
|
// Multiple entities as JSON
|
|
'genres_json' => json_encode($game['Genres'] ?? []),
|
|
'developers_json' => json_encode($game['Developers'] ?? []),
|
|
'publishers_json' => json_encode($game['Publishers'] ?? []),
|
|
'tags_json' => json_encode($game['Tags'] ?? []),
|
|
'features_json' => json_encode($game['Features'] ?? []),
|
|
'links_json' => json_encode($game['Links'] ?? []),
|
|
'series_json' => json_encode($game['Series'] ?? []),
|
|
'age_ratings_json' => json_encode($game['AgeRatings'] ?? []),
|
|
|
|
// Play statistics
|
|
'playtime_minutes' => $this->parsePlaytime($game['Playtime'] ?? 0),
|
|
'play_count' => $game['PlayCount'] ?? 0,
|
|
'install_size' => $this->parseInstallSize($game['InstallSize'] ?? null),
|
|
'completion_status' => $game['CompletionStatus']['Name'] ?? null,
|
|
|
|
// Enhanced ratings
|
|
'rating' => $this->normalizeRating($game['CriticScore'] ?? null),
|
|
'critic_score' => $game['CriticScore'] ?? null,
|
|
'community_score' => $game['CommunityScore'] ?? null,
|
|
'user_score' => $game['UserScore'] ?? null,
|
|
|
|
// Legacy single-value fields (take first from arrays if available)
|
|
'genre' => $this->getFirstItemName($game['Genres'] ?? []),
|
|
'developer' => $this->getFirstItemName($game['Developers'] ?? []),
|
|
'publisher' => $this->getFirstItemName($game['Publishers'] ?? []),
|
|
|
|
// Platform-specific data
|
|
'steam_app_id' => $this->extractSteamAppId($game),
|
|
|
|
// Playnite-specific metadata
|
|
// 'is_installed' => $this->toBoolean($game['IsInstalled'] ?? false),
|
|
//'is_favorite' => $this->toBoolean($game['Favorite'] ?? false),
|
|
//'is_custom_game' => $this->toBoolean($game['IsCustomGame'] ?? false),
|
|
//'installation_status' => $game['InstallationStatus'] ?? 0,
|
|
|
|
// 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,
|
|
'release_date' => isset($game['ReleaseDate']['ReleaseDate']) ? date('Y-m-d', strtotime($game['ReleaseDate']['ReleaseDate'])) : null,
|
|
|
|
// Playnite metadata
|
|
'metadata' => json_encode([
|
|
'playnite_id' => $game['Id'] ?? null,
|
|
'version' => $game['Version'] ?? null,
|
|
'hidden' => $this->toBoolean($game['Hidden'] ?? false),
|
|
'notes' => $game['Notes'] ?? null,
|
|
'manual' => $game['Manual'] ?? null,
|
|
'pre_script' => $game['PreScript'] ?? null,
|
|
'post_script' => $game['PostScript'] ?? null,
|
|
'game_started_script' => $game['GameStartedScript'] ?? null,
|
|
'use_global_scripts' => [
|
|
'pre' => $this->toBoolean($game['UseGlobalPreScript'] ?? true),
|
|
'post' => $this->toBoolean($game['UseGlobalPostScript'] ?? true),
|
|
'game_started' => $this->toBoolean($game['UseGlobalGameStartedScript'] ?? true)
|
|
]
|
|
])
|
|
];
|
|
|
|
return $transformed;
|
|
}
|
|
|
|
/**
|
|
* Find or create a source for the game
|
|
*/
|
|
private function findOrCreateSource(array $game): array
|
|
{
|
|
$sourceName = $game['Source']['Name'] ?? 'Playnite';
|
|
$sourceId = $game['Source']['Id'] ?? null;
|
|
|
|
// Try to find existing source
|
|
$stmt = $this->pdo->prepare("SELECT id, display_name FROM sources WHERE display_name = :name OR id = :source_id");
|
|
$stmt->execute([
|
|
'name' => $sourceName,
|
|
'source_id' => $sourceId
|
|
]);
|
|
|
|
$source = $stmt->fetch(\PDO::FETCH_ASSOC);
|
|
|
|
if (!$source) {
|
|
// Create new source
|
|
$stmt = $this->pdo->prepare("INSERT INTO sources (display_name, created_at, updated_at) VALUES (:name, NOW(), NOW())");
|
|
$stmt->execute(['name' => $sourceName]);
|
|
$source = ['id' => $this->pdo->lastInsertId(), 'display_name' => $sourceName];
|
|
}
|
|
|
|
return $source;
|
|
}
|
|
|
|
/**
|
|
* Generate a consistent game key for grouping
|
|
*/
|
|
private function generateGameKey(string $title): string
|
|
{
|
|
return 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);
|
|
}
|
|
|
|
return 'PC'; // Default platform
|
|
}
|
|
|
|
/**
|
|
* Extract Steam App ID from game links or metadata
|
|
*/
|
|
private function extractSteamAppId(array $game): ?string
|
|
{
|
|
if (isset($game['Links']) && is_array($game['Links'])) {
|
|
foreach ($game['Links'] as $link) {
|
|
if (isset($link['Name']) && strtolower($link['Name']) === 'steam' &&
|
|
preg_match('/\/app\/(\d+)/', $link['Url'], $matches)) {
|
|
return $matches[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Parse install size
|
|
*/
|
|
private function parseInstallSize($installSize): ?int
|
|
{
|
|
if (is_numeric($installSize)) {
|
|
return (int)$installSize;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Normalize rating to 0-10 scale
|
|
*/
|
|
private function normalizeRating($rating): ?float
|
|
{
|
|
if (is_numeric($rating)) {
|
|
$rating = (float)$rating;
|
|
// If rating is 0-100 scale, convert to 0-10
|
|
if ($rating > 10) {
|
|
return $rating / 10;
|
|
}
|
|
return $rating;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Clean HTML from description
|
|
*/
|
|
private function cleanHtml(?string $html): ?string
|
|
{
|
|
if (!$html) {
|
|
return null;
|
|
}
|
|
|
|
// Remove HTML tags but keep basic formatting
|
|
$text = strip_tags($html);
|
|
// Decode HTML entities
|
|
$text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
|
|
// Clean up extra whitespace
|
|
$text = preg_replace('/\s+/', ' ', $text);
|
|
return trim($text);
|
|
}
|
|
|
|
/**
|
|
* Get first item name from an array of objects
|
|
*/
|
|
private function getFirstItemName(array $items): ?string
|
|
{
|
|
if (empty($items)) {
|
|
return null;
|
|
}
|
|
|
|
$first = reset($items);
|
|
return $first['Name'] ?? null;
|
|
}
|
|
|
|
/**
|
|
* 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, ?callable $logCallback = null): array
|
|
{
|
|
$results = [
|
|
'imported' => 0,
|
|
'updated' => 0,
|
|
'skipped' => 0,
|
|
'errors' => []
|
|
];
|
|
|
|
$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) {
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Insert new game
|
|
*/
|
|
private function insertGame(array $gameData): void
|
|
{
|
|
// Use the Game model's create method which respects fillable fields
|
|
$gameModel = new Game($this->pdo);
|
|
$gameModel->create($gameData);
|
|
}
|
|
|
|
/**
|
|
* Update existing game
|
|
*/
|
|
private function updateGame(int $gameId, array $gameData): void
|
|
{
|
|
// Use the Game model's update method which respects fillable fields
|
|
$gameModel = new Game($this->pdo);
|
|
$gameModel->update($gameId, $gameData);
|
|
}
|
|
|
|
/**
|
|
* Convert a value to boolean, handling empty strings properly
|
|
*/
|
|
private function toBoolean($value): bool
|
|
{
|
|
if ($value === null || $value === false || $value === 0 || $value === '0') {
|
|
return false;
|
|
}
|
|
if ($value === true || $value === 1 || $value === '1') {
|
|
return true;
|
|
}
|
|
if (is_string($value)) {
|
|
return !empty(trim($value));
|
|
}
|
|
return (bool) $value;
|
|
}
|
|
}
|