diff --git a/app/Controllers/Controller.php b/app/Controllers/Controller.php index 5f77bbe..80ec9ea 100644 --- a/app/Controllers/Controller.php +++ b/app/Controllers/Controller.php @@ -3,18 +3,19 @@ namespace App\Controllers; use Psr\Http\Message\ResponseInterface as Response; -use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Views\Twig; abstract class Controller { protected $view; - - public function __construct(Twig $view) + protected $auth; + + public function __construct(Twig $view, $auth = null) { $this->view = $view; + $this->auth = $auth; } - + protected function json(Response $response, $data, int $status = 200): Response { $response->getBody()->write(json_encode($data)); @@ -22,4 +23,122 @@ abstract class Controller ->withHeader('Content-Type', 'application/json') ->withStatus($status); } + + protected function jsonResponse(Response $response, $data, int $status = 200): Response + { + return $this->json($response, $data, $status); + } + + protected function withRedirect(Response $response, string $url): Response + { + return $response->withStatus(302)->withHeader('Location', $url); + } + + protected function generateCSRFToken(): string + { + if ($this->auth && method_exists($this->auth, 'generateCSRFToken')) { + return $this->auth->generateCSRFToken(); + } + + // Fallback for when auth service is not available + if (!isset($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; + } + + protected function verifyCSRFToken(string $token): bool + { + if ($this->auth && method_exists($this->auth, 'verifyCSRFToken')) { + return $this->auth->verifyCSRFToken($token); + } + + // Fallback for when auth service is not available + return isset($_SESSION['csrf_token']) && $_SESSION['csrf_token'] === $token; + } + + protected function getRoutePath(string $routeName, array $data = [], array $queryParams = []): string + { + // Simple implementation matching the path_for function in templates + $basePath = ''; + + // Handle common route patterns + switch ($routeName) { + case 'home': + $basePath = '/'; + break; + case 'games.index': + $basePath = '/media/games'; + break; + case 'games.show': + $basePath = '/media/games/' . ($data['game_key'] ?? ''); + break; + case 'movies.index': + $basePath = '/media/movies'; + break; + case 'tvshows.index': + $basePath = '/media/tv-shows'; + break; + case 'music.index': + $basePath = '/media/music'; + break; + case 'admin.index': + $basePath = '/admin'; + break; + case 'admin.playnite.import': + $basePath = '/admin/playnite/import'; + break; + case 'admin.playnite.upload': + $basePath = '/admin/playnite/import'; + break; + case 'admin.settings': + $basePath = '/admin/settings'; + break; + case 'admin.sources': + $basePath = '/admin/sources'; + break; + case 'admin.sync': + $basePath = '/admin/sync/' . ($data['id'] ?? ''); + break; + case 'auth.login': + $basePath = '/login'; + break; + case 'auth.logout': + $basePath = '/logout'; + break; + case 'movies.show': + $basePath = '/media/movies/' . ($data['id'] ?? ''); + break; + case 'tvshows.show': + $basePath = '/media/tv-shows/' . ($data['id'] ?? ''); + break; + case 'music.show': + $basePath = '/media/music/' . ($data['id'] ?? ''); + break; + case 'adult.index': + $basePath = '/media/adult'; + break; + case 'adult.show': + $basePath = '/media/adult/' . ($data['id'] ?? ''); + break; + case 'actors.index': + $basePath = '/media/actors'; + break; + case 'actors.show': + $basePath = '/media/actors/' . ($data['id'] ?? ''); + break; + case 'search.index': + $basePath = '/search'; + break; + default: + $basePath = '/' . str_replace('.', '/', $routeName); + } + + // Add query parameters + if (!empty($queryParams)) { + $basePath .= '?' . http_build_query($queryParams); + } + + return $basePath; + } } diff --git a/app/Controllers/PlayniteImportController.php b/app/Controllers/PlayniteImportController.php new file mode 100644 index 0000000..52d738d --- /dev/null +++ b/app/Controllers/PlayniteImportController.php @@ -0,0 +1,239 @@ +pdo = $pdo; + $this->importService = new PlayniteImportService($pdo); + $this->auth = $auth; + } + + /** + * Show the import form + */ + public function showImport(Request $request, Response $response, $args) + { + return $this->view->render($response, 'admin/playnite/import.twig', [ + 'title' => 'Import Playnite Games', + 'csrf_token' => $this->generateCSRFToken() + ]); + } + + /** + * Handle file upload and preview + */ + public function upload(Request $request, Response $response, $args) + { + $data = $request->getParsedBody(); + $csrfToken = $data['csrf_token'] ?? ''; + + // Verify CSRF token + if (!$this->verifyCSRFToken($csrfToken)) { + return $this->view->render($response->withStatus(400), 'admin/playnite/import.twig', [ + 'title' => 'Import Playnite Games', + 'error' => 'Invalid CSRF token', + 'csrf_token' => $this->generateCSRFToken() + ]); + } + + $uploadedFiles = $request->getUploadedFiles(); + + if (empty($uploadedFiles['playnite_file'])) { + return $this->view->render($response->withStatus(400), 'admin/playnite/import.twig', [ + 'title' => 'Import Playnite Games', + 'error' => 'No file uploaded', + 'csrf_token' => $this->generateCSRFToken() + ]); + } + + $file = $uploadedFiles['playnite_file']; + + // Validate file + if ($file->getError() !== UPLOAD_ERR_OK) { + return $this->view->render($response->withStatus(400), 'admin/playnite/import.twig', [ + 'title' => 'Import Playnite Games', + 'error' => 'Upload error: ' . $this->getUploadErrorMessage($file->getError()), + 'csrf_token' => $this->generateCSRFToken() + ]); + } + + // Check file type + $filename = $file->getClientFilename(); + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + if (!in_array($extension, ['json'])) { + return $this->view->render($response->withStatus(400), 'admin/playnite/import.twig', [ + 'title' => 'Import Playnite Games', + 'error' => 'Only JSON files are supported', + 'csrf_token' => $this->generateCSRFToken() + ]); + } + + // Move uploaded file to temp location + $tempPath = sys_get_temp_dir() . '/playnite_import_' . uniqid() . '.json'; + $file->moveTo($tempPath); + + try { + // Parse and validate the file + $result = $this->importService->parsePlayniteFile($tempPath); + + // Store the temp file path and results in session for the confirmation step + $_SESSION['playnite_import'] = [ + 'temp_file' => $tempPath, + 'preview_data' => $result + ]; + + return $this->view->render($response, 'admin/playnite/preview.twig', [ + 'title' => 'Preview Playnite Import', + 'preview' => $result, + 'filename' => $filename + ]); + + } catch (\Exception $e) { + // Clean up temp file + if (file_exists($tempPath)) { + unlink($tempPath); + } + + return $this->view->render($response->withStatus(400), 'admin/playnite/import.twig', [ + 'title' => 'Import Playnite Games', + 'error' => 'Error parsing file: ' . $e->getMessage(), + 'csrf_token' => $this->generateCSRFToken() + ]); + } + } + + /** + * Confirm and execute the import + */ + public function confirm(Request $request, Response $response, $args) + { + if (!isset($_SESSION['playnite_import'])) { + return $response->withRedirect($this->getRoutePath('admin.playnite.import')); + } + + $importData = $_SESSION['playnite_import']; + $tempPath = $importData['temp_file']; + $previewData = $importData['preview_data']; + + // Get import options from form + $queryParams = $request->getQueryParams(); + $updateExisting = ($queryParams['update_existing'] ?? 'false') === 'true'; + + try { + // Execute the import + $importResult = $this->importService->importGames($previewData['games'], $updateExisting); + + // Clean up temp file + if (file_exists($tempPath)) { + unlink($tempPath); + } + + // Clear session data + unset($_SESSION['playnite_import']); + + return $this->view->render($response, 'admin/playnite/result.twig', [ + 'title' => 'Import Complete', + 'import_result' => $importResult, + 'preview_data' => $previewData + ]); + + } catch (\Exception $e) { + // Clean up temp file + if (file_exists($tempPath)) { + unlink($tempPath); + } + + // Clear session data + unset($_SESSION['playnite_import']); + + return $this->view->render($response->withStatus(500), 'admin/playnite/import.twig', [ + 'title' => 'Import Playnite Games', + 'error' => 'Import failed: ' . $e->getMessage(), + 'csrf_token' => $this->generateCSRFToken() + ]); + } + } + + /** + * Cancel the import (cleanup) + */ + public function cancel(Request $request, Response $response, $args) + { + if (isset($_SESSION['playnite_import'])) { + $tempPath = $_SESSION['playnite_import']['temp_file']; + if (file_exists($tempPath)) { + unlink($tempPath); + } + unset($_SESSION['playnite_import']); + } + + return $response->withRedirect($this->getRoutePath('admin.playnite.import')); + } + + /** + * API endpoint for programmatic imports + */ + public function apiImport(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'], $data['update_existing'] ?? true); + + return $this->jsonResponse($response, [ + 'success' => true, + 'result' => $importResult + ]); + + } catch (\Exception $e) { + return $this->jsonResponse($response->withStatus(500), [ + 'error' => $e->getMessage() + ]); + } + } + + /** + * Get upload error message + */ + private function getUploadErrorMessage(int $errorCode): string + { + switch ($errorCode) { + case UPLOAD_ERR_INI_SIZE: + return 'File too large (exceeds server limit)'; + case UPLOAD_ERR_FORM_SIZE: + return 'File too large (exceeds form limit)'; + case UPLOAD_ERR_PARTIAL: + return 'File only partially uploaded'; + case UPLOAD_ERR_NO_FILE: + return 'No file uploaded'; + case UPLOAD_ERR_NO_TMP_DIR: + return 'No temporary directory'; + case UPLOAD_ERR_CANT_WRITE: + return 'Cannot write to disk'; + case UPLOAD_ERR_EXTENSION: + return 'File upload stopped by extension'; + default: + return 'Unknown upload error'; + } + } +} diff --git a/app/Models/Game.php b/app/Models/Game.php index 96bc988..313d049 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -25,9 +25,27 @@ class Game extends Model 'is_favorite', 'metadata', 'platform_achievements', - 'platform_stats', - 'source_id', - 'last_played_at' + 'background_image', + 'cover_image', + 'icon', + 'genres_json', + 'developers_json', + 'publishers_json', + 'tags_json', + 'features_json', + 'links_json', + 'series_json', + 'age_ratings_json', + 'play_count', + 'install_size', + 'completion_status', + 'critic_score', + 'community_score', + 'user_score', + 'is_custom_game', + 'installation_status', + 'added_at', + 'modified_at' ]; protected array $casts = [ @@ -39,7 +57,23 @@ class Game extends Model 'release_date' => 'date', 'last_played_at' => 'datetime', 'platform_achievements' => 'array', - 'platform_stats' => 'array' + 'critic_score' => 'int', + 'community_score' => 'int', + 'user_score' => 'int', + 'play_count' => 'int', + 'install_size' => 'int', + 'installation_status' => 'int', + 'is_custom_game' => 'bool', + 'added_at' => 'datetime', + 'modified_at' => 'datetime', + 'genres_json' => 'array', + 'developers_json' => 'array', + 'publishers_json' => 'array', + 'tags_json' => 'array', + 'features_json' => 'array', + 'links_json' => 'array', + 'series_json' => 'array', + 'age_ratings_json' => 'array' ]; public function source() @@ -295,4 +329,110 @@ class Game extends Model return $games; } + + /** + * Get Playnite-specific genres + */ + public function getGenres(): array + { + return $this->genres_json ?? []; + } + + /** + * Get Playnite-specific developers + */ + public function getDevelopers(): array + { + return $this->developers_json ?? []; + } + + /** + * Get Playnite-specific publishers + */ + public function getPublishers(): array + { + return $this->publishers_json ?? []; + } + + /** + * Get Playnite-specific tags + */ + public function getTags(): array + { + return $this->tags_json ?? []; + } + + /** + * Get Playnite-specific features + */ + public function getFeatures(): array + { + return $this->features_json ?? []; + } + + /** + * Get Playnite-specific links + */ + public function getLinks(): array + { + return $this->links_json ?? []; + } + + /** + * Get Playnite-specific series + */ + public function getSeries(): array + { + return $this->series_json ?? []; + } + + /** + * Get Playnite-specific age ratings + */ + public function getAgeRatings(): array + { + return $this->age_ratings_json ?? []; + } + + /** + * Get formatted install size + */ + public function getFormattedInstallSize(): string + { + if (!$this->install_size) { + return 'Unknown'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $bytes = $this->install_size; + $i = 0; + + while ($bytes >= 1024 && $i < count($units) - 1) { + $bytes /= 1024; + $i++; + } + + return round($bytes, 2) . ' ' . $units[$i]; + } + + /** + * Get Steam store URL if available + */ + public function getSteamUrl(): ?string + { + if (!$this->steam_app_id) { + return null; + } + return "https://store.steampowered.com/app/{$this->steam_app_id}"; + } + + /** + * Check if game has rich Playnite data + */ + public function hasPlayniteData(): bool + { + return !empty($this->genres_json) || !empty($this->tags_json) || + !empty($this->links_json) || !empty($this->background_image); + } } + diff --git a/app/Models/TvShow.php b/app/Models/TvShow.php index 4b9b3de..67113cf 100644 --- a/app/Models/TvShow.php +++ b/app/Models/TvShow.php @@ -37,19 +37,32 @@ class TvShow extends Model ]; /** - * Get all actors associated with this TV show + * Remove an actor from this TV show */ - public function actors() + public function removeActor(int $actorId): bool { $stmt = $this->pdo->prepare(" - SELECT a.* - FROM actors a - JOIN actor_tv_show ats ON a.id = ats.actor_id - WHERE ats.tv_show_id = :tv_show_id - ORDER BY a.name ASC + DELETE FROM actor_tv_show + WHERE tv_show_id = :tv_show_id AND actor_id = :actor_id "); - $stmt->execute(['tv_show_id' => $this->id]); - return $stmt->fetchAll(\PDO::FETCH_ASSOC); + return $stmt->execute([ + 'tv_show_id' => $this->id, + 'actor_id' => $actorId + ]); + } + + /** + * Update the cast field with actor names + */ + public function updateCastField(): bool + { + $actors = $this->actors(); + $actorNames = array_column($actors, 'name'); + $castString = implode(', ', $actorNames); + + return $this->update($this->id, [ + 'cast' => $castString + ]); } /** @@ -151,35 +164,51 @@ class TvShow extends Model } public function getSeasonsWithEpisodes(): array -{ - $stmt = $this->pdo->prepare(" - SELECT s.*, - COUNT(e.id) as episode_count, - SUM(CASE WHEN e.watched = 1 THEN 1 ELSE 0 END) as watched_episodes - FROM seasons s - LEFT JOIN episodes e ON s.id = e.season_id - WHERE s.tv_show_id = :tv_show_id - GROUP BY s.id - ORDER BY s.season_number ASC - "); - $stmt->execute(['tv_show_id' => $this->id]); - $seasons = $stmt->fetchAll(\PDO::FETCH_ASSOC); - - // Get episodes for each season - foreach ($seasons as &$season) { + { + // Get all episodes for this TV show, grouped by season $stmt = $this->pdo->prepare(" - SELECT e.*, - (SELECT COUNT(*) FROM user_episodes WHERE episode_id = e.id AND watched = 1) as watch_count - FROM episodes e - WHERE e.season_id = :season_id - ORDER BY e.episode_number ASC + SELECT season_number, + COUNT(*) as episode_count, + SUM(CASE WHEN watched = 1 THEN 1 ELSE 0 END) as watched_episodes + FROM tv_episodes + WHERE tv_show_id = :tv_show_id + GROUP BY season_number + ORDER BY season_number ASC "); - $stmt->execute(['season_id' => $season['id']]); - $season['episodes'] = $stmt->fetchAll(\PDO::FETCH_ASSOC); - } + $stmt->execute(['tv_show_id' => $this->id]); + $seasonStats = $stmt->fetchAll(\PDO::FETCH_ASSOC); - return $seasons; -} + $seasons = []; + + // For each season, get the episodes and create a season object + foreach ($seasonStats as $stat) { + $seasonNumber = $stat['season_number']; + + // Get episodes for this season + $stmt = $this->pdo->prepare(" + SELECT e.* + FROM tv_episodes e + WHERE e.tv_show_id = :tv_show_id AND e.season_number = :season_number + ORDER BY e.episode_number ASC + "); + $stmt->execute([ + 'tv_show_id' => $this->id, + 'season_number' => $seasonNumber + ]); + $episodes = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + // Create a season object (simulating the old seasons table structure) + $seasons[] = [ + 'id' => null, // No seasons table, so no ID + 'season_number' => $seasonNumber, + 'episode_count' => (int)$stat['episode_count'], + 'watched_episodes' => (int)$stat['watched_episodes'], + 'episodes' => $episodes + ]; + } + + return $seasons; + } /** * Get similar TV shows based on genres diff --git a/app/Services/PlayniteImportService.php b/app/Services/PlayniteImportService.php new file mode 100644 index 0000000..de903a8 --- /dev/null +++ b/app/Services/PlayniteImportService.php @@ -0,0 +1,374 @@ +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' => $game['IsInstalled'] ?? false, + 'is_favorite' => $game['Favorite'] ?? false, + 'is_custom_game' => $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' => $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' => $game['UseGlobalPreScript'] ?? true, + 'post' => $game['UseGlobalPostScript'] ?? true, + 'game_started' => $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 + */ + public function importGames(array $games, bool $updateExisting = true): array + { + $results = [ + 'imported' => 0, + 'updated' => 0, + 'skipped' => 0, + 'errors' => [] + ]; + + foreach ($games as $gameData) { + try { + $existingGame = $this->findExistingGame($gameData); + + if ($existingGame && $updateExisting) { + $this->updateGame($existingGame['id'], $gameData); + $results['updated']++; + } elseif (!$existingGame) { + $this->insertGame($gameData); + $results['imported']++; + } else { + $results['skipped']++; + } + } catch (\Exception $e) { + $results['errors'][] = "Failed to import {$gameData['title']}: " . $e->getMessage(); + } + } + + 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); + } +} diff --git a/database/migrations/2023_10_15_000015_add_playnite_fields_to_games_table.php b/database/migrations/2023_10_15_000015_add_playnite_fields_to_games_table.php new file mode 100644 index 0000000..04ee5fa --- /dev/null +++ b/database/migrations/2023_10_15_000015_add_playnite_fields_to_games_table.php @@ -0,0 +1,80 @@ +schema()->table('games', function ($table) { + // Rich media fields + $table->string('background_image')->nullable()->after('banner_url'); + $table->string('cover_image')->nullable()->after('background_image'); + $table->string('icon')->nullable()->after('cover_image'); + + // Multiple entities as JSON (to handle arrays of objects from Playnite) + $table->json('genres_json')->nullable()->after('genre'); + $table->json('developers_json')->nullable()->after('developer'); + $table->json('publishers_json')->nullable()->after('publisher'); + $table->json('tags_json')->nullable()->after('genres_json'); + $table->json('features_json')->nullable()->after('tags_json'); + $table->json('links_json')->nullable()->after('features_json'); + $table->json('series_json')->nullable()->after('links_json'); + $table->json('age_ratings_json')->nullable()->after('series_json'); + + // Playnite-specific statistics + $table->integer('play_count')->default(0)->after('playtime_minutes'); + $table->bigInteger('install_size')->nullable()->after('play_count'); + $table->string('completion_status')->nullable()->after('completion_percentage'); + + // Enhanced ratings (from Playnite) + $table->integer('critic_score')->nullable()->after('rating'); + $table->integer('community_score')->nullable()->after('critic_score'); + $table->integer('user_score')->nullable()->after('community_score'); + + // Platform-specific identifiers from Playnite + //$table->string('platform_game_id')->nullable()->after('steam_app_id'); + + // Playnite-specific metadata + $table->timestamp('added_at')->nullable()->after('last_played_at'); + $table->timestamp('modified_at')->nullable()->after('added_at'); + $table->boolean('is_custom_game')->default(false)->after('is_favorite'); + $table->integer('installation_status')->default(0)->after('is_installed'); + + // Legacy field removal (keeping for backward compatibility) + // These will be replaced by the JSON fields above + }); + } + + public function down() + { + Database::getCapsule()->schema()->table('games', function ($table) { + $table->dropColumn([ + 'background_image', + 'cover_image', + 'icon', + 'genres_json', + 'developers_json', + 'publishers_json', + 'tags_json', + 'features_json', + 'links_json', + 'series_json', + 'age_ratings_json', + 'play_count', + 'install_size', + 'completion_status', + 'critic_score', + 'community_score', + 'user_score', + 'platform_game_id', + 'added_at', + 'modified_at', + 'is_custom_game', + 'installation_status' + ]); + }); + } +} diff --git a/database/migrations/2023_10_15_000016_create_import_logs_table.php b/database/migrations/2023_10_15_000016_create_import_logs_table.php new file mode 100644 index 0000000..2cb2099 --- /dev/null +++ b/database/migrations/2023_10_15_000016_create_import_logs_table.php @@ -0,0 +1,37 @@ +schema()->create('import_logs', function ($table) { + $table->id(); + $table->string('import_type'); // 'playnite', 'csv', etc. + $table->enum('status', ['started', 'running', 'completed', 'failed'])->default('started'); + $table->integer('total_items')->default(0); + $table->integer('processed_items')->default(0); + $table->integer('imported_items')->default(0); + $table->integer('updated_items')->default(0); + $table->integer('skipped_items')->default(0); + $table->json('errors')->nullable(); + $table->text('message')->nullable(); + $table->json('file_info')->nullable(); // File name, size, etc. + $table->string('log_file_path')->nullable(); // Path to detailed log file + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->index(['import_type', 'status']); + $table->index(['created_at']); + }); + } + + public function down() + { + Database::getCapsule()->schema()->dropIfExists('import_logs'); + } +} diff --git a/public/index.php b/public/index.php index 36e539f..77b537d 100644 --- a/public/index.php +++ b/public/index.php @@ -89,11 +89,11 @@ $container->set('view', function () use ($container) { case 'admin.index': $basePath = '/admin'; break; - case 'admin.settings': - $basePath = '/admin/settings'; + case 'admin.playnite.import': + $basePath = '/admin/playnite/import'; break; - case 'admin.sources': - $basePath = '/admin/sources'; + case 'admin.playnite.upload': + $basePath = '/admin/playnite/import'; break; case 'admin.sync': $basePath = '/admin/sync/' . ($data['id'] ?? ''); @@ -182,6 +182,21 @@ $container->set('view', function () use ($container) { return $decoded === null ? null : $decoded; })); + $twig->getEnvironment()->addFilter(new TwigFilter('filesizeformat', function ($bytes) { + if (!$bytes || !is_numeric($bytes)) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + + $bytes /= pow(1024, $pow); + + return round($bytes, 2) . ' ' . $units[$pow]; + })); + return $twig; }); @@ -248,6 +263,20 @@ $container->set(\App\Controllers\SettingsController::class, function ($c) { return new \App\Controllers\SettingsController($c->get(PDO::class), $c->get('view')); }); +// Register PlayniteImportController +$container->set(\App\Controllers\PlayniteImportController::class, function ($c) { + return new \App\Controllers\PlayniteImportController( + $c->get(PDO::class), + $c->get('view'), + $c->get(\App\Services\AuthService::class) + ); +}); + +// Register PlayniteImportService +$container->set(\App\Services\PlayniteImportService::class, function ($c) { + return new \App\Services\PlayniteImportService($c->get(PDO::class)); +}); + // Register middleware $container->set(\App\Http\Middleware\AuthMiddleware::class, function ($c) { return new \App\Http\Middleware\AuthMiddleware($c->get(\App\Services\AuthService::class)); diff --git a/resources/views/admin/layout.twig b/resources/views/admin/layout.twig index 72dff0b..8144fb8 100644 --- a/resources/views/admin/layout.twig +++ b/resources/views/admin/layout.twig @@ -254,6 +254,12 @@ Sync Media +
+ Import games from Playnite export files. Playnite is a game library management application that can export your game library to JSON format. +
+{{ error }}
+No recent imports found. Import history will appear here.
++ Review the games that will be imported from "{{ filename }}". Check the details below and click "Import Games" to proceed. +
+{{ game.description|striptags|slice(0, 200) }}{% if game.description|length > 200 %}...{% endif %}
++ The Playnite import has been completed. Here's a summary of what was imported. +
+The following games could not be imported:
+{{ game.description|striptags|slice(0, 150) }}{% if game.description|length > 150 %}...{% endif %}
+{{ movie.tagline }}
+ {% endif %}{{ metadata|json_encode(constant('JSON_PRETTY_PRINT')) }}
diff --git a/resources/views/games/show.twig b/resources/views/games/show.twig
index e259c8e..359fb46 100644
--- a/resources/views/games/show.twig
+++ b/resources/views/games/show.twig
@@ -172,6 +172,211 @@
{{ version.description }}
Critic Score
+{{ version.critic_score }}%
+Community Score
+{{ version.community_score }}%
+User Score
+{{ version.user_score }}%
+Play Count
+{{ version.play_count }}
+Install Size
+{{ (version.install_size / 1024 / 1024 / 1024)|round(1) }} GB
+Completion Status
+{{ version.completion_status }}
+