Stuff i guess ?

This commit is contained in:
Lars Behrends
2025-10-31 00:24:17 +01:00
parent db0fd4e728
commit 04140786a7
40 changed files with 5411 additions and 525 deletions

View File

@@ -40,6 +40,424 @@ class AdminController extends AdminBaseController
]);
}
/**
* Media Management
*/
// Movies Management
public function movies(Request $request, Response $response, $args)
{
$movieModel = new \App\Models\Movie($this->pdo);
// Get query parameters with defaults
$page = max(1, (int)($request->getQueryParams()['page'] ?? 1));
$search = trim($request->getQueryParams()['search'] ?? '');
$genre = trim($request->getQueryParams()['genre'] ?? '');
$director = trim($request->getQueryParams()['director'] ?? '');
$sort = trim($request->getQueryParams()['sort'] ?? 'title_asc');
$perPage = 20;
// Prepare filters for the view
$filters = [
'search' => $search,
'genre' => $genre,
'director' => $director,
'sort' => $sort
];
// Get paginated and filtered movies
$movies = $movieModel->getPaginated(
$this->pdo,
$page,
$perPage,
$search,
$genre ? [$genre] : [],
$sort
);
// Get available genres and directors for filters
$genres = $movieModel->getGenres($this->pdo);
$directors = $movieModel->getDirectors($this->pdo);
// Calculate pagination data
$totalMovies = $movieModel->getTotalCount(
$this->pdo,
$search,
$genre ? [$genre] : []
);
$totalPages = max(1, ceil($totalMovies / $perPage));
$currentPage = min($page, $totalPages);
// Get flash messages if any
// $successMessages = $this->container->get('flash')->getMessage('success');
return $this->render($response, 'admin/movies/index.twig', [
'title' => 'Manage Movies',
'movies' => $movies,
'genres' => $genres,
'directors' => $directors,
'filters' => $filters,
'pagination' => [
'current' => $currentPage,
'total' => $totalPages,
'per_page' => $perPage,
'total_items' => $totalMovies,
'from' => (($currentPage - 1) * $perPage) + 1,
'to' => min($currentPage * $perPage, $totalMovies)
]
]);
}
public function editMovie(Request $request, Response $response, $args)
{
$id = $args['id'] ?? null;
$movieModel = new \App\Models\Movie($this->pdo);
if ($request->getMethod() === 'POST') {
$data = $request->getParsedBody();
if ($id) {
// Update existing movie
$movieModel->update($id, $data);
//$this->flash->addMessage('success', 'Movie updated successfully');
} else {
// Create new movie
$id = $movieModel->create($data);
// $this->flash->addMessage('success', 'Movie created successfully');
}
return $response->withHeader('Location', '/admin/movies/' . $id . '/edit')
->withStatus(302);
}
$movie = $id ? $movieModel->find($id) : null;
return $this->render($response, 'admin/movies/edit.twig', [
'title' => $id ? 'Edit Movie' : 'Add New Movie',
'movie' => $movie
]);
}
public function deleteMovie(Request $request, Response $response, $args)
{
$id = $args['id'];
$movieModel = new \App\Models\Movie($this->pdo);
$movieModel->delete($id);
$this->flash->addMessage('success', 'Movie deleted successfully');
return $response->withHeader('Location', '/admin/movies')
->withStatus(302);
}
// Games Management
public function games(Request $request, Response $response, $args)
{
$gameModel = new \App\Models\Game($this->pdo);
// Get query parameters
$page = (int)($request->getQueryParams()['page'] ?? 1);
$search = $request->getQueryParams()['search'] ?? '';
$platform = $request->getQueryParams()['platform'] ?? '';
$genre = $request->getQueryParams()['genre'] ?? '';
$isInstalled = $request->getQueryParams()['installed'] ?? '';
$isFavorite = $request->getQueryParams()['favorite'] ?? '';
$sort = $request->getQueryParams()['sort'] ?? 'title_asc';
$perPage = 20; // Items per page
// Prepare filters
$filters = [
'search' => $search,
'platform' => $platform,
'genre' => $genre,
'is_installed' => $isInstalled,
'is_favorite' => $isFavorite,
'sort' => $sort
];
// Get paginated and filtered games
$result = $gameModel->getGroupedGamesWithPagination(
$this->pdo,
$page,
$perPage,
$search,
$genre ? [$genre] : [],
$platform ? [$platform] : []
);
// Get available platforms and genres for filters
$platforms = $gameModel->getPlatforms();
$genres = $gameModel->getGenres();
// Calculate pagination data
$totalGames = $gameModel->getTotalCount(
$this->pdo,
$search,
$genre ? [$genre] : [],
$platform ? [$platform] : []
);
$totalPages = ceil($totalGames / $perPage);
return $this->render($response, 'admin/games/index.twig', [
'title' => 'Manage Games',
'games' => $result,
'platforms' => $platforms,
'genres' => $genres,
'filters' => $filters,
'pagination' => [
'current' => $page,
'total' => $totalPages,
'per_page' => $perPage,
'total_items' => $totalGames
]
]);
}
public function editGame(Request $request, Response $response, $args)
{
$id = $args['id'] ?? null;
$gameModel = new \App\Models\Game($this->pdo);
if ($request->getMethod() === 'POST') {
$data = $request->getParsedBody();
if ($id) {
$gameModel->update($id, $data);
} else {
$id = $gameModel->create($data);
}
return $response->withHeader('Location', '/admin/games/' . $id . '/edit')
->withStatus(302);
}
$game = $id ? $gameModel->find($id) : null;
return $this->render($response, 'admin/games/edit.twig', [
'title' => $id ? 'Edit Game' : 'Add New Game',
'game' => $game
]);
}
public function deleteGame(Request $request, Response $response, $args)
{
$id = $args['id'];
$gameModel = new \App\Models\Game($this->pdo);
$gameModel->delete($id);
return $response->withHeader('Location', '/admin/games')
->withStatus(302);
}
// TV Shows Management
public function shows(Request $request, Response $response, $args)
{
$showModel = new \App\Models\TvShow($this->pdo);
// Get query parameters with defaults
$page = max(1, (int)($request->getQueryParams()['page'] ?? 1));
$search = trim($request->getQueryParams()['search'] ?? '');
$genre = trim($request->getQueryParams()['genre'] ?? '');
$network = trim($request->getQueryParams()['network'] ?? '');
$status = trim($request->getQueryParams()['status'] ?? '');
$sort = trim($request->getQueryParams()['sort'] ?? 'name_asc');
$perPage = 20;
// Prepare filters for the view
$filters = [
'search' => $search,
'genre' => $genre,
'network' => $network,
'status' => $status,
'sort' => $sort
];
// Get paginated and filtered shows
$shows = $showModel->getPaginated(
$this->pdo,
$page,
$perPage,
$search,
$genre ? [$genre] : [],
$network ? [$network] : [],
$status ? [$status] : [],
$sort
);
// Get available filters
$genres = $showModel->getGenres($this->pdo);
//$networks = $showModel->getNetworks($this->pdo);
$statuses = ['Returning Series', 'Ended', 'Canceled', 'In Production'];
// Calculate pagination data
$totalShows = $showModel->getTotalCount(
$this->pdo,
$search,
$genre ? [$genre] : [],
$network ? [$network] : [],
$status ? [$status] : []
);
$totalPages = max(1, ceil($totalShows / $perPage));
$currentPage = min($page, $totalPages);
return $this->render($response, 'admin/shows/index.twig', [
'title' => 'Manage TV Shows',
'shows' => $shows,
'genres' => $genres,
//'networks' => $networks,
'statuses' => $statuses,
'filters' => $filters,
'pagination' => [
'current' => $currentPage,
'total' => $totalPages,
'per_page' => $perPage,
'total_items' => $totalShows,
'from' => (($currentPage - 1) * $perPage) + 1,
'to' => min($currentPage * $perPage, $totalShows)
]
]);
}
public function editShow(Request $request, Response $response, $args)
{
$id = $args['id'] ?? null;
$showModel = new \App\Models\TvShow($this->pdo);
if ($request->getMethod() === 'POST') {
$data = $request->getParsedBody();
if ($id) {
$showModel->update($id, $data);
} else {
$id = $showModel->create($data);
}
return $response->withHeader('Location', '/admin/shows/' . $id . '/edit')
->withStatus(302);
}
$show = $id ? $showModel->find($id) : null;
return $this->render($response, 'admin/shows/edit.twig', [
'title' => $id ? 'Edit TV Show' : 'Add New TV Show',
'show' => $show
]);
}
public function deleteShow(Request $request, Response $response, $args)
{
$id = $args['id'];
$showModel = new \App\Models\TvShow($this->pdo);
$showModel->delete($id);
return $response->withHeader('Location', '/admin/shows')
->withStatus(302);
}
/**
* Display a listing of adult videos with pagination and filters
*/
public function adultVideos(Request $request, Response $response, $args)
{
$adultVideoModel = new \App\Models\AdultVideo($this->pdo);
// Get query parameters with defaults
$page = max(1, (int)($request->getQueryParams()['page'] ?? 1));
$search = trim($request->getQueryParams()['search'] ?? '');
$genre = trim($request->getQueryParams()['genre'] ?? '');
$director = trim($request->getQueryParams()['director'] ?? '');
$sort = trim($request->getQueryParams()['sort'] ?? 'newest');
$perPage = 20;
// Prepare filters for the view
$filters = [
'search' => $search,
'genre' => $genre,
'director' => $director,
'sort' => $sort
];
// Get available filters
$genres = $adultVideoModel::getAvailableGenres($this->pdo);
$directors = $adultVideoModel::getAvailableDirectors($this->pdo);
// Get paginated and filtered adult videos
$videos = $adultVideoModel::getAllWithPagination(
$this->pdo,
$page,
$perPage,
$search,
$genre ? [$genre] : [],
$director ? [$director] : []
);
// Get total count for pagination
$totalVideos = $adultVideoModel::getTotalCount(
$this->pdo,
$search,
$genre ? [$genre] : [],
$director ? [$director] : []
);
$totalPages = max(1, ceil($totalVideos / $perPage));
$currentPage = min($page, $totalPages);
return $this->render($response, 'admin/adult/index.twig', [
'title' => 'Manage Adult Videos',
'videos' => $videos,
'genres' => $genres,
'directors' => $directors,
'filters' => $filters,
'pagination' => [
'current' => $currentPage,
'total' => $totalPages,
'per_page' => $perPage,
'total_items' => $totalVideos,
'from' => (($currentPage - 1) * $perPage) + 1,
'to' => min($currentPage * $perPage, $totalVideos)
]
]);
}
public function editAdultVideo(Request $request, Response $response, $args)
{
$id = $args['id'] ?? null;
$adultModel = new \App\Models\AdultVideo($this->pdo);
if ($request->getMethod() === 'POST') {
$data = $request->getParsedBody();
if ($id) {
$adultModel->update($id, $data);
} else {
$id = $adultModel->create($data);
}
return $response->withHeader('Location', '/admin/adult/' . $id . '/edit')
->withStatus(302);
}
$video = $id ? $adultModel->find($id) : null;
return $this->render($response, 'admin/adult/edit.twig', [
'title' => $id ? 'Edit Adult Video' : 'Add New Adult Video',
'video' => $video
]);
}
public function deleteAdultVideo(Request $request, Response $response, $args)
{
$id = $args['id'];
$adultModel = new \App\Models\AdultVideo($this->pdo);
$adultModel->delete($id);
return $response->withHeader('Location', '/admin/adult')
->withStatus(302);
}
public function syncSource(Request $request, Response $response, $args)
{
$sourceId = $args['id'];
@@ -202,4 +620,119 @@ class AdminController extends AdminBaseController
'message' => 'Sync process starting in background'
]);
}
/**
* Get actors for a specific adult video
*/
public function getAdultVideoActors(Request $request, Response $response, $args)
{
$adultVideo = new \App\Models\AdultVideo($this->pdo);
$video = $adultVideo->find($args['id']);
if (!$video) {
$response->getBody()->write(json_encode(['error' => 'Video not found']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$actors = $adultVideo->actors($args['id']);
$response->getBody()->write(json_encode(['data' => $actors]));
return $response->withHeader('Content-Type', 'application/json');
}
/**
* Add an actor to an adult video
*/
public function addActorToAdultVideo(Request $request, Response $response, $args)
{
$contentType = $request->getHeaderLine('Content-Type');
if (strstr($contentType, 'application/json')) {
$data = json_decode((string)$request->getBody(), true);
} else {
$data = $request->getParsedBody();
}
$actorId = $data['actor_id'] ?? null;
if (!$actorId) {
$response->getBody()->write(json_encode(['error' => 'Actor ID is required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
$adultVideo = new \App\Models\AdultVideo($this->pdo);
$video = $adultVideo->find($args['id']);
if (!$video) {
$response->getBody()->write(json_encode(['error' => 'Video not found']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$success = $adultVideo->addActor($actorId);
if ($success) {
$adultVideo->updateCastField();
$response->getBody()->write(json_encode([
'success' => true,
'message' => 'Actor added successfully'
]));
return $response->withHeader('Content-Type', 'application/json');
}
$response->getBody()->write(json_encode(['error' => 'Failed to add actor']));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
/**
* Remove an actor from an adult video
*/
public function removeActorFromAdultVideo(Request $request, Response $response, $args)
{
$actorId = $args['actorId'] ?? null;
if (!$actorId) {
$response->getBody()->write(json_encode(['error' => 'Actor ID is required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
$adultVideo = new \App\Models\AdultVideo($this->pdo);
$video = $adultVideo->find($args['id']);
if (!$video) {
$response->getBody()->write(json_encode(['error' => 'Video not found']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$success = $adultVideo->removeActor($actorId);
if ($success) {
$adultVideo->updateCastField();
$response->getBody()->write(json_encode([
'success' => true,
'message' => 'Actor removed successfully'
]));
return $response->withHeader('Content-Type', 'application/json');
}
$response->getBody()->write(json_encode(['error' => 'Failed to remove actor']));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
/**
* Search actors by name
*/
public function searchActors(Request $request, Response $response, $args)
{
$query = $request->getQueryParams()['q'] ?? '';
if (empty($query)) {
$response->getBody()->write(json_encode(['data' => []]));
return $response->withHeader('Content-Type', 'application/json');
}
$adultVideo = new \App\Models\AdultVideo($this->pdo);
$actors = $adultVideo->searchActors($this->pdo, $query);
$response->getBody()->write(json_encode(['data' => $actors]));
return $response->withHeader('Content-Type', 'application/json');
}
}

View File

@@ -41,11 +41,12 @@ class AdultController extends Controller
}
$directors = array_filter($directors);
// Get view mode
// Get view mode and sort
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
$sort = $queryParams['sort'] ?? 'recent';
// Get adult videos with pagination and filters
$adultVideos = AdultVideo::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $directors);
// Get adult videos with pagination, filters, and sorting
$adultVideos = AdultVideo::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $directors, $sort);
// Process metadata to extract local image paths for template compatibility
foreach ($adultVideos as &$video) {
@@ -94,6 +95,21 @@ class AdultController extends Controller
'search' => $search,
'view_mode' => $viewMode,
'view_modes' => ['grid', 'list', 'covers'],
'sort' => $sort,
'sort_options' => [
'recent' => 'Most Recent',
'oldest' => 'Oldest First',
'title_asc' => 'Title (A-Z)',
'title_desc' => 'Title (Z-A)',
'year_asc' => 'Release Year (Oldest First)',
'year_desc' => 'Release Year (Newest First)',
'rating_desc' => 'Highest Rated',
'rating_asc' => 'Lowest Rated',
'views_desc' => 'Most Viewed',
'views_asc' => 'Least Viewed',
'runtime_desc' => 'Longest Runtime',
'runtime_asc' => 'Shortest Runtime',
],
'filters' => [
'genres' => $genres,
'directors' => $directors

View File

@@ -0,0 +1,51 @@
<?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\Services\AuthService;
class AuthController extends Controller
{
private AuthService $authService;
public function __construct(AuthService $authService)
{
$this->authService = $authService;
}
/**
* Check if user is authenticated (API endpoint)
*/
public function checkAuth(Request $request, Response $response, $args)
{
try {
if (!$this->authService->isLoggedIn()) {
return $this->jsonResponse($response->withStatus(401), [
'error' => '401 Forbidden'
]);
}
$user = $this->authService->getCurrentUser();
if (!$user) {
return $this->jsonResponse($response->withStatus(401), [
'error' => '401 Forbidden'
]);
}
return $this->jsonResponse($response, [
'id' => $user['id'],
'username' => $user['username'],
'email' => $user['email'],
'is_admin' => $this->authService->isAdmin()
]);
} catch (\Exception $e) {
return $this->jsonResponse($response->withStatus(500), [
'error' => 'Authentication check failed'
]);
}
}
}

View File

@@ -0,0 +1,569 @@
<?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;
class PlayniteController extends Controller
{
private \PDO $pdo;
private PlayniteImportService $importService;
public function __construct(\PDO $pdo)
{
$this->pdo = $pdo;
$this->importService = new PlayniteImportService($pdo);
}
/**
* @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)
{
$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\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)
{
$data = $request->getParsedBody();
try {
$results = [
'uploaded' => 0,
'errors' => []
];
// Handle image uploads based on the format expected by the plugin
if (isset($data['name']) && isset($data['cover'])) {
// Single game image upload
$result = $this->handleImageUpload($data);
if ($result) {
$results['uploaded']++;
}
} elseif (isset($data['games']) && is_array($data['games'])) {
// Multiple games with images
foreach ($data['games'] as $gameData) {
$result = $this->handleImageUpload($gameData);
if ($result) {
$results['uploaded']++;
}
}
}
return $this->jsonResponse($response, [
'success' => true,
'result' => $results
]);
} catch (\Exception $e) {
return $this->jsonResponse($response->withStatus(500), [
'error' => $e->getMessage()
]);
}
}
/**
* Handle individual image upload
*/
private function handleImageUpload(array $gameData): bool
{
try {
// For now, we'll just validate the data format
// In a real implementation, you might want to save the images to disk
// and update the game records with the image paths
$name = $gameData['name'] ?? '';
$cover = $gameData['cover'] ?? '';
$icon = $gameData['icon'] ?? '';
$background = $gameData['background'] ?? '';
// Validate base64 images
if ($cover && !preg_match('/^data:image\/(jpeg|png|gif|webp);base64,/', $cover)) {
throw new \Exception("Invalid cover image format");
}
// Here you would typically:
// 1. Decode base64 images
// 2. Save them to the filesystem
// 3. Update the game record with the image paths
return true;
} catch (\Exception $e) {
error_log("Image upload failed for game {$name}: " . $e->getMessage());
return false;
}
}
/**
* 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
{
$sourceName = $game['Source']['Name'] ?? $game['source'] ?? 'Playnite';
$sourceId = $game['Source']['Id'] ?? $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 \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;
}
/**
* 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 a value to boolean
*/
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;
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* @OA\OpenApi(
* @OA\Info(
* title="Playnite API",
* version="1.0.0",
* description="API for managing games and media from Playnite",
* @OA\Contact(
* email="support@example.com"
* ),
* @OA\License(
* name="Apache 2.0",
* url="http://www.apache.org/licenses/LICENSE-2.0.html"
* )
* ),
* @OA\Server(
* url="/api",
* description="API Server"
* ),
* @OA\Components(
* @OA\Schema(
* schema="Error",
* type="object",
* @OA\Property(property="success", type="boolean", example=false),
* @OA\Property(property="error", type="string", example="Error message")
* ),
* @OA\Schema(
* schema="Success",
* type="object",
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="result", type="object")
* )
* )
* )
*
* @OA\Tag(
* name="Playnite",
* description="Endpoints for Playnite integration"
* )
*/

View File

@@ -80,7 +80,7 @@ class DashboardController extends Controller
'recent_games' => $recentGames,
'recent_movies' => $recentMovies,
'recent_syncs' => $recentSyncs,
'sync_stats' => $syncStats
//'sync_stats' => $syncStats
]);
}
}

View File

@@ -5,16 +5,164 @@ namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Models\Game;
use App\Services\SteamGridDbService;
use Slim\Views\Twig;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
class GameController extends Controller
{
private \PDO $pdo;
private SteamGridDbService $steamGridDb;
public function __construct(\PDO $pdo, Twig $view)
{
parent::__construct($view);
$this->pdo = $pdo;
$this->steamGridDb = new SteamGridDbService();
}
/**
* Search for games on SteamGridDB
*/
public function searchSteamGridDb(Request $request, Response $response, array $args): Response
{
$query = $request->getQueryParams()['q'] ?? '';
$results = [];
if (!empty($query)) {
$results = $this->steamGridDb->searchGames($query);
}
return $this->json($response, [
'success' => true,
'data' => $results
]);
}
/**
* Get media from SteamGridDB
*/
public function getSteamGridDbMedia(Request $request, Response $response, array $args): Response
{
$gameId = $args['gameId'] ?? null;
$type = $args['type'] ?? 'grids';
$media = [];
if ($gameId) {
switch ($type) {
case 'grids':
$media = $this->steamGridDb->getGrids($gameId, [
'styles' => ['alternate', 'blurred', 'white_logo', 'material', 'no_logo'],
'dimensions' => ['600x900', '920x430', '460x215', '920x430']
]);
break;
case 'heroes':
$media = $this->steamGridDb->getHeroes($gameId, [
'dimensions' => ['1920x620', '3840x1240']
]);
break;
case 'icons':
$media = $this->steamGridDb->getIcons($gameId, [
'dimensions' => ['32x32', '64x64', '128x128', '256x256', '512x512']
]);
break;
case 'logos':
$media = $this->steamGridDb->getLogos($gameId);
break;
}
}
return $this->json($response, [
'success' => true,
'data' => $media
]);
}
/**
* Download and set media from SteamGridDB
*/
public function setSteamGridDbMedia(Request $request, Response $response, array $args): Response
{
$gameId = $args['id'] ?? null;
$data = $request->getParsedBody();
$type = $data['type'] ?? '';
$url = $data['url'] ?? '';
$field = '';
if (!$gameId || !$type || !$url) {
return $this->json($response, [
'success' => false,
'message' => 'Missing required parameters'
], 400);
}
// Map media type to database field
switch ($type) {
case 'grid':
$field = 'image_url';
break;
case 'hero':
$field = 'banner_url';
break;
case 'icon':
$field = 'icon';
break;
case 'logo':
$field = 'logo_url';
break;
default:
return $this->json($response, [
'success' => false,
'message' => 'Invalid media type'
], 400);
}
// Download the media file
$tempFile = $this->steamGridDb->downloadMedia($url);
if (!$tempFile) {
return $this->json($response, [
'success' => false,
'message' => 'Failed to download media'
], 500);
}
// Move the file to the appropriate location
$uploadDir = __DIR__ . '/../../public/uploads/games/' . $gameId;
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$filename = $type . '_' . uniqid() . '.' . pathinfo($url, PATHINFO_EXTENSION);
$filepath = $uploadDir . '/' . $filename;
$publicPath = '/uploads/games/' . $gameId . '/' . $filename;
if (!rename($tempFile, $filepath)) {
return $this->json($response, [
'success' => false,
'message' => 'Failed to save media file'
], 500);
}
// Update the game record
$game = Game::find($this->pdo, $gameId);
if (!$game) {
return $this->json($response, [
'success' => false,
'message' => 'Game not found'
], 404);
}
$game->{$field} = $publicPath;
$game->save($this->pdo);
return $this->json($response, [
'success' => true,
'data' => [
'url' => $publicPath,
'field' => $field
]
]);
}
public function index(Request $request, Response $response, $args)
@@ -44,8 +192,11 @@ class GameController extends Controller
// Get view mode
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
// Get games with pagination and filters
$games = Game::getGroupedGamesWithPagination($this->pdo, $page, $perPage, $search, $genres, $platforms);
// Get sort parameter
$sort = $queryParams['sort'] ?? 'title_asc';
// Get games with pagination, filters, and sorting
$games = Game::getGroupedGamesWithPagination($this->pdo, $page, $perPage, $search, $genres, $platforms, $sort);
// Get total count for pagination
$totalCount = Game::getTotalCount($this->pdo, $search, $genres, $platforms);
@@ -82,6 +233,17 @@ class GameController extends Controller
'available_filters' => [
'genres' => $availableGenres,
'platforms' => $availablePlatforms
],
'sort' => $sort,
'sort_options' => [
'title_asc' => 'Title (A-Z)',
'title_desc' => 'Title (Z-A)',
'year_asc' => 'Release Year (Oldest First)',
'year_desc' => 'Release Year (Newest First)',
'playtime_desc' => 'Most Played',
'completion_desc' => 'Highest Completion',
'added_desc' => 'Recently Added',
'last_played_desc' => 'Last Played'
]
]);
}
@@ -92,9 +254,9 @@ class GameController extends Controller
// Find the main game entry (could be any platform version)
$stmt = $this->pdo->prepare("
SELECT g.*, s.display_name as source_name
SELECT g.*, g.platform as source_name
FROM games g
JOIN sources s ON g.source_id = s.id
WHERE g.game_key = :game_key
LIMIT 1
");

View File

@@ -44,8 +44,11 @@ class MovieController extends Controller
// Get view mode
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
// Get movies with pagination and filters
$movies = Movie::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $directors);
// Get sort parameter
$sort = $queryParams['sort'] ?? 'title_asc';
// Get movies with pagination, filters, and sorting
$movies = Movie::getAllWithPagination($this->pdo, $page, $perPage, $search, $genres, $directors, $sort);
// Get total count for pagination
$totalCount = Movie::getTotalCount($this->pdo, $search, $genres, $directors);
@@ -82,6 +85,18 @@ class MovieController extends Controller
'available_filters' => [
'genres' => $availableGenres,
'directors' => $availableDirectors
],
'sort' => $sort,
'sort_options' => [
'title_asc' => 'Title (A-Z)',
'title_desc' => 'Title (Z-A)',
'year_asc' => 'Release Year (Oldest First)',
'year_desc' => 'Release Year (Newest First)',
'rating_desc' => 'Highest Rated',
'views_desc' => 'Most Viewed',
'added_desc' => 'Recently Added',
'added_asc' => 'Oldest Added',
'last_watched_desc' => 'Last Watched'
]
]);
}

View File

@@ -33,32 +33,27 @@ class SearchController extends Controller
$results = [];
// Search movies (including adult videos)
$movieStmt = $this->pdo->prepare("
$searchTerm = $this->pdo->quote("%$search%");
$movieStmt = $this->pdo->query("
SELECT m.*, s.display_name as source_name, 'movie' as type
FROM movies m
JOIN sources s ON m.source_id = s.id
WHERE (m.title LIKE :search OR m.overview LIKE :search)
WHERE (m.title LIKE $searchTerm OR m.overview LIKE $searchTerm)
ORDER BY m.title
LIMIT 20
");
$searchParam = "%{$search}%";
$movieStmt->bindParam(':search', $searchParam, \PDO::PARAM_STR);
$movieStmt->execute();
$results['movies'] = $movieStmt->fetchAll(\PDO::FETCH_ASSOC);
// Search games
$gameStmt = $this->pdo->prepare("
SELECT g.*, s.display_name as source_name, 'game' as type
$gameStmt = $this->pdo->query("
SELECT g.*, 'game' as type
FROM games g
JOIN sources s ON g.source_id = s.id
WHERE (g.name LIKE :search OR g.description LIKE :search)
ORDER BY g.name
WHERE (g.title LIKE $searchTerm OR g.description LIKE $searchTerm)
ORDER BY g.title
LIMIT 20
");
$gameStmt->bindParam(':search', $searchParam, \PDO::PARAM_STR);
$gameStmt->execute();
$results['games'] = $gameStmt->fetchAll(\PDO::FETCH_ASSOC);
return $this->view->render($response, 'search/index.twig', [
'title' => 'Search Results',
'search' => $search,

View File

@@ -25,7 +25,7 @@ class AdultVideo extends Model
'external_id'
];
public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $directors = []): array
public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $directors = [], string $sort = 'recent'): array
{
$offset = ($page - 1) * $perPage;
@@ -61,7 +61,24 @@ class AdultVideo extends Model
$sql .= $whereClause . " av.director IN (" . implode(',', $placeholders) . ")";
}
$sql .= " ORDER BY av.created_at DESC LIMIT :limit OFFSET :offset";
// Add sorting
$sortOptions = [
'recent' => 'av.created_at DESC',
'oldest' => 'av.created_at ASC',
'title_asc' => 'av.title ASC',
'title_desc' => 'av.title DESC',
'year_asc' => 'av.release_date ASC',
'year_desc' => 'av.release_date DESC',
'rating_asc' => 'av.rating ASC',
'rating_desc' => 'av.rating DESC',
'views_asc' => 'av.watch_count ASC',
'views_desc' => 'av.watch_count DESC',
'runtime_asc' => 'av.runtime_minutes ASC',
'runtime_desc' => 'av.runtime_minutes DESC',
];
$sortOrder = $sortOptions[$sort] ?? $sortOptions['recent'];
$sql .= " ORDER BY " . $sortOrder . " LIMIT :limit OFFSET :offset";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT);
@@ -143,7 +160,7 @@ class AdultVideo extends Model
/**
* Get all actors associated with this adult video
*/
public function actors()
public function actors($args)
{
$stmt = $this->pdo->prepare("
SELECT a.*
@@ -152,7 +169,7 @@ class AdultVideo extends Model
WHERE aav.adult_video_id = :adult_video_id
ORDER BY a.name ASC
");
$stmt->execute(['adult_video_id' => $this->id]);
$stmt->execute(['adult_video_id' => $args]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
@@ -200,21 +217,6 @@ class AdultVideo extends Model
]);
}
/**
* Get TV show statistics
*/
public static function getStats(\PDO $pdo): array
{
$stmt = $pdo->query("
SELECT
COUNT(*) as total_adult_videos,
COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_adult_videos,
AVG(rating) as avg_rating
FROM adult_videos
");
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
/**
* Get available genres for filtering
*/
@@ -242,4 +244,36 @@ class AdultVideo extends Model
");
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}
/**
* Get TV show statistics
*/
public static function getStats(\PDO $pdo): array
{
$stmt = $pdo->query("
SELECT
COUNT(*) as total_adult_videos,
COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_adult_videos,
AVG(rating) as avg_rating
FROM adult_videos
");
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
/**
* Search actors by name
*/
public static function searchActors(\PDO $pdo, string $query): array
{
$stmt = $pdo->prepare("
SELECT a.*
FROM actors a
WHERE a.name LIKE :query
ORDER BY a.name
LIMIT 10
");
$stmt->execute(['query' => "%{$query}%"]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
}

View File

@@ -177,9 +177,9 @@ class Game extends Model
}
$stmt = $this->pdo->prepare("
SELECT g.*, s.display_name as source_name
SELECT g.*, g.platform as source_name
FROM games g
JOIN sources s ON g.source_id = s.id
WHERE g.game_key = :game_key
ORDER BY g.platform, g.source_id
");
@@ -215,9 +215,9 @@ class Game extends Model
// Enhance each game with platform details
foreach ($games as &$game) {
$game['platforms'] = array_unique(explode(',', $game['platforms']));
$game['source_ids'] = array_unique(explode(',', $game['source_ids']));
$game['genres'] = array_unique(array_filter(explode(',', $game['genres'])));
$game['platforms'] = !empty($game['platforms']) ? array_unique(explode(',', $game['platforms'])) : [];
$game['source_ids'] = !empty($game['source_ids']) ? array_unique(explode(',', $game['source_ids'])) : [];
$game['genres'] = !empty($game['genres']) ? array_unique(array_filter(explode(',', $game['genres']))) : [];
}
return $games;
@@ -298,7 +298,7 @@ class Game extends Model
/**
* Get grouped games with pagination and search support
*/
public static function getGroupedGamesWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $platforms = []): array
public static function getGroupedGamesWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $platforms = [], string $sort = 'title_asc'): array
{
$offset = ($page - 1) * $perPage;
@@ -313,7 +313,9 @@ class Game extends Model
MAX(last_played_at) as last_played_at,
SUM(playtime_minutes) as total_playtime,
MAX(completion_percentage) as max_completion,
GROUP_CONCAT(DISTINCT genre ORDER BY genre) as genres
GROUP_CONCAT(DISTINCT genre ORDER BY genre) as genres,
MAX(release_date) as release_date,
MAX(added_at) as added_at
FROM games
WHERE game_key IS NOT NULL
";
@@ -343,7 +345,24 @@ class Game extends Model
$sql .= " AND platform IN (" . implode(',', $placeholders) . ")";
}
$sql .= " GROUP BY game_key, title ORDER BY last_played_at DESC, total_playtime DESC LIMIT :limit OFFSET :offset";
// Add sorting
$sortOptions = [
'title_asc' => 'title ASC',
'title_desc' => 'title DESC',
'year_asc' => 'release_date ASC NULLS LAST',
'year_desc' => 'release_date DESC NULLS LAST',
'playtime_asc' => 'total_playtime ASC',
'playtime_desc' => 'total_playtime DESC',
'completion_asc' => 'max_completion ASC NULLS LAST',
'completion_desc' => 'max_completion DESC NULLS LAST',
'added_asc' => 'added_at ASC NULLS LAST',
'added_desc' => 'added_at DESC NULLS LAST',
'last_played_asc' => 'last_played_at ASC NULLS LAST',
'last_played_desc' => 'last_played_at DESC NULLS LAST'
];
$sortClause = $sortOptions[$sort] ?? 'title ASC';
$sql .= " GROUP BY game_key, title ORDER BY $sortClause LIMIT :limit OFFSET :offset";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT);
@@ -358,20 +377,52 @@ class Game extends Model
// Enhance each game with platform details
foreach ($games as &$game) {
$game['platforms'] = array_unique(explode(',', $game['platforms']));
$game['source_ids'] = array_unique(explode(',', $game['source_ids']));
$game['genres'] = array_unique(array_filter(explode(',', $game['genres'])));
$game['platforms'] = !empty($game['platforms']) ? array_unique(explode(',', $game['platforms'])) : [];
$game['source_ids'] = !empty($game['source_ids']) ? array_unique(explode(',', $game['source_ids'])) : [];
$game['genres'] = !empty($game['genres']) ? array_unique(array_filter(explode(',', $game['genres']))) : [];
}
return $games;
}
/**
* Get Playnite-specific genres
* Get all unique genres from the games table
* Combines both Playnite JSON genres and regular genre field
*/
public function getGenres(): array
{
return $this->genres_json ?? [];
// First get genres from the regular genre field
$stmt = $this->pdo->query("SELECT DISTINCT genre FROM {$this->table} WHERE genre IS NOT NULL AND genre != ''");
$genres = [];
$results = $stmt->fetchAll(\PDO::FETCH_COLUMN);
// Flatten and deduplicate genres
foreach ($results as $genreList) {
$genreArray = array_map('trim', explode(',', $genreList));
$genres = array_merge($genres, $genreArray);
}
// Also get genres from Playnite JSON data
$stmt = $this->pdo->query("SELECT genres_json FROM {$this->table} WHERE genres_json IS NOT NULL AND genres_json != '[]'");
$jsonGenres = $stmt->fetchAll(\PDO::FETCH_COLUMN);
foreach ($jsonGenres as $json) {
$decoded = json_decode($json, true);
if (is_array($decoded)) {
foreach ($decoded as $genre) {
if (is_array($genre) && isset($genre['Name'])) {
$genres[] = $genre['Name'];
} elseif (is_string($genre)) {
$genres[] = $genre;
}
}
}
}
$genres = array_unique($genres);
sort($genres);
return array_values(array_filter($genres));
}
/**
@@ -397,6 +448,16 @@ class Game extends Model
{
return $this->tags_json ?? [];
}
/**
* Get all unique platforms from the games table
*/
public function getPlatforms(): array
{
$stmt = $this->pdo->query("SELECT DISTINCT platform FROM {$this->table} WHERE platform IS NOT NULL AND platform != '' ORDER BY platform");
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}
/**
* Get Playnite-specific features

View File

@@ -46,6 +46,150 @@ class Movie extends Model
return $sourceData ? new Source($this->pdo, $sourceData) : null;
}
/**
* Get total count of movies with optional filters
*/
public static function getTotalCount(\PDO $pdo, string $search = '', array $genres = [], array $directors = []): int
{
$where = [];
$params = [];
$sql = "SELECT COUNT(*) as count FROM movies m JOIN sources s ON m.source_id = s.id";
if (!empty($search)) {
$where[] = "(m.title LIKE :search OR m.overview LIKE :search)";
$params[':search'] = "%$search%";
}
if (!empty($genres)) {
$genreConditions = [];
foreach ($genres as $i => $genre) {
$param = ":genre_$i";
$genreConditions[] = "m.genre LIKE $param";
$params[$param] = "%$genre%";
}
$where[] = "(" . implode(' OR ', $genreConditions) . ")";
}
if (!empty($directors)) {
$directorConditions = [];
foreach ($directors as $i => $director) {
$param = ":director_$i";
$directorConditions[] = "m.director LIKE $param";
$params[$param] = "%$director%";
}
$where[] = "(" . implode(' OR ', $directorConditions) . ")";
}
if (!empty($where)) {
$sql .= " WHERE " . implode(' AND ', $where);
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return (int)$stmt->fetchColumn();
}
/**
* Get paginated movies with optional filters
*/
public function getPaginated(
\PDO $pdo,
int $page = 1,
int $perPage = 20,
string $search = '',
array $genres = [],
string $sort = 'title_asc'
): array {
$offset = ($page - 1) * $perPage;
$where = [];
$params = [];
if (!empty($search)) {
$where[] = "(title LIKE :search OR overview LIKE :search OR director LIKE :search OR writer LIKE :search)";
$params[':search'] = "%$search%";
}
if (!empty($genres)) {
$genreConditions = [];
foreach ($genres as $i => $genre) {
$param = ":genre$i";
$genreConditions[] = "genre LIKE $param";
$params[$param] = "%$genre%";
}
$where[] = "(" . implode(' OR ', $genreConditions) . ")";
}
// Determine sort order
$orderBy = 'title ASC';
switch ($sort) {
case 'title_desc':
$orderBy = 'title DESC';
break;
case 'release_asc':
$orderBy = 'release_date ASC';
break;
case 'release_desc':
$orderBy = 'release_date DESC';
break;
case 'rating_desc':
$orderBy = 'rating DESC';
break;
case 'rating_asc':
$orderBy = 'rating ASC';
break;
}
$sql = "SELECT * FROM {$this->table}";
if (!empty($where)) {
$sql .= " WHERE " . implode(' AND ', $where);
}
$sql .= " ORDER BY $orderBy LIMIT :limit OFFSET :offset";
$stmt = $pdo->prepare($sql);
// Bind parameters
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Get all unique genres from movies
*/
public function getGenres(\PDO $pdo): array
{
$stmt = $pdo->query("SELECT DISTINCT genre FROM {$this->table} WHERE genre IS NOT NULL AND genre != ''");
$results = $stmt->fetchAll(\PDO::FETCH_COLUMN);
$genres = [];
foreach ($results as $genreList) {
$genreArray = array_map('trim', explode(',', $genreList));
$genres = array_merge($genres, $genreArray);
}
$genres = array_unique($genres);
sort($genres);
return array_values(array_filter($genres));
}
/**
* Get all unique directors from movies
*/
public function getDirectors(\PDO $pdo): array
{
$stmt = $pdo->query("SELECT DISTINCT director FROM {$this->table} WHERE director IS NOT NULL AND director != '' ORDER BY director");
return $stmt->fetchAll(\PDO::FETCH_COLUMN);
}
public function markAsWatched(): bool
{
$this->watched = true;
@@ -108,7 +252,7 @@ class Movie extends Model
$stmt->execute(['limit' => $limit]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/*
public static function getTotalCount(\PDO $pdo, string $search = '', array $genres = [], array $directors = []): int
{
$sql = "SELECT COUNT(*) as count FROM movies m JOIN sources s ON m.source_id = s.id";
@@ -146,8 +290,8 @@ class Movie extends Model
$stmt->execute();
return (int) $stmt->fetch()['count'];
}
public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $directors = []): array
*/
public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = '', array $genres = [], array $directors = [], string $sort = 'title_asc'): array
{
$offset = ($page - 1) * $perPage;
@@ -170,20 +314,47 @@ class Movie extends Model
$params["genre_{$index}"] = $genre;
}
$whereClause = !empty($search) ? " AND" : " WHERE";
$sql .= $whereClause . " m.genre IN (" . implode(',', $placeholders) . ")";
$sql .= $whereClause . " (";
foreach ($placeholders as $i => $placeholder) {
if ($i > 0) $sql .= " OR ";
$sql .= "m.genre LIKE $placeholder";
}
$sql .= ")";
}
if (!empty($directors)) {
$placeholders = [];
foreach ($directors as $index => $director) {
$placeholders[] = ":director_{$index}";
$params["director_{$index}"] = $director;
$params["director_{$index}"] = "%$director%";
}
$whereClause = (!empty($search) || !empty($genres)) ? " AND" : " WHERE";
$sql .= $whereClause . " m.director IN (" . implode(',', $placeholders) . ")";
$sql .= $whereClause . " (";
foreach ($placeholders as $i => $placeholder) {
if ($i > 0) $sql .= " OR ";
$sql .= "m.director LIKE $placeholder";
}
$sql .= ")";
}
$sql .= " ORDER BY m.title ASC LIMIT :limit OFFSET :offset";
// Add sorting
$sortOptions = [
'title_asc' => 'm.title ASC',
'title_desc' => 'm.title DESC',
'year_asc' => 'm.release_date ASC',
'year_desc' => 'm.release_date DESC',
'rating_asc' => 'm.rating ASC NULLS LAST',
'rating_desc' => 'm.rating DESC NULLS LAST',
'views_asc' => 'm.watch_count ASC',
'views_desc' => 'm.watch_count DESC',
'added_asc' => 'm.created_at ASC',
'added_desc' => 'm.created_at DESC',
'last_watched_asc' => 'm.last_watched_at ASC NULLS LAST',
'last_watched_desc' => 'm.last_watched_at DESC NULLS LAST'
];
$sortClause = $sortOptions[$sort] ?? 'm.title ASC';
$sql .= " ORDER BY $sortClause LIMIT :limit OFFSET :offset";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT);

View File

@@ -51,20 +51,6 @@ class TvShow extends Model
]);
}
/**
* 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
]);
}
/**
* Get TV show statistics
*/
@@ -81,44 +67,64 @@ class TvShow extends Model
}
/**
* Get total count with optional search
* Get total count with optional search and filters
*/
public static function getTotalCount(\PDO $pdo, string $search = '', array $genres = [], array $years = []): int
{
public static function getTotalCount(
\PDO $pdo,
string $search = '',
array $genres = [],
array $networks = [],
array $statuses = []
): int {
$sql = "SELECT COUNT(*) as count FROM tv_shows t JOIN sources s ON t.source_id = s.id";
$params = [];
$whereClauses = [];
if (!empty($search)) {
$sql .= " WHERE t.title LIKE :search";
$whereClauses[] = "(t.title LIKE :search OR t.overview LIKE :search)";
$params['search'] = "%{$search}%";
}
if (!empty($genres)) {
$placeholders = [];
foreach ($genres as $index => $genre) {
$placeholders[] = ":genre_{$index}";
$params["genre_{$index}"] = $genre;
$genreConditions = [];
foreach ($genres as $i => $genre) {
$param = ":genre_{$i}";
$genreConditions[] = "FIND_IN_SET({$param}, t.genre) > 0";
$params["genre_{$i}"] = $genre;
}
$whereClause = !empty($search) ? " AND" : " WHERE";
$sql .= $whereClause . " t.genre IN (" . implode(',', $placeholders) . ")";
$whereClauses[] = '(' . implode(' OR ', $genreConditions) . ')';
}
if (!empty($years)) {
$placeholders = [];
foreach ($years as $index => $year) {
$placeholders[] = ":year_{$index}";
$params["year_{$index}"] = $year;
if (!empty($networks)) {
$networkConditions = [];
foreach ($networks as $i => $network) {
$param = ":network_{$i}";
$networkConditions[] = "t.networks LIKE {$param}";
$params["network_{$i}"] = "%\"name\":\"{$network}\"%";
}
$whereClause = (!empty($search) || !empty($genres)) ? " AND" : " WHERE";
$sql .= $whereClause . " YEAR(first_air_date) IN (" . implode(',', $placeholders) . ")";
$whereClauses[] = '(' . implode(' OR ', $networkConditions) . ')';
}
if (!empty($statuses)) {
$statusConditions = [];
foreach ($statuses as $i => $status) {
$param = ":status_{$i}";
$statusConditions[] = "t.status = {$param}";
$params["status_{$i}"] = $status;
}
$whereClauses[] = '(' . implode(' OR ', $statusConditions) . ')';
}
if (!empty($whereClauses)) {
$sql .= ' WHERE ' . implode(' AND ', $whereClauses);
}
$stmt = $pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue(":{$key}", $value);
$stmt->bindValue($key, $value);
}
$stmt->execute();
return (int) $stmt->fetch()['count'];
return (int) $stmt->fetch(\PDO::FETCH_COLUMN);
}
/**
@@ -206,6 +212,135 @@ class TvShow extends Model
return $sourceData ? new Source($this->pdo, $sourceData) : null;
}
/**
* Get paginated TV shows with filters
*/
public static function getPaginated(
\PDO $pdo,
int $page,
int $perPage,
string $search = '',
array $genres = [],
array $networks = [],
array $statuses = [],
string $sort = 'title_asc'
): array {
$offset = ($page - 1) * $perPage;
$sql = "
SELECT t.*, s.display_name as source_name
FROM tv_shows t
JOIN sources s ON t.source_id = s.id
";
$params = [];
$whereClauses = [];
if (!empty($search)) {
$whereClauses[] = "(t.title LIKE :search OR t.overview LIKE :search)";
$params['search'] = "%{$search}%";
}
if (!empty($genres)) {
$genreConditions = [];
foreach ($genres as $i => $genre) {
$param = ":genre_{$i}";
$genreConditions[] = "FIND_IN_SET({$param}, t.genre) > 0";
$params["genre_{$i}"] = $genre;
}
$whereClauses[] = '(' . implode(' OR ', $genreConditions) . ')';
}
if (!empty($networks)) {
$networkConditions = [];
foreach ($networks as $i => $network) {
$param = ":network_{$i}";
$networkConditions[] = "t.networks LIKE {$param}";
$params["network_{$i}"] = "%\"name\":\"{$network}\"%";
}
$whereClauses[] = '(' . implode(' OR ', $networkConditions) . ')';
}
if (!empty($statuses)) {
$statusConditions = [];
foreach ($statuses as $i => $status) {
$param = ":status_{$i}";
$statusConditions[] = "t.status = {$param}";
$params["status_{$i}"] = $status;
}
$whereClauses[] = '(' . implode(' OR ', $statusConditions) . ')';
}
if (!empty($whereClauses)) {
$sql .= ' WHERE ' . implode(' AND ', $whereClauses);
}
// Add sorting
$sortMap = [
'title_asc' => 't.title ASC',
'title_desc' => 't.title DESC',
'rating_desc' => 't.vote_average DESC NULLS LAST',
'rating_asc' => 't.vote_average ASC NULLS LAST',
'newest' => 't.first_air_date DESC NULLS LAST',
'oldest' => 't.first_air_date ASC NULLS LAST',
];
$sortClause = $sortMap[$sort] ?? 't.title ASC';
$sql .= " ORDER BY {$sortClause} LIMIT :limit OFFSET :offset";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, \PDO::PARAM_INT);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Get all available genres from TV shows
*/
public static function getGenres(\PDO $pdo): array
{
$stmt = $pdo->query("
SELECT DISTINCT TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(t.genre, ',', n.n), ',', -1)) as genre
FROM tv_shows t
JOIN (
SELECT 1 as n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL
SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6
) n
WHERE n.n <= LENGTH(t.genre) - LENGTH(REPLACE(t.genre, ',', '')) + 1
AND t.genre IS NOT NULL AND t.genre != ''
ORDER BY genre
");
$genres = $stmt->fetchAll(\PDO::FETCH_COLUMN);
return array_values(array_filter(array_unique($genres)));
}
/**
* Get all available networks from TV shows
*/
public static function getNetworks(\PDO $pdo): array
{
$stmt = $pdo->query("SELECT DISTINCT networks FROM tv_shows WHERE networks IS NOT NULL AND networks != ''");
$networks = [];
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
$showNetworks = json_decode($row['networks'], true) ?: [];
foreach ($showNetworks as $network) {
if (isset($network['name'])) {
$networks[$network['name']] = $network['name'];
}
}
}
sort($networks);
return array_values($networks);
}
public function getSeasonsWithEpisodes(): array
{
// Get all episodes for this TV show, grouped by season

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
class SteamGridDbService
{
private const BASE_URI = 'https://www.steamgriddb.com/api/v2/';
private Client $client;
private ?string $apiKey;
public function __construct(?string $apiKey = null)
{
$this->apiKey = $apiKey ?? $_ENV['STEAMGRIDDB_API_KEY'] ?? null;
$this->client = new Client([
'base_uri' => self::BASE_URI,
'headers' => [
'Authorization' => 'Bearer ' . $this->apiKey,
'Accept' => 'application/json',
],
'http_errors' => false,
]);
}
/**
* Search for games by name
*/
public function searchGames(string $query): array
{
try {
$response = $this->client->get('search/autocomplete/' . urlencode($query));
$data = json_decode($response->getBody()->getContents(), true);
return $data['data'] ?? [];
} catch (GuzzleException $e) {
return [];
}
}
/**
* Get game by ID
*/
public function getGame(int $gameId): ?array
{
try {
$response = $this->client->get('games/id/' . $gameId);
$data = json_decode($response->getBody()->getContents(), true);
return $data['data'] ?? null;
} catch (GuzzleException $e) {
return null;
}
}
/**
* Get game grids (covers)
*/
public function getGrids(int $gameId, array $options = []): array
{
return $this->getMedia('grids/game/' . $gameId, $options);
}
/**
* Get game heroes (backgrounds)
*/
public function getHeroes(int $gameId, array $options = []): array
{
return $this->getMedia('heroes/game/' . $gameId, $options);
}
/**
* Get game icons
*/
public function getIcons(int $gameId, array $options = []): array
{
return $this->getMedia('icons/game/' . $gameId, $options);
}
/**
* Get game logos
*/
public function getLogos(int $gameId, array $options = []): array
{
return $this->getMedia('logos/game/' . $gameId, $options);
}
/**
* Download a media file
*/
public function downloadMedia(string $url): ?string
{
try {
$response = $this->client->get($url, ['stream' => true]);
if ($response->getStatusCode() !== 200) {
return null;
}
$tempFile = tempnam(sys_get_temp_dir(), 'sgdb_');
file_put_contents($tempFile, $response->getBody());
return $tempFile;
} catch (GuzzleException $e) {
return null;
}
}
private function getMedia(string $endpoint, array $options = []): array
{
$query = [];
if (!empty($options['styles'])) {
$query['styles'] = is_array($options['styles']) ? implode(',', $options['styles']) : $options['styles'];
}
if (!empty($options['dimensions'])) {
$query['dimensions'] = is_array($options['dimensions']) ? implode(',', $options['dimensions']) : $options['dimensions'];
}
if (!empty($options['mimes'])) {
$query['mimes'] = is_array($options['mimes']) ? implode(',', $options['mimes']) : $options['mimes'];
}
if (!empty($options['types'])) {
$query['types'] = is_array($options['types']) ? implode(',', $options['types']) : $options['types'];
}
try {
$response = $this->client->get($endpoint, ['query' => $query]);
$data = json_decode($response->getBody()->getContents(), true);
return $data['data'] ?? [];
} catch (GuzzleException $e) {
return [];
}
}
}