mirror of
https://github.com/ceratic/MediaCollectorLibary.git
synced 2026-05-13 23:56:46 +02:00
impoort stuff!
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
239
app/Controllers/PlayniteImportController.php
Normal file
239
app/Controllers/PlayniteImportController.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use App\Services\PlayniteImportService;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class PlayniteImportController extends Controller
|
||||
{
|
||||
private \PDO $pdo;
|
||||
private PlayniteImportService $importService;
|
||||
protected $auth;
|
||||
|
||||
public function __construct(\PDO $pdo, Twig $view, $auth = null)
|
||||
{
|
||||
parent::__construct($view, $auth);
|
||||
$this->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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
374
app/Services/PlayniteImportService.php
Normal file
374
app/Services/PlayniteImportService.php
Normal file
@@ -0,0 +1,374 @@
|
||||
<?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' => $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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user