mirror of
https://github.com/ceratic/MediaCollectorLibary.git
synced 2026-05-13 23:56:46 +02:00
first commit
This commit is contained in:
129
app/Controllers/AdminController.php
Normal file
129
app/Controllers/AdminController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use App\Models\Source;
|
||||
use App\Models\SyncLog;
|
||||
use App\Services\SteamSyncService;
|
||||
use App\Services\JellyfinSyncService;
|
||||
use App\Services\StashSyncService;
|
||||
use App\Services\XbvrSyncService;
|
||||
use App\Services\AdultSyncService;
|
||||
use App\Services\ExophaseSyncService;
|
||||
use PDO;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class AdminController extends Controller
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(PDO $pdo, Twig $view)
|
||||
{
|
||||
parent::__construct($view);
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
public function index(Request $request, Response $response, $args)
|
||||
{
|
||||
$sourceModel = new Source($this->pdo);
|
||||
$sources = $sourceModel->findAll();
|
||||
|
||||
$syncLogModel = new SyncLog($this->pdo);
|
||||
$recentSyncs = SyncLog::getRecent($this->pdo, 10);
|
||||
|
||||
return $this->view->render($response, 'admin/index.twig', [
|
||||
'title' => 'Admin Dashboard',
|
||||
'sources' => $sources,
|
||||
'recent_syncs' => $recentSyncs
|
||||
]);
|
||||
}
|
||||
|
||||
public function syncSource(Request $request, Response $response, $args)
|
||||
{
|
||||
$sourceId = $args['id'];
|
||||
$syncType = $request->getQueryParams()['type'] ?? 'full';
|
||||
|
||||
$sourceModel = new Source($this->pdo);
|
||||
$source = $sourceModel->find($sourceId);
|
||||
|
||||
if (!$source) {
|
||||
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
// Start sync in background (simplified - in production you'd use queues)
|
||||
$syncLogId = $this->startSync($source, $syncType);
|
||||
|
||||
return $this->json($response, [
|
||||
'success' => true,
|
||||
'sync_log_id' => $syncLogId,
|
||||
'message' => 'Sync started successfully'
|
||||
]);
|
||||
}
|
||||
|
||||
public function syncStatus(Request $request, Response $response, $args)
|
||||
{
|
||||
$syncLogId = $args['id'];
|
||||
|
||||
$syncLogModel = new SyncLog($this->pdo);
|
||||
$syncLog = $syncLogModel->find($syncLogId);
|
||||
|
||||
if (!$syncLog) {
|
||||
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
return $this->json($response, [
|
||||
'id' => $syncLog['id'],
|
||||
'status' => $syncLog['status'],
|
||||
'sync_type' => $syncLog['sync_type'],
|
||||
'total_items' => $syncLog['total_items'],
|
||||
'processed_items' => $syncLog['processed_items'],
|
||||
'new_items' => $syncLog['new_items'],
|
||||
'updated_items' => $syncLog['updated_items'],
|
||||
'deleted_items' => $syncLog['deleted_items'],
|
||||
'started_at' => $syncLog['started_at'],
|
||||
'completed_at' => $syncLog['completed_at'],
|
||||
'message' => $syncLog['message'],
|
||||
'errors' => $syncLog['errors'] ? json_decode($syncLog['errors'], true) : []
|
||||
]);
|
||||
}
|
||||
|
||||
public function sources(Request $request, Response $response, $args)
|
||||
{
|
||||
$sourceModel = new Source($this->pdo);
|
||||
$sources = $sourceModel->findAll();
|
||||
|
||||
return $this->view->render($response, 'admin/sources.twig', [
|
||||
'title' => 'Source Management',
|
||||
'sources' => $sources
|
||||
]);
|
||||
}
|
||||
|
||||
private function startSync(array $source, string $syncType): int
|
||||
{
|
||||
// Create appropriate sync service based on source type
|
||||
switch ($source['name']) {
|
||||
case 'steam':
|
||||
$syncService = new SteamSyncService($this->pdo, $source);
|
||||
break;
|
||||
case 'jellyfin':
|
||||
$syncService = new JellyfinSyncService($this->pdo, $source);
|
||||
break;
|
||||
case 'stash':
|
||||
$syncService = new StashSyncService($this->pdo, $source);
|
||||
break;
|
||||
case 'adult':
|
||||
$syncService = new AdultSyncService($this->pdo, $source);
|
||||
break;
|
||||
case 'exophase':
|
||||
$syncService = new ExophaseSyncService($this->pdo, $source);
|
||||
break;
|
||||
default:
|
||||
throw new \Exception('Unsupported source type: ' . $source['name']);
|
||||
}
|
||||
|
||||
// Start sync (this would typically be queued in production)
|
||||
return $syncService->startSync($syncType);
|
||||
}
|
||||
}
|
||||
99
app/Controllers/AdultController.php
Normal file
99
app/Controllers/AdultController.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use App\Models\AdultVideo;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class AdultController extends Controller
|
||||
{
|
||||
private \PDO $pdo;
|
||||
|
||||
public function __construct(\PDO $pdo, Twig $view)
|
||||
{
|
||||
parent::__construct($view);
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
public function index(Request $request, Response $response, $args)
|
||||
{
|
||||
$queryParams = $request->getQueryParams();
|
||||
|
||||
// Get pagination parameters
|
||||
$page = max(1, (int)($queryParams['page'] ?? 1));
|
||||
$perPage = max(12, min(100, (int)($queryParams['per_page'] ?? 24)));
|
||||
|
||||
// Get search parameters
|
||||
$search = trim($queryParams['search'] ?? '');
|
||||
|
||||
// Get view mode
|
||||
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
|
||||
|
||||
// Get adult videos with pagination and search
|
||||
$adultVideos = AdultVideo::getAllWithPagination($this->pdo, $page, $perPage, $search);
|
||||
|
||||
// Get total count for pagination
|
||||
$totalCount = AdultVideo::getTotalCount($this->pdo, $search);
|
||||
|
||||
// Calculate pagination info
|
||||
$totalPages = ceil($totalCount / $perPage);
|
||||
$hasNextPage = $page < $totalPages;
|
||||
$hasPrevPage = $page > 1;
|
||||
|
||||
return $this->view->render($response, 'adult/index.twig', [
|
||||
'title' => 'Adult Videos',
|
||||
'movies' => $adultVideos, // Keep same variable name for template compatibility
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total_pages' => $totalPages,
|
||||
'total_items' => $totalCount,
|
||||
'has_next' => $hasNextPage,
|
||||
'has_prev' => $hasPrevPage,
|
||||
'next_page' => $page + 1,
|
||||
'prev_page' => $page - 1
|
||||
],
|
||||
'search' => $search,
|
||||
'view_mode' => $viewMode,
|
||||
'view_modes' => ['grid', 'list', 'covers']
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Response $response, $args)
|
||||
{
|
||||
$adultVideoId = (int) $args['id'];
|
||||
|
||||
// Get adult video details
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT av.*, s.display_name as source_name
|
||||
FROM adult_videos av
|
||||
JOIN sources s ON av.source_id = s.id
|
||||
WHERE av.id = :id
|
||||
");
|
||||
$stmt->execute(['id' => $adultVideoId]);
|
||||
$adultVideo = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$adultVideo) {
|
||||
return $response->withStatus(404);
|
||||
}
|
||||
|
||||
// Decode metadata for display
|
||||
$metadata = json_decode($adultVideo['metadata'], true);
|
||||
|
||||
return $this->view->render($response, 'adult/show.twig', [
|
||||
'title' => $adultVideo['title'],
|
||||
'movie' => $adultVideo, // Keep same variable name for template compatibility
|
||||
'metadata' => $metadata
|
||||
]);
|
||||
}
|
||||
|
||||
private function getAdultSourceId(): ?int
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT id FROM sources WHERE name = 'adult' LIMIT 1");
|
||||
$stmt->execute();
|
||||
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
return $result ? (int) $result['id'] : null;
|
||||
}
|
||||
}
|
||||
76
app/Controllers/AuthController.php
Normal file
76
app/Controllers/AuthController.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use App\Services\AuthService;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
private AuthService $auth;
|
||||
|
||||
public function __construct(AuthService $auth, Twig $view)
|
||||
{
|
||||
parent::__construct($view);
|
||||
$this->auth = $auth;
|
||||
}
|
||||
|
||||
public function showLogin(Request $request, Response $response, $args)
|
||||
{
|
||||
// If already logged in, redirect to dashboard
|
||||
if ($this->auth->isLoggedIn()) {
|
||||
return $response->withStatus(302)->withHeader('Location', '/');
|
||||
}
|
||||
|
||||
return $this->view->render($response, 'auth/login.twig', [
|
||||
'title' => 'Login',
|
||||
'csrf_token' => $this->auth->generateCSRFToken()
|
||||
]);
|
||||
}
|
||||
|
||||
public function login(Request $request, Response $response, $args)
|
||||
{
|
||||
$data = $request->getParsedBody();
|
||||
$username = $data['username'] ?? '';
|
||||
$password = $data['password'] ?? '';
|
||||
$csrfToken = $data['csrf_token'] ?? '';
|
||||
|
||||
// Verify CSRF token
|
||||
if (!$this->auth->verifyCSRFToken($csrfToken)) {
|
||||
return $this->view->render($response->withStatus(400), 'auth/login.twig', [
|
||||
'title' => 'Login',
|
||||
'error' => 'Invalid CSRF token',
|
||||
'csrf_token' => $this->auth->generateCSRFToken()
|
||||
]);
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if (empty($username) || empty($password)) {
|
||||
return $this->view->render($response->withStatus(400), 'auth/login.twig', [
|
||||
'title' => 'Login',
|
||||
'error' => 'Username and password are required',
|
||||
'csrf_token' => $this->auth->generateCSRFToken()
|
||||
]);
|
||||
}
|
||||
|
||||
// Attempt login
|
||||
if ($this->auth->login($username, $password, $_SERVER['REMOTE_ADDR'] ?? null)) {
|
||||
return $response->withStatus(302)->withHeader('Location', '/');
|
||||
}
|
||||
|
||||
// Login failed
|
||||
return $this->view->render($response->withStatus(401), 'auth/login.twig', [
|
||||
'title' => 'Login',
|
||||
'error' => 'Invalid username or password',
|
||||
'csrf_token' => $this->auth->generateCSRFToken()
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request, Response $response, $args)
|
||||
{
|
||||
$this->auth->logout();
|
||||
return $response->withStatus(302)->withHeader('Location', '/login');
|
||||
}
|
||||
}
|
||||
25
app/Controllers/Controller.php
Normal file
25
app/Controllers/Controller.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
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)
|
||||
{
|
||||
$this->view = $view;
|
||||
}
|
||||
|
||||
protected function json(Response $response, $data, int $status = 200): Response
|
||||
{
|
||||
$response->getBody()->write(json_encode($data));
|
||||
return $response
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withStatus($status);
|
||||
}
|
||||
}
|
||||
82
app/Controllers/DashboardController.php
Normal file
82
app/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use App\Models\Game;
|
||||
use App\Models\Movie;
|
||||
use App\Models\TvShow;
|
||||
use App\Models\MusicArtist;
|
||||
use App\Models\SyncLog;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(Twig $view)
|
||||
{
|
||||
parent::__construct($view);
|
||||
}
|
||||
|
||||
public function index(Request $request, Response $response, $args)
|
||||
{
|
||||
$pdo = $this->view->getEnvironment()->getGlobals()['pdo'] ?? null;
|
||||
|
||||
if (!$pdo) {
|
||||
return $this->view->render($response, 'dashboard/index.twig', [
|
||||
'title' => 'Dashboard',
|
||||
'stats' => [
|
||||
'total_media' => 0,
|
||||
'total_games' => 0,
|
||||
'total_movies' => 0,
|
||||
'total_tv_shows' => 0,
|
||||
'total_episodes' => 0,
|
||||
'total_music' => 0,
|
||||
],
|
||||
'error' => 'Database connection not available'
|
||||
]);
|
||||
}
|
||||
|
||||
// Get statistics from models
|
||||
$gameStats = Game::getStats($pdo);
|
||||
$movieStats = Movie::getStats($pdo);
|
||||
$tvShowStats = TvShow::getStats($pdo);
|
||||
$musicStats = MusicArtist::getStats($pdo);
|
||||
$syncStats = SyncLog::getStats($pdo);
|
||||
|
||||
// Get recent activity
|
||||
$recentGames = Game::getRecent($pdo, 5);
|
||||
$recentMovies = Movie::getRecent($pdo, 5);
|
||||
$recentSyncs = SyncLog::getRecent($pdo, 5);
|
||||
|
||||
// Calculate total media count
|
||||
$totalMedia = ($gameStats['total_games'] ?? 0) +
|
||||
($movieStats['total_movies'] ?? 0) +
|
||||
($tvShowStats['total_shows'] ?? 0) +
|
||||
($musicStats['total_artists'] ?? 0);
|
||||
|
||||
$stats = [
|
||||
'total_media' => $totalMedia,
|
||||
'total_games' => $gameStats['total_games'] ?? 0,
|
||||
'total_movies' => $movieStats['total_movies'] ?? 0,
|
||||
'total_tv_shows' => $tvShowStats['total_shows'] ?? 0,
|
||||
'total_episodes' => $tvShowStats['total_episodes'] ?? 0,
|
||||
'total_music' => $musicStats['total_artists'] ?? 0,
|
||||
'total_playtime' => $gameStats['total_playtime'] ?? 0,
|
||||
'watched_movies' => $movieStats['watched_movies'] ?? 0,
|
||||
'favorite_games' => $gameStats['favorite_games'] ?? 0,
|
||||
'favorite_movies' => $movieStats['favorite_movies'] ?? 0,
|
||||
'favorite_shows' => $tvShowStats['favorite_shows'] ?? 0,
|
||||
'favorite_music' => $musicStats['favorite_artists'] ?? 0,
|
||||
];
|
||||
|
||||
return $this->view->render($response, 'dashboard/index.twig', [
|
||||
'title' => 'Dashboard',
|
||||
'stats' => $stats,
|
||||
'recent_games' => $recentGames,
|
||||
'recent_movies' => $recentMovies,
|
||||
'recent_syncs' => $recentSyncs,
|
||||
'sync_stats' => $syncStats
|
||||
]);
|
||||
}
|
||||
}
|
||||
95
app/Controllers/GameController.php
Normal file
95
app/Controllers/GameController.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use App\Models\Game;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class GameController extends Controller
|
||||
{
|
||||
private \PDO $pdo;
|
||||
|
||||
public function __construct(\PDO $pdo, Twig $view)
|
||||
{
|
||||
parent::__construct($view);
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
public function index(Request $request, Response $response, $args)
|
||||
{
|
||||
$queryParams = $request->getQueryParams();
|
||||
|
||||
// Get pagination parameters
|
||||
$page = max(1, (int)($queryParams['page'] ?? 1));
|
||||
$perPage = max(12, min(100, (int)($queryParams['per_page'] ?? 24)));
|
||||
|
||||
// Get search parameters
|
||||
$search = trim($queryParams['search'] ?? '');
|
||||
|
||||
// Get view mode
|
||||
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
|
||||
|
||||
// Get games with pagination and search
|
||||
$games = Game::getGroupedGamesWithPagination($this->pdo, $page, $perPage, $search);
|
||||
|
||||
// Get total count for pagination
|
||||
$totalCount = Game::getTotalCount($this->pdo, $search);
|
||||
|
||||
// Calculate pagination info
|
||||
$totalPages = ceil($totalCount / $perPage);
|
||||
$hasNextPage = $page < $totalPages;
|
||||
$hasPrevPage = $page > 1;
|
||||
|
||||
return $this->view->render($response, 'games/index.twig', [
|
||||
'title' => 'Games',
|
||||
'games' => $games,
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total_pages' => $totalPages,
|
||||
'total_items' => $totalCount,
|
||||
'has_next' => $hasNextPage,
|
||||
'has_prev' => $hasPrevPage,
|
||||
'next_page' => $page + 1,
|
||||
'prev_page' => $page - 1
|
||||
],
|
||||
'search' => $search,
|
||||
'view_mode' => $viewMode,
|
||||
'view_modes' => ['grid', 'list', 'covers']
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Response $response, $args)
|
||||
{
|
||||
$gameKey = $args['game_key'];
|
||||
|
||||
// Find the main game entry (could be any platform version)
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT g.*, s.display_name as source_name
|
||||
FROM games g
|
||||
JOIN sources s ON g.source_id = s.id
|
||||
WHERE g.game_key = :game_key
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute(['game_key' => $gameKey]);
|
||||
$mainGame = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$mainGame) {
|
||||
return $response->withStatus(404);
|
||||
}
|
||||
|
||||
// Get all platform versions
|
||||
$gameModel = new Game($this->pdo);
|
||||
$gameModel->id = $mainGame['id'];
|
||||
$gameModel->game_key = $mainGame['game_key'];
|
||||
$platformVersions = $gameModel->getPlatformVersions();
|
||||
|
||||
return $this->view->render($response, 'games/show.twig', [
|
||||
'title' => $mainGame['title'],
|
||||
'main_game' => $mainGame,
|
||||
'platform_versions' => $platformVersions
|
||||
]);
|
||||
}
|
||||
}
|
||||
91
app/Controllers/MovieController.php
Normal file
91
app/Controllers/MovieController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use App\Models\Movie;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class MovieController extends Controller
|
||||
{
|
||||
private \PDO $pdo;
|
||||
|
||||
public function __construct(\PDO $pdo, Twig $view)
|
||||
{
|
||||
parent::__construct($view);
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
public function index(Request $request, Response $response, $args)
|
||||
{
|
||||
$queryParams = $request->getQueryParams();
|
||||
|
||||
// Get pagination parameters
|
||||
$page = max(1, (int)($queryParams['page'] ?? 1));
|
||||
$perPage = max(12, min(100, (int)($queryParams['per_page'] ?? 24)));
|
||||
|
||||
// Get search parameters
|
||||
$search = trim($queryParams['search'] ?? '');
|
||||
|
||||
// Get view mode
|
||||
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
|
||||
|
||||
// Get movies with pagination and search
|
||||
$movies = Movie::getAllWithPagination($this->pdo, $page, $perPage, $search);
|
||||
|
||||
// Get total count for pagination
|
||||
$totalCount = Movie::getTotalCount($this->pdo, $search);
|
||||
|
||||
// Calculate pagination info
|
||||
$totalPages = ceil($totalCount / $perPage);
|
||||
$hasNextPage = $page < $totalPages;
|
||||
$hasPrevPage = $page > 1;
|
||||
|
||||
return $this->view->render($response, 'movies/index.twig', [
|
||||
'title' => 'Movies',
|
||||
'movies' => $movies,
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total_pages' => $totalPages,
|
||||
'total_items' => $totalCount,
|
||||
'has_next' => $hasNextPage,
|
||||
'has_prev' => $hasPrevPage,
|
||||
'next_page' => $page + 1,
|
||||
'prev_page' => $page - 1
|
||||
],
|
||||
'search' => $search,
|
||||
'view_mode' => $viewMode,
|
||||
'view_modes' => ['grid', 'list', 'covers']
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Response $response, $args)
|
||||
{
|
||||
$movieId = (int) $args['id'];
|
||||
|
||||
// Get movie details
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT m.*, s.display_name as source_name
|
||||
FROM movies m
|
||||
JOIN sources s ON m.source_id = s.id
|
||||
WHERE m.id = :id
|
||||
");
|
||||
$stmt->execute(['id' => $movieId]);
|
||||
$movie = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$movie) {
|
||||
return $response->withStatus(404);
|
||||
}
|
||||
|
||||
// Decode metadata for display
|
||||
$metadata = json_decode($movie['metadata'], true);
|
||||
|
||||
return $this->view->render($response, 'movies/show.twig', [
|
||||
'title' => $movie['title'],
|
||||
'movie' => $movie,
|
||||
'metadata' => $metadata
|
||||
]);
|
||||
}
|
||||
}
|
||||
72
app/Controllers/MusicController.php
Normal file
72
app/Controllers/MusicController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class MusicController extends Controller
|
||||
{
|
||||
private \PDO $pdo;
|
||||
|
||||
public function __construct(\PDO $pdo, Twig $view)
|
||||
{
|
||||
parent::__construct($view);
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
public function index(Request $request, Response $response, $args)
|
||||
{
|
||||
$queryParams = $request->getQueryParams();
|
||||
|
||||
// Get pagination parameters
|
||||
$page = max(1, (int)($queryParams['page'] ?? 1));
|
||||
$perPage = max(12, min(100, (int)($queryParams['per_page'] ?? 24)));
|
||||
|
||||
// Get search parameters
|
||||
$search = trim($queryParams['search'] ?? '');
|
||||
|
||||
// Get view mode
|
||||
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
|
||||
|
||||
// For now, return empty arrays since Music isn't implemented yet
|
||||
$music = [];
|
||||
$totalCount = 0;
|
||||
|
||||
// Calculate pagination info
|
||||
$totalPages = 0;
|
||||
$hasNextPage = false;
|
||||
$hasPrevPage = false;
|
||||
|
||||
return $this->view->render($response, 'music/index.twig', [
|
||||
'title' => 'Music',
|
||||
'music' => $music,
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total_pages' => $totalPages,
|
||||
'total_items' => $totalCount,
|
||||
'has_next' => $hasNextPage,
|
||||
'has_prev' => $hasPrevPage,
|
||||
'next_page' => $page + 1,
|
||||
'prev_page' => $page - 1
|
||||
],
|
||||
'search' => $search,
|
||||
'view_mode' => $viewMode,
|
||||
'view_modes' => ['grid', 'list', 'covers']
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Response $response, $args)
|
||||
{
|
||||
$musicId = (int) $args['id'];
|
||||
|
||||
// For now, return a placeholder since Music isn't implemented yet
|
||||
return $this->view->render($response, 'music/show.twig', [
|
||||
'title' => 'Music Details',
|
||||
'music' => ['id' => $musicId, 'title' => 'Coming Soon'],
|
||||
'message' => 'Music details page is not yet implemented.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
68
app/Controllers/SearchController.php
Normal file
68
app/Controllers/SearchController.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
private \PDO $pdo;
|
||||
|
||||
public function __construct(\PDO $pdo, Twig $view)
|
||||
{
|
||||
parent::__construct($view);
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
public function index(Request $request, Response $response, $args)
|
||||
{
|
||||
$queryParams = $request->getQueryParams();
|
||||
$search = trim($queryParams['q'] ?? '');
|
||||
|
||||
if (empty($search)) {
|
||||
return $this->view->render($response, 'search/index.twig', [
|
||||
'title' => 'Search',
|
||||
'search' => $search,
|
||||
'results' => []
|
||||
]);
|
||||
}
|
||||
|
||||
// Search across different media types
|
||||
$results = [];
|
||||
|
||||
// Search movies (including adult videos)
|
||||
$movieStmt = $this->pdo->prepare("
|
||||
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)
|
||||
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
|
||||
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
|
||||
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,
|
||||
'results' => $results
|
||||
]);
|
||||
}
|
||||
}
|
||||
72
app/Controllers/TvShowController.php
Normal file
72
app/Controllers/TvShowController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class TvShowController extends Controller
|
||||
{
|
||||
private \PDO $pdo;
|
||||
|
||||
public function __construct(\PDO $pdo, Twig $view)
|
||||
{
|
||||
parent::__construct($view);
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
public function index(Request $request, Response $response, $args)
|
||||
{
|
||||
$queryParams = $request->getQueryParams();
|
||||
|
||||
// Get pagination parameters
|
||||
$page = max(1, (int)($queryParams['page'] ?? 1));
|
||||
$perPage = max(12, min(100, (int)($queryParams['per_page'] ?? 24)));
|
||||
|
||||
// Get search parameters
|
||||
$search = trim($queryParams['search'] ?? '');
|
||||
|
||||
// Get view mode
|
||||
$viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers
|
||||
|
||||
// For now, return empty arrays since TV Shows aren't implemented yet
|
||||
$tvshows = [];
|
||||
$totalCount = 0;
|
||||
|
||||
// Calculate pagination info
|
||||
$totalPages = 0;
|
||||
$hasNextPage = false;
|
||||
$hasPrevPage = false;
|
||||
|
||||
return $this->view->render($response, 'tvshows/index.twig', [
|
||||
'title' => 'TV Shows',
|
||||
'tvshows' => $tvshows,
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total_pages' => $totalPages,
|
||||
'total_items' => $totalCount,
|
||||
'has_next' => $hasNextPage,
|
||||
'has_prev' => $hasPrevPage,
|
||||
'next_page' => $page + 1,
|
||||
'prev_page' => $page - 1
|
||||
],
|
||||
'search' => $search,
|
||||
'view_mode' => $viewMode,
|
||||
'view_modes' => ['grid', 'list', 'covers']
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Response $response, $args)
|
||||
{
|
||||
$tvShowId = (int) $args['id'];
|
||||
|
||||
// For now, return a placeholder since TV Shows aren't implemented yet
|
||||
return $this->view->render($response, 'tvshows/show.twig', [
|
||||
'title' => 'TV Show Details',
|
||||
'tvshow' => ['id' => $tvShowId, 'title' => 'Coming Soon'],
|
||||
'message' => 'TV show details page is not yet implemented.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
183
app/Database/Database.php
Normal file
183
app/Database/Database.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use Illuminate\Database\Capsule\Manager as Capsule;
|
||||
|
||||
class Database
|
||||
{
|
||||
private static ?Capsule $capsule = null;
|
||||
private static array $config = [];
|
||||
|
||||
public static function setConfig(array $config): void
|
||||
{
|
||||
self::$config = $config;
|
||||
}
|
||||
|
||||
public static function getInstance(): PDO
|
||||
{
|
||||
if (self::$capsule === null) {
|
||||
self::connect();
|
||||
}
|
||||
|
||||
return self::$capsule->getConnection()->getPdo();
|
||||
}
|
||||
|
||||
public static function getCapsule(): Capsule
|
||||
{
|
||||
if (self::$capsule === null) {
|
||||
self::connect();
|
||||
}
|
||||
|
||||
return self::$capsule;
|
||||
}
|
||||
|
||||
private static function connect(): void
|
||||
{
|
||||
self::$capsule = new Capsule();
|
||||
|
||||
self::$capsule->addConnection([
|
||||
'driver' => self::$config['driver'] ?? 'sqlite',
|
||||
'host' => self::$config['host'] ?? '127.0.0.1',
|
||||
'port' => self::$config['port'] ?? 3306,
|
||||
'database' => self::$config['database'] ?? '',
|
||||
'username' => self::$config['username'] ?? '',
|
||||
'password' => self::$config['password'] ?? '',
|
||||
'charset' => self::$config['charset'] ?? 'utf8mb4',
|
||||
'prefix' => self::$config['prefix'] ?? '',
|
||||
'schema' => self::$config['schema'] ?? 'public',
|
||||
]);
|
||||
|
||||
// Set as global for Eloquent
|
||||
self::$capsule->setAsGlobal();
|
||||
|
||||
// Boot Eloquent (for schema operations)
|
||||
self::$capsule->bootEloquent();
|
||||
|
||||
// Set up facade application
|
||||
if (!self::$capsule->getContainer()->bound('app')) {
|
||||
self::$capsule->getContainer()->instance('app', self::$capsule->getContainer());
|
||||
}
|
||||
}
|
||||
|
||||
public static function migrate(): void
|
||||
{
|
||||
$capsule = self::getCapsule();
|
||||
|
||||
// Create migrations table if it doesn't exist
|
||||
if (!$capsule->schema()->hasTable('migrations')) {
|
||||
$capsule->schema()->create('migrations', function ($table) {
|
||||
$table->unsignedInteger('id', true); // Primary key with AUTO_INCREMENT
|
||||
$table->string('migration');
|
||||
$table->integer('batch');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
// Get list of migration files
|
||||
$migrationFiles = glob(__DIR__ . '/../../database/migrations/*.php');
|
||||
|
||||
foreach ($migrationFiles as $file) {
|
||||
$migrationName = basename($file, '.php');
|
||||
|
||||
// Check if migration has already been run
|
||||
$ran = $capsule->table('migrations')->where('migration', $migrationName)->exists();
|
||||
|
||||
if ($ran) {
|
||||
echo "Migration {$migrationName} already run. Skipping.\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Run the migration
|
||||
echo "Running migration {$migrationName}\n";
|
||||
|
||||
// Include the migration file to make the class available
|
||||
require_once $file;
|
||||
|
||||
$className = self::getMigrationClassName($file);
|
||||
$migration = new $className();
|
||||
$migration->up();
|
||||
|
||||
// Record the migration
|
||||
$capsule->table('migrations')->insert([
|
||||
'migration' => $migrationName,
|
||||
'batch' => 1
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private static function getMigrationClassName(string $file): string
|
||||
{
|
||||
$content = file_get_contents($file);
|
||||
|
||||
// Extract class name from PHP file
|
||||
if (preg_match('/class\s+(\w+)\s+extends\s+Migration/', $content, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// Fallback: convert filename to class name
|
||||
$filename = basename($file, '.php');
|
||||
return str_replace(' ', '', ucwords(str_replace('_', ' ', $filename)));
|
||||
}
|
||||
|
||||
public static function seed(): void
|
||||
{
|
||||
$pdo = self::getInstance();
|
||||
|
||||
// Seed media types
|
||||
$mediaTypes = [
|
||||
['name' => 'games', 'display_name' => 'Games', 'icon' => 'game-controller'],
|
||||
['name' => 'movies', 'display_name' => 'Movies', 'icon' => 'film'],
|
||||
['name' => 'tv_shows', 'display_name' => 'TV Shows', 'icon' => 'tv'],
|
||||
['name' => 'music', 'display_name' => 'Music', 'icon' => 'musical-notes']
|
||||
];
|
||||
|
||||
foreach ($mediaTypes as $type) {
|
||||
$pdo->prepare("INSERT IGNORE INTO media_types (name, display_name, icon) VALUES (?, ?, ?)")
|
||||
->execute([$type['name'], $type['display_name'], $type['icon']]);
|
||||
}
|
||||
|
||||
// Seed sources
|
||||
$sources = [
|
||||
['name' => 'steam', 'display_name' => 'Steam', 'api_url' => null, 'api_key' => null],
|
||||
['name' => 'jellyfin', 'display_name' => 'Jellyfin', 'api_url' => null, 'api_key' => null],
|
||||
['name' => 'stash', 'display_name' => 'Stash', 'api_url' => null, 'api_key' => null],
|
||||
['name' => 'xbvr', 'display_name' => 'XBVR', 'api_url' => null, 'api_key' => null]
|
||||
];
|
||||
|
||||
foreach ($sources as $source) {
|
||||
$pdo->prepare("INSERT IGNORE INTO sources (name, display_name, api_url, api_key) VALUES (?, ?, ?, ?)")
|
||||
->execute([$source['name'], $source['display_name'], $source['api_url'], $source['api_key']]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function reset(): void
|
||||
{
|
||||
$pdo = self::getInstance();
|
||||
|
||||
// Drop all tables (in reverse order due to foreign keys)
|
||||
$tables = [
|
||||
'sync_logs',
|
||||
'music_tracks',
|
||||
'music_albums',
|
||||
'music_artists',
|
||||
'tv_episodes',
|
||||
'tv_shows',
|
||||
'movies',
|
||||
'games',
|
||||
'sources',
|
||||
'media_types',
|
||||
'migrations'
|
||||
];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
try {
|
||||
$pdo->exec("DROP TABLE IF EXISTS {$table}");
|
||||
} catch (PDOException $e) {
|
||||
// Ignore errors if table doesn't exist
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/Http/Middleware/AdminMiddleware.php
Normal file
33
app/Http/Middleware/AdminMiddleware.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\AuthService;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class AdminMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private AuthService $auth;
|
||||
|
||||
public function __construct(AuthService $auth)
|
||||
{
|
||||
$this->auth = $auth;
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
// Check if user is logged in and is admin
|
||||
if (!$this->auth->isLoggedIn() || !$this->auth->isAdmin()) {
|
||||
$response = new \Slim\Psr7\Response();
|
||||
return $response->withStatus(403)->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
// Add user to request attributes
|
||||
$request = $request->withAttribute('user', $this->auth->getCurrentUser());
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
33
app/Http/Middleware/AuthMiddleware.php
Normal file
33
app/Http/Middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\AuthService;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class AuthMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private AuthService $auth;
|
||||
|
||||
public function __construct(AuthService $auth)
|
||||
{
|
||||
$this->auth = $auth;
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
// Check if user is logged in
|
||||
if (!$this->auth->isLoggedIn()) {
|
||||
$response = new \Slim\Psr7\Response();
|
||||
return $response->withStatus(302)->withHeader('Location', '/login');
|
||||
}
|
||||
|
||||
// Add user to request attributes
|
||||
$request = $request->withAttribute('user', $this->auth->getCurrentUser());
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
109
app/Models/AdultVideo.php
Normal file
109
app/Models/AdultVideo.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class AdultVideo extends Model
|
||||
{
|
||||
protected string $table = 'adult_videos';
|
||||
protected array $fillable = [
|
||||
'title',
|
||||
'overview',
|
||||
'poster_url',
|
||||
'backdrop_url',
|
||||
'rating',
|
||||
'runtime_minutes',
|
||||
'release_date',
|
||||
'director',
|
||||
'writer',
|
||||
'cast',
|
||||
'genre',
|
||||
'metadata',
|
||||
'watched',
|
||||
'watch_count',
|
||||
'is_favorite',
|
||||
'source_id',
|
||||
'external_id'
|
||||
];
|
||||
|
||||
public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array
|
||||
{
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$whereClause = '';
|
||||
$params = [];
|
||||
|
||||
if (!empty($search)) {
|
||||
$whereClause = "WHERE (title LIKE :search OR overview LIKE :search)";
|
||||
$params['search'] = "%{$search}%";
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT av.*, s.display_name as source_name
|
||||
FROM adult_videos av
|
||||
JOIN sources s ON av.source_id = s.id
|
||||
{$whereClause}
|
||||
ORDER BY av.created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, \PDO::PARAM_INT);
|
||||
|
||||
if (!empty($search)) {
|
||||
$stmt->bindValue(':search', "%{$search}%", \PDO::PARAM_STR);
|
||||
}
|
||||
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public static function getTotalCount(\PDO $pdo, string $search = ''): int
|
||||
{
|
||||
$whereClause = '';
|
||||
$params = [];
|
||||
|
||||
if (!empty($search)) {
|
||||
$whereClause = "WHERE (title LIKE :search OR overview LIKE :search)";
|
||||
$params['search'] = "%{$search}%";
|
||||
}
|
||||
|
||||
$sql = "SELECT COUNT(*) as count FROM adult_videos {$whereClause}";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
|
||||
if (!empty($search)) {
|
||||
$stmt->bindValue(':search', "%{$search}%", \PDO::PARAM_STR);
|
||||
}
|
||||
|
||||
$stmt->execute();
|
||||
return (int) $stmt->fetch(\PDO::FETCH_ASSOC)['count'];
|
||||
}
|
||||
|
||||
public function markAsWatched(): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare("UPDATE adult_videos SET watched = 1, watch_count = watch_count + 1, updated_at = NOW() WHERE id = :id");
|
||||
return $stmt->execute(['id' => $this->id]);
|
||||
}
|
||||
|
||||
public function markAsUnwatched(): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare("UPDATE adult_videos SET watched = 0, updated_at = NOW() WHERE id = :id");
|
||||
return $stmt->execute(['id' => $this->id]);
|
||||
}
|
||||
|
||||
public function toggleFavorite(): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare("UPDATE adult_videos SET is_favorite = !is_favorite, updated_at = NOW() WHERE id = :id");
|
||||
return $stmt->execute(['id' => $this->id]);
|
||||
}
|
||||
|
||||
public function source(): ?Source
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM sources WHERE id = :source_id");
|
||||
$stmt->execute(['source_id' => $this->source_id]);
|
||||
$sourceData = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
return $sourceData ? new Source($this->pdo, $sourceData) : null;
|
||||
}
|
||||
}
|
||||
298
app/Models/Game.php
Normal file
298
app/Models/Game.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Game extends Model
|
||||
{
|
||||
protected string $table = 'games';
|
||||
protected array $fillable = [
|
||||
'title',
|
||||
'game_key',
|
||||
'description',
|
||||
'genre',
|
||||
'developer',
|
||||
'publisher',
|
||||
'release_date',
|
||||
'platform',
|
||||
'platform_game_id',
|
||||
'steam_app_id',
|
||||
'image_url',
|
||||
'banner_url',
|
||||
'rating',
|
||||
'playtime_minutes',
|
||||
'completion_percentage',
|
||||
'is_installed',
|
||||
'is_favorite',
|
||||
'metadata',
|
||||
'platform_achievements',
|
||||
'platform_stats',
|
||||
'source_id',
|
||||
'last_played_at'
|
||||
];
|
||||
|
||||
protected array $casts = [
|
||||
'rating' => 'float',
|
||||
'playtime_minutes' => 'int',
|
||||
'completion_percentage' => 'int',
|
||||
'is_installed' => 'bool',
|
||||
'is_favorite' => 'bool',
|
||||
'release_date' => 'date',
|
||||
'last_played_at' => 'datetime',
|
||||
'platform_achievements' => 'array',
|
||||
'platform_stats' => 'array'
|
||||
];
|
||||
|
||||
public function source()
|
||||
{
|
||||
return new Source($this->pdo);
|
||||
}
|
||||
|
||||
public function markAsPlayed(int $minutes = 60): bool
|
||||
{
|
||||
$this->playtime_minutes += $minutes;
|
||||
$this->last_played_at = date('Y-m-d H:i:s');
|
||||
return $this->update($this->id, [
|
||||
'playtime_minutes' => $this->playtime_minutes,
|
||||
'last_played_at' => $this->last_played_at
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleFavorite(): bool
|
||||
{
|
||||
return $this->update($this->id, [
|
||||
'is_favorite' => !$this->is_favorite
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleInstalled(): bool
|
||||
{
|
||||
return $this->update($this->id, [
|
||||
'is_installed' => !$this->is_installed
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateCompletion(int $percentage): bool
|
||||
{
|
||||
return $this->update($this->id, [
|
||||
'completion_percentage' => min(100, max(0, $percentage))
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateRating(float $rating): bool
|
||||
{
|
||||
return $this->update($this->id, [
|
||||
'rating' => min(10.0, max(0.0, $rating))
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getStats(\PDO $pdo): array
|
||||
{
|
||||
$stmt = $pdo->query("
|
||||
SELECT
|
||||
COUNT(*) as total_games,
|
||||
SUM(playtime_minutes) as total_playtime,
|
||||
AVG(rating) as avg_rating,
|
||||
COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_games,
|
||||
COUNT(CASE WHEN is_installed = 1 THEN 1 END) as installed_games,
|
||||
AVG(completion_percentage) as avg_completion
|
||||
FROM games
|
||||
");
|
||||
return $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public static function getRecent(\PDO $pdo, int $limit = 10): array
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT g.*, s.display_name as source_name
|
||||
FROM games g
|
||||
JOIN sources s ON g.source_id = s.id
|
||||
WHERE g.last_played_at IS NOT NULL
|
||||
ORDER BY g.last_played_at DESC
|
||||
LIMIT :limit
|
||||
");
|
||||
$stmt->execute(['limit' => $limit]);
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a game key for grouping games across platforms
|
||||
*/
|
||||
public static function generateGameKey(string $title, string $platform = null): string
|
||||
{
|
||||
// Normalize title for consistent grouping
|
||||
$normalized = strtolower(trim($title));
|
||||
$normalized = preg_replace('/[^\w\s]/', '', $normalized); // Remove special characters
|
||||
$normalized = preg_replace('/\s+/', ' ', $normalized); // Normalize whitespace
|
||||
$normalized = trim($normalized);
|
||||
|
||||
// Add platform to make it unique if provided
|
||||
if ($platform) {
|
||||
$normalized .= ' ' . strtolower($platform);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all platform versions of a game
|
||||
*/
|
||||
public function getPlatformVersions(): array
|
||||
{
|
||||
if (!$this->game_key) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT g.*, s.display_name 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
|
||||
");
|
||||
$stmt->execute(['game_key' => $this->game_key]);
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get games grouped by title for display
|
||||
*/
|
||||
public static function getGroupedGames(\PDO $pdo, int $limit = 50): array
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT
|
||||
game_key,
|
||||
title,
|
||||
COUNT(*) as platform_count,
|
||||
GROUP_CONCAT(DISTINCT platform ORDER BY platform) as platforms,
|
||||
GROUP_CONCAT(DISTINCT source_id ORDER BY source_id) as source_ids,
|
||||
MAX(image_url) as image_url,
|
||||
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
|
||||
FROM games
|
||||
WHERE game_key IS NOT NULL
|
||||
GROUP BY game_key, title
|
||||
ORDER BY last_played_at DESC, total_playtime DESC
|
||||
LIMIT :limit
|
||||
");
|
||||
$stmt->execute(['limit' => $limit]);
|
||||
$games = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
// 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'])));
|
||||
}
|
||||
|
||||
return $games;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update platform-specific achievements
|
||||
*/
|
||||
public function updatePlatformAchievements(array $achievements): bool
|
||||
{
|
||||
return $this->update($this->id, [
|
||||
'platform_achievements' => json_encode($achievements)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update platform-specific statistics
|
||||
*/
|
||||
public function updatePlatformStats(array $stats): bool
|
||||
{
|
||||
return $this->update($this->id, [
|
||||
'platform_stats' => json_encode($stats)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-specific achievements
|
||||
*/
|
||||
public function getPlatformAchievements(): array
|
||||
{
|
||||
return $this->platform_achievements ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-specific statistics
|
||||
*/
|
||||
public function getPlatformStats(): array
|
||||
{
|
||||
return $this->platform_stats ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of games for pagination
|
||||
*/
|
||||
public static function getTotalCount(\PDO $pdo, string $search = ''): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*) as count FROM games";
|
||||
$params = [];
|
||||
|
||||
if (!empty($search)) {
|
||||
$sql .= " WHERE title LIKE :search";
|
||||
$params['search'] = "%{$search}%";
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return (int) $stmt->fetch()['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get grouped games with pagination and search support
|
||||
*/
|
||||
public static function getGroupedGamesWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array
|
||||
{
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
game_key,
|
||||
title,
|
||||
COUNT(*) as platform_count,
|
||||
GROUP_CONCAT(DISTINCT platform ORDER BY platform) as platforms,
|
||||
GROUP_CONCAT(DISTINCT source_id ORDER BY source_id) as source_ids,
|
||||
MAX(image_url) as image_url,
|
||||
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
|
||||
FROM games
|
||||
WHERE game_key IS NOT NULL
|
||||
";
|
||||
|
||||
$params = [];
|
||||
|
||||
if (!empty($search)) {
|
||||
$sql .= " AND title LIKE :search";
|
||||
$params['search'] = "%{$search}%";
|
||||
}
|
||||
|
||||
$sql .= " GROUP BY game_key, title ORDER BY last_played_at DESC, total_playtime DESC 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();
|
||||
$games = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
// 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'])));
|
||||
}
|
||||
|
||||
return $games;
|
||||
}
|
||||
}
|
||||
108
app/Models/Model.php
Normal file
108
app/Models/Model.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use PDOException;
|
||||
|
||||
abstract class Model
|
||||
{
|
||||
protected \PDO $pdo;
|
||||
protected string $table;
|
||||
protected array $fillable = [];
|
||||
protected array $hidden = [];
|
||||
protected array $casts = [];
|
||||
|
||||
public function __construct(\PDO $pdo)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM {$this->table} WHERE id = :id");
|
||||
$stmt->execute(['id' => $id]);
|
||||
return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
public function findAll(array $conditions = []): array
|
||||
{
|
||||
$whereClause = $this->buildWhereClause($conditions);
|
||||
$sql = "SELECT * FROM {$this->table} {$whereClause['sql']}";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($whereClause['params']);
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function create(array $data): ?int
|
||||
{
|
||||
$filteredData = array_intersect_key($data, array_flip($this->fillable));
|
||||
$columns = array_keys($filteredData);
|
||||
$placeholders = array_map(fn($col) => ":$col", $columns);
|
||||
$sql = "INSERT INTO {$this->table} (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ")";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($filteredData);
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
$filteredData = array_intersect_key($data, array_flip($this->fillable));
|
||||
if (empty($filteredData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$setClause = array_map(fn($col) => "$col = :$col", array_keys($filteredData));
|
||||
$sql = "UPDATE {$this->table} SET " . implode(', ', $setClause) . " WHERE id = :id";
|
||||
$filteredData['id'] = $id;
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
return $stmt->execute($filteredData);
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE id = :id");
|
||||
return $stmt->execute(['id' => $id]);
|
||||
}
|
||||
|
||||
public function count(array $conditions = []): int
|
||||
{
|
||||
$whereClause = $this->buildWhereClause($conditions);
|
||||
$sql = "SELECT COUNT(*) FROM {$this->table} {$whereClause['sql']}";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($whereClause['params']);
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
protected function buildWhereClause(array $conditions): array
|
||||
{
|
||||
if (empty($conditions)) {
|
||||
return ['sql' => '', 'params' => []];
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($conditions as $column => $value) {
|
||||
if (is_array($value)) {
|
||||
$parts[] = "$column IN (" . implode(', ', array_map(fn($k) => ":$column$k", array_keys($value))) . ")";
|
||||
foreach ($value as $k => $v) {
|
||||
$params["$column$k"] = $v;
|
||||
}
|
||||
} else {
|
||||
$parts[] = "$column = :$column";
|
||||
$params[$column] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'sql' => 'WHERE ' . implode(' AND ', $parts),
|
||||
'params' => $params
|
||||
];
|
||||
}
|
||||
|
||||
public function getTable(): string
|
||||
{
|
||||
return $this->table;
|
||||
}
|
||||
}
|
||||
165
app/Models/Movie.php
Normal file
165
app/Models/Movie.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Movie extends Model
|
||||
{
|
||||
protected string $table = 'movies';
|
||||
protected array $fillable = [
|
||||
'title',
|
||||
'overview',
|
||||
'director',
|
||||
'writer',
|
||||
'genre',
|
||||
'cast',
|
||||
'release_date',
|
||||
'runtime_minutes',
|
||||
'rating',
|
||||
'imdb_id',
|
||||
'tmdb_id',
|
||||
'poster_url',
|
||||
'backdrop_url',
|
||||
'watched',
|
||||
'watch_count',
|
||||
'is_favorite',
|
||||
'metadata',
|
||||
'source_id',
|
||||
'last_watched_at'
|
||||
];
|
||||
|
||||
protected array $casts = [
|
||||
'runtime_minutes' => 'int',
|
||||
'rating' => 'float',
|
||||
'watched' => 'bool',
|
||||
'watch_count' => 'int',
|
||||
'is_favorite' => 'bool',
|
||||
'release_date' => 'date',
|
||||
'last_watched_at' => 'datetime'
|
||||
];
|
||||
|
||||
public function source()
|
||||
{
|
||||
return new Source($this->pdo);
|
||||
}
|
||||
|
||||
public function markAsWatched(): bool
|
||||
{
|
||||
$this->watched = true;
|
||||
$this->watch_count += 1;
|
||||
$this->last_watched_at = date('Y-m-d H:i:s');
|
||||
return $this->update($this->id, [
|
||||
'watched' => $this->watched,
|
||||
'watch_count' => $this->watch_count,
|
||||
'last_watched_at' => $this->last_watched_at
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsUnwatched(): bool
|
||||
{
|
||||
$this->watched = false;
|
||||
return $this->update($this->id, [
|
||||
'watched' => $this->watched
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleFavorite(): bool
|
||||
{
|
||||
return $this->update($this->id, [
|
||||
'is_favorite' => !$this->is_favorite
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateRating(float $rating): bool
|
||||
{
|
||||
return $this->update($this->id, [
|
||||
'rating' => min(10.0, max(0.0, $rating))
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getStats(\PDO $pdo): array
|
||||
{
|
||||
$stmt = $pdo->query("
|
||||
SELECT
|
||||
COUNT(*) as total_movies,
|
||||
COUNT(CASE WHEN watched = 1 THEN 1 END) as watched_movies,
|
||||
SUM(watch_count) as total_watches,
|
||||
AVG(rating) as avg_rating,
|
||||
COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_movies,
|
||||
SUM(runtime_minutes) as total_runtime
|
||||
FROM movies
|
||||
");
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public static function getRecent(\PDO $pdo, int $limit = 10): array
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT m.*, s.display_name as source_name
|
||||
FROM movies m
|
||||
JOIN sources s ON m.source_id = s.id
|
||||
WHERE m.last_watched_at IS NOT NULL
|
||||
ORDER BY m.last_watched_at DESC
|
||||
LIMIT :limit
|
||||
");
|
||||
$stmt->execute(['limit' => $limit]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public static function getTotalCount(\PDO $pdo, string $search = ''): int
|
||||
{
|
||||
$sql = "SELECT COUNT(*) as count FROM movies m JOIN sources s ON m.source_id = s.id";
|
||||
$params = [];
|
||||
|
||||
if (!empty($search)) {
|
||||
$sql .= " WHERE m.title LIKE :search";
|
||||
$params['search'] = "%{$search}%";
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return (int) $stmt->fetch()['count'];
|
||||
}
|
||||
|
||||
public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array
|
||||
{
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$sql = "
|
||||
SELECT m.*, s.display_name as source_name
|
||||
FROM movies m
|
||||
JOIN sources s ON m.source_id = s.id
|
||||
";
|
||||
$params = [];
|
||||
|
||||
if (!empty($search)) {
|
||||
$sql .= " WHERE m.title LIKE :search";
|
||||
$params['search'] = "%{$search}%";
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY m.title ASC 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);
|
||||
}
|
||||
|
||||
public static function getAll(\PDO $pdo, int $limit = 100): array
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT m.*, s.display_name as source_name
|
||||
FROM movies m
|
||||
JOIN sources s ON m.source_id = s.id
|
||||
ORDER BY m.title ASC
|
||||
LIMIT :limit
|
||||
");
|
||||
$stmt->execute(['limit' => $limit]);
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
103
app/Models/Source.php
Normal file
103
app/Models/Source.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Source extends Model
|
||||
{
|
||||
protected string $table = 'sources';
|
||||
protected array $fillable = [
|
||||
'name',
|
||||
'display_name',
|
||||
'api_url',
|
||||
'api_key',
|
||||
'config',
|
||||
'is_active',
|
||||
'last_sync_at'
|
||||
];
|
||||
|
||||
public function games(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM games WHERE source_id = :source_id");
|
||||
$stmt->execute(['source_id' => $this->id]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function movies(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM movies WHERE source_id = :source_id");
|
||||
$stmt->execute(['source_id' => $this->id]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function tvShows(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM tv_shows WHERE source_id = :source_id");
|
||||
$stmt->execute(['source_id' => $this->id]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function musicArtists(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM music_artists WHERE source_id = :source_id");
|
||||
$stmt->execute(['source_id' => $this->id]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getSyncLogs(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM sync_logs WHERE source_id = :source_id ORDER BY created_at DESC");
|
||||
$stmt->execute(['source_id' => $this->id]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function createSyncLog(string $syncType, string $status): int
|
||||
{
|
||||
$data = [
|
||||
'source_id' => $this->id,
|
||||
'sync_type' => $syncType,
|
||||
'status' => $status,
|
||||
'total_items' => 0,
|
||||
'processed_items' => 0,
|
||||
'new_items' => 0,
|
||||
'updated_items' => 0,
|
||||
'deleted_items' => 0,
|
||||
'started_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$columns = array_keys($data);
|
||||
$placeholders = array_map(fn($col) => ":$col", $columns);
|
||||
$sql = "INSERT INTO sync_logs (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ")";
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($data);
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
public function updateSyncLog(int $syncLogId, string $status, array $stats = []): bool
|
||||
{
|
||||
$data = [
|
||||
'status' => $status,
|
||||
'processed_items' => $stats['processed_items'] ?? 0,
|
||||
'new_items' => $stats['new_items'] ?? 0,
|
||||
'updated_items' => $stats['updated_items'] ?? 0,
|
||||
'deleted_items' => $stats['deleted_items'] ?? 0,
|
||||
'completed_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
if (!empty($stats['errors'])) {
|
||||
$data['errors'] = json_encode($stats['errors']);
|
||||
}
|
||||
|
||||
if (!empty($stats['message'])) {
|
||||
$data['message'] = $stats['message'];
|
||||
}
|
||||
|
||||
$setClause = array_map(fn($col) => "$col = :$col", array_keys($data));
|
||||
$sql = "UPDATE sync_logs SET " . implode(', ', $setClause) . " WHERE id = :id";
|
||||
$data['id'] = $syncLogId;
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
return $stmt->execute($data);
|
||||
}
|
||||
}
|
||||
90
app/Models/SyncLog.php
Normal file
90
app/Models/SyncLog.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class SyncLog extends Model
|
||||
{
|
||||
protected string $table = 'sync_logs';
|
||||
protected array $fillable = [
|
||||
'source_id',
|
||||
'sync_type',
|
||||
'status',
|
||||
'total_items',
|
||||
'processed_items',
|
||||
'new_items',
|
||||
'updated_items',
|
||||
'deleted_items',
|
||||
'errors',
|
||||
'message',
|
||||
'started_at',
|
||||
'completed_at'
|
||||
];
|
||||
|
||||
protected array $casts = [
|
||||
'errors' => 'array',
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime'
|
||||
];
|
||||
|
||||
public static function getRecent(\PDO $pdo, int $limit = 10): array
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT sl.*, s.display_name as source_name
|
||||
FROM sync_logs sl
|
||||
JOIN sources s ON sl.source_id = s.id
|
||||
ORDER BY sl.created_at DESC
|
||||
LIMIT :limit
|
||||
");
|
||||
$stmt->execute(['limit' => $limit]);
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function source()
|
||||
{
|
||||
return new Source($this->pdo);
|
||||
}
|
||||
|
||||
public function markAsStarted(): void
|
||||
{
|
||||
$this->update($this->id, [
|
||||
'status' => 'started',
|
||||
'started_at' => date('Y-m-d H:i:s'),
|
||||
'message' => null,
|
||||
'errors' => null
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsCompleted(array $stats = []): void
|
||||
{
|
||||
$data = [
|
||||
'status' => 'completed',
|
||||
'completed_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
if (!empty($stats)) {
|
||||
$data = array_merge($data, $stats);
|
||||
}
|
||||
|
||||
$this->update($this->id, $data);
|
||||
}
|
||||
|
||||
public function markAsFailed(string $errorMessage, array $errors = []): void
|
||||
{
|
||||
$this->update($this->id, [
|
||||
'status' => 'failed',
|
||||
'completed_at' => date('Y-m-d H:i:s'),
|
||||
'message' => $errorMessage,
|
||||
'errors' => !empty($errors) ? json_encode($errors) : null
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateProgress(int $processed, int $new = 0, int $updated = 0, int $deleted = 0): void
|
||||
{
|
||||
$this->update($this->id, [
|
||||
'processed_items' => $processed,
|
||||
'new_items' => $new,
|
||||
'updated_items' => $updated,
|
||||
'deleted_items' => $deleted
|
||||
]);
|
||||
}
|
||||
}
|
||||
92
app/Models/User.php
Normal file
92
app/Models/User.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class User extends Model
|
||||
{
|
||||
protected string $table = 'users';
|
||||
protected array $fillable = [
|
||||
'username',
|
||||
'email',
|
||||
'password',
|
||||
'role',
|
||||
'is_active',
|
||||
'last_login_at',
|
||||
'login_ip'
|
||||
];
|
||||
|
||||
protected array $hidden = [
|
||||
'password',
|
||||
'remember_token'
|
||||
];
|
||||
|
||||
protected array $casts = [
|
||||
'is_active' => 'bool',
|
||||
'last_login_at' => 'datetime'
|
||||
];
|
||||
|
||||
public function setPassword(string $password): void
|
||||
{
|
||||
$this->password = password_hash($password, PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
public function verifyPassword(string $password): bool
|
||||
{
|
||||
return password_verify($password, $this->password);
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === 'admin';
|
||||
}
|
||||
|
||||
public function updateLastLogin(string $ip = null): bool
|
||||
{
|
||||
return $this->update($this->id, [
|
||||
'last_login_at' => date('Y-m-d H:i:s'),
|
||||
'login_ip' => $ip
|
||||
]);
|
||||
}
|
||||
|
||||
public static function findByUsername(\PDO $pdo, string $username): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username AND is_active = 1");
|
||||
$stmt->execute(['username' => $username]);
|
||||
return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
public static function findByEmail(\PDO $pdo, string $email): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email AND is_active = 1");
|
||||
$stmt->execute(['email' => $email]);
|
||||
return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
public static function createAdmin(\PDO $pdo, string $username, string $email, string $password): int
|
||||
{
|
||||
$data = [
|
||||
'username' => $username,
|
||||
'email' => $email,
|
||||
'role' => 'admin',
|
||||
'is_active' => true
|
||||
];
|
||||
|
||||
$userModel = new self($pdo);
|
||||
$userModel->setPassword($password);
|
||||
$data['password'] = $userModel->password;
|
||||
|
||||
return $userModel->create($data);
|
||||
}
|
||||
|
||||
public static function getStats(\PDO $pdo): array
|
||||
{
|
||||
$stmt = $pdo->query("
|
||||
SELECT
|
||||
COUNT(*) as total_users,
|
||||
COUNT(CASE WHEN role = 'admin' THEN 1 END) as admin_users,
|
||||
COUNT(CASE WHEN last_login_at IS NOT NULL THEN 1 END) as active_users
|
||||
FROM users
|
||||
");
|
||||
return $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
211
app/Services/AdultSyncService.php
Normal file
211
app/Services/AdultSyncService.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AdultVideo;
|
||||
use GuzzleHttp\Client;
|
||||
use PDO;
|
||||
use Exception;
|
||||
|
||||
class AdultSyncService extends BaseSyncService
|
||||
{
|
||||
private Client $httpClient;
|
||||
private array $xbvrSource;
|
||||
private array $stashSource;
|
||||
private int $processedCount = 0;
|
||||
private int $newCount = 0;
|
||||
private int $updatedCount = 0;
|
||||
|
||||
public function __construct(PDO $pdo, array $source)
|
||||
{
|
||||
parent::__construct($pdo, $source);
|
||||
|
||||
// Find XBVR and Stash sources
|
||||
$this->xbvrSource = $this->findSourceByName('xbvr');
|
||||
$this->stashSource = $this->findSourceByName('stash');
|
||||
|
||||
$this->httpClient = new Client([
|
||||
'timeout' => 60,
|
||||
'headers' => [
|
||||
'User-Agent' => 'MediaCollector/1.0',
|
||||
'Content-Type' => 'application/json'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
private function findSourceByName(string $name): ?array
|
||||
{
|
||||
$stmt = $this->pdo->prepare("SELECT * FROM sources WHERE name = :name AND is_active = 1 LIMIT 1");
|
||||
$stmt->execute(['name' => $name]);
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
protected function executeSync(string $syncType): void
|
||||
{
|
||||
if (!$this->xbvrSource && !$this->stashSource) {
|
||||
throw new Exception('No active XBVR or Stash sources found');
|
||||
}
|
||||
|
||||
$this->logProgress('Starting adult content library sync...');
|
||||
|
||||
// Sync XBVR content
|
||||
if ($this->xbvrSource) {
|
||||
$this->syncXbvrContent();
|
||||
}
|
||||
|
||||
// Sync Stash content
|
||||
if ($this->stashSource) {
|
||||
$this->syncStashContent();
|
||||
}
|
||||
|
||||
$this->logProgress("Processed {$this->processedCount} adult content items");
|
||||
}
|
||||
|
||||
private function syncXbvrContent(): void
|
||||
{
|
||||
if (!$this->xbvrSource) return;
|
||||
|
||||
try {
|
||||
$xbvrService = new XbvrSyncService($this->pdo, $this->xbvrSource);
|
||||
$xbvrService->startSync('full');
|
||||
|
||||
$this->logProgress("XBVR content synced directly to adult videos");
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress("Error syncing XBVR content: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function syncStashContent(): void
|
||||
{
|
||||
if (!$this->stashSource) return;
|
||||
|
||||
try {
|
||||
$stashService = new StashSyncService($this->pdo, $this->stashSource);
|
||||
$stashService->startSync('full');
|
||||
|
||||
$this->logProgress("Stash content synced directly to adult videos");
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress("Error syncing Stash content: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function migrateXbvrToAdultVideos(): void
|
||||
{
|
||||
// Get all movies from XBVR source
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT m.* FROM movies m
|
||||
WHERE m.source_id = :xbvr_source_id
|
||||
");
|
||||
$stmt->execute(['xbvr_source_id' => $this->xbvrSource['id']]);
|
||||
$xbvrMovies = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($xbvrMovies as $movie) {
|
||||
// Check if adult video already exists
|
||||
$existingStmt = $this->pdo->prepare("
|
||||
SELECT id FROM adult_videos
|
||||
WHERE source_id = :adult_source_id AND external_id = :external_id
|
||||
LIMIT 1
|
||||
");
|
||||
$existingStmt->execute([
|
||||
'adult_source_id' => $this->source['id'],
|
||||
'external_id' => $movie['id']
|
||||
]);
|
||||
|
||||
if (!$existingStmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
// Create adult video from XBVR movie
|
||||
$adultVideoData = [
|
||||
'title' => $movie['title'],
|
||||
'overview' => $movie['overview'],
|
||||
'poster_url' => $movie['poster_url'],
|
||||
'backdrop_url' => $movie['backdrop_url'],
|
||||
'rating' => $movie['rating'],
|
||||
'runtime_minutes' => $movie['runtime_minutes'],
|
||||
'release_date' => $movie['release_date'],
|
||||
'director' => $movie['director'],
|
||||
'writer' => $movie['writer'],
|
||||
'cast' => $movie['cast'],
|
||||
'genre' => $movie['genre'],
|
||||
'metadata' => $movie['metadata'],
|
||||
'watched' => $movie['watched'],
|
||||
'watch_count' => $movie['watch_count'],
|
||||
'is_favorite' => $movie['is_favorite'],
|
||||
'source_id' => $this->source['id'],
|
||||
'external_id' => $movie['id'],
|
||||
'created_at' => $movie['created_at'],
|
||||
'updated_at' => $movie['updated_at']
|
||||
];
|
||||
|
||||
$columns = array_keys($adultVideoData);
|
||||
$placeholders = array_map(fn($col) => ":$col", $columns);
|
||||
$sql = "INSERT INTO adult_videos (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ")";
|
||||
|
||||
$insertStmt = $this->pdo->prepare($sql);
|
||||
$insertStmt->execute($adultVideoData);
|
||||
|
||||
$this->processedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logProgress("Processed {$this->processedCount} XBVR items");
|
||||
}
|
||||
|
||||
private function migrateStashToAdultVideos(): void
|
||||
{
|
||||
// Get all movies from Stash source
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT m.* FROM movies m
|
||||
WHERE m.source_id = :stash_source_id
|
||||
");
|
||||
$stmt->execute(['stash_source_id' => $this->stashSource['id']]);
|
||||
$stashMovies = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($stashMovies as $movie) {
|
||||
// Check if adult video already exists
|
||||
$existingStmt = $this->pdo->prepare("
|
||||
SELECT id FROM adult_videos
|
||||
WHERE source_id = :adult_source_id AND external_id = :external_id
|
||||
LIMIT 1
|
||||
");
|
||||
$existingStmt->execute([
|
||||
'adult_source_id' => $this->source['id'],
|
||||
'external_id' => $movie['id']
|
||||
]);
|
||||
|
||||
if (!$existingStmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
// Create adult video from Stash movie
|
||||
$adultVideoData = [
|
||||
'title' => $movie['title'],
|
||||
'overview' => $movie['overview'],
|
||||
'poster_url' => $movie['poster_url'],
|
||||
'backdrop_url' => $movie['backdrop_url'],
|
||||
'rating' => $movie['rating'],
|
||||
'runtime_minutes' => $movie['runtime_minutes'],
|
||||
'release_date' => $movie['release_date'],
|
||||
'director' => $movie['director'],
|
||||
'writer' => $movie['writer'],
|
||||
'cast' => $movie['cast'],
|
||||
'genre' => $movie['genre'],
|
||||
'metadata' => $movie['metadata'],
|
||||
'watched' => $movie['watched'],
|
||||
'watch_count' => $movie['watch_count'],
|
||||
'is_favorite' => $movie['is_favorite'],
|
||||
'source_id' => $this->source['id'],
|
||||
'external_id' => $movie['id'],
|
||||
'created_at' => $movie['created_at'],
|
||||
'updated_at' => $movie['updated_at']
|
||||
];
|
||||
|
||||
$columns = array_keys($adultVideoData);
|
||||
$placeholders = array_map(fn($col) => ":$col", $columns);
|
||||
$sql = "INSERT INTO adult_videos (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ")";
|
||||
|
||||
$insertStmt = $this->pdo->prepare($sql);
|
||||
$insertStmt->execute($adultVideoData);
|
||||
|
||||
$this->processedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logProgress("Processed {$this->processedCount} Stash items");
|
||||
}
|
||||
}
|
||||
121
app/Services/AuthService.php
Normal file
121
app/Services/AuthService.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use PDO;
|
||||
|
||||
class AuthService
|
||||
{
|
||||
private PDO $pdo;
|
||||
private ?array $user = null;
|
||||
|
||||
public function __construct(PDO $pdo)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->checkSession();
|
||||
}
|
||||
|
||||
public function checkSession(): void
|
||||
{
|
||||
if (isset($_SESSION['user_id'])) {
|
||||
$user = User::findByUsername($this->pdo, $_SESSION['username']);
|
||||
|
||||
if (!$user || !$user['is_active']) {
|
||||
$this->logout();
|
||||
} else {
|
||||
$this->user = $user;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function login(string $username, string $password, string $ip = null): bool
|
||||
{
|
||||
$user = User::findByUsername($this->pdo, $username);
|
||||
|
||||
if (!$user || !$user['is_active']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify password directly using the hash from database
|
||||
if (!password_verify($password, $user['password'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set session
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['username'] = $user['username'];
|
||||
$_SESSION['role'] = $user['role'];
|
||||
|
||||
// Update last login
|
||||
$this->updateUserLastLogin($user['id'], $ip);
|
||||
|
||||
$this->user = $user;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
unset($_SESSION['user_id']);
|
||||
unset($_SESSION['username']);
|
||||
unset($_SESSION['role']);
|
||||
$this->user = null;
|
||||
}
|
||||
|
||||
public function getCurrentUser(): ?array
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function isLoggedIn(): bool
|
||||
{
|
||||
return $this->user !== null;
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->isLoggedIn() && $this->user['role'] === 'admin';
|
||||
}
|
||||
|
||||
public function requireLogin(): void
|
||||
{
|
||||
if (!$this->isLoggedIn()) {
|
||||
header('Location: /login');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public function requireAdmin(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
|
||||
if (!$this->isAdmin()) {
|
||||
http_response_code(403);
|
||||
echo 'Access denied. Admin privileges required.';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public function generateCSRFToken(): string
|
||||
{
|
||||
if (!isset($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
public function verifyCSRFToken(string $token): bool
|
||||
{
|
||||
return isset($_SESSION['csrf_token']) && $_SESSION['csrf_token'] === $token;
|
||||
}
|
||||
|
||||
private function updateUserLastLogin(int $userId, string $ip = null): void
|
||||
{
|
||||
$stmt = $this->pdo->prepare("UPDATE users SET last_login_at = :last_login_at, login_ip = :login_ip WHERE id = :id");
|
||||
$stmt->execute([
|
||||
'id' => $userId,
|
||||
'last_login_at' => date('Y-m-d H:i:s'),
|
||||
'login_ip' => $ip
|
||||
]);
|
||||
}
|
||||
}
|
||||
141
app/Services/BaseSyncService.php
Normal file
141
app/Services/BaseSyncService.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\SyncLog;
|
||||
use Exception;
|
||||
|
||||
abstract class BaseSyncService
|
||||
{
|
||||
protected \PDO $pdo;
|
||||
protected array $source;
|
||||
protected SyncLog $syncLog;
|
||||
protected int $sourceId;
|
||||
|
||||
public function __construct(\PDO $pdo, array $source)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->source = $source;
|
||||
|
||||
if (!isset($source['id']) || empty($source['id'])) {
|
||||
throw new \Exception('Source ID is required for sync service');
|
||||
}
|
||||
|
||||
$this->sourceId = (int) $source['id'];
|
||||
}
|
||||
|
||||
public function startSync(string $syncType = 'full'): int
|
||||
{
|
||||
// Create sync log entry
|
||||
$this->syncLog = new SyncLog($this->pdo);
|
||||
$syncLogId = $this->createSyncLog($syncType, 'started');
|
||||
|
||||
$this->syncLog->id = $syncLogId;
|
||||
|
||||
try {
|
||||
$this->executeSync($syncType);
|
||||
|
||||
// Update sync log as completed
|
||||
$this->updateSyncLog($syncLogId, 'completed', [
|
||||
'processed_items' => $this->getProcessedCount(),
|
||||
'new_items' => $this->getNewCount(),
|
||||
'updated_items' => $this->getUpdatedCount(),
|
||||
'deleted_items' => $this->getDeletedCount()
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Update sync log as failed
|
||||
$this->updateSyncLog($syncLogId, 'failed', [
|
||||
'message' => $e->getMessage(),
|
||||
'errors' => [$e->getMessage()]
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $syncLogId;
|
||||
}
|
||||
|
||||
private function createSyncLog(string $syncType, string $status): int
|
||||
{
|
||||
$data = [
|
||||
'source_id' => $this->sourceId,
|
||||
'sync_type' => $syncType,
|
||||
'status' => $status,
|
||||
'total_items' => 0,
|
||||
'processed_items' => 0,
|
||||
'new_items' => 0,
|
||||
'updated_items' => 0,
|
||||
'deleted_items' => 0,
|
||||
'started_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$columns = array_keys($data);
|
||||
$placeholders = array_map(fn($col) => ":$col", $columns);
|
||||
$sql = "INSERT INTO sync_logs (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ")";
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($data);
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
private function updateSyncLog(int $syncLogId, string $status, array $stats = []): bool
|
||||
{
|
||||
$data = [
|
||||
'status' => $status,
|
||||
'processed_items' => $stats['processed_items'] ?? 0,
|
||||
'new_items' => $stats['new_items'] ?? 0,
|
||||
'updated_items' => $stats['updated_items'] ?? 0,
|
||||
'deleted_items' => $stats['deleted_items'] ?? 0,
|
||||
'completed_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
if (!empty($stats['errors'])) {
|
||||
$data['errors'] = json_encode($stats['errors']);
|
||||
}
|
||||
|
||||
if (!empty($stats['message'])) {
|
||||
$data['message'] = $stats['message'];
|
||||
}
|
||||
|
||||
$setClause = array_map(fn($col) => "$col = :$col", array_keys($data));
|
||||
$sql = "UPDATE sync_logs SET " . implode(', ', $setClause) . " WHERE id = :id";
|
||||
$data['id'] = $syncLogId;
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
return $stmt->execute($data);
|
||||
}
|
||||
|
||||
abstract protected function executeSync(string $syncType): void;
|
||||
|
||||
protected function getProcessedCount(): int
|
||||
{
|
||||
return 0; // Override in subclasses
|
||||
}
|
||||
|
||||
protected function getNewCount(): int
|
||||
{
|
||||
return 0; // Override in subclasses
|
||||
}
|
||||
|
||||
protected function getUpdatedCount(): int
|
||||
{
|
||||
return 0; // Override in subclasses
|
||||
}
|
||||
|
||||
protected function getDeletedCount(): int
|
||||
{
|
||||
return 0; // Override in subclasses
|
||||
}
|
||||
|
||||
protected function logProgress(string $message): void
|
||||
{
|
||||
// Update sync log with progress message
|
||||
if ($this->syncLog) {
|
||||
$this->updateSyncLog($this->syncLog->id, 'running', [
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
174
app/Services/ExophaseSyncService.php
Normal file
174
app/Services/ExophaseSyncService.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Game;
|
||||
use GuzzleHttp\Client;
|
||||
use Exception;
|
||||
|
||||
class ExophaseSyncService extends BaseSyncService
|
||||
{
|
||||
private Client $httpClient;
|
||||
private ?string $apiKey;
|
||||
private string $baseUrl;
|
||||
private int $processedCount = 0;
|
||||
private int $newCount = 0;
|
||||
private int $updatedCount = 0;
|
||||
|
||||
public function __construct(\PDO $pdo, array $source)
|
||||
{
|
||||
parent::__construct($pdo, $source);
|
||||
$this->httpClient = new Client([
|
||||
'timeout' => 30,
|
||||
'headers' => [
|
||||
'User-Agent' => 'MediaCollector/1.0',
|
||||
'Authorization' => 'Bearer ' . $source['api_key']
|
||||
]
|
||||
]);
|
||||
$this->apiKey = $source['api_key'];
|
||||
$this->baseUrl = rtrim($source['api_url'] ?? 'https://api.exophase.com', '/');
|
||||
}
|
||||
|
||||
protected function executeSync(string $syncType): void
|
||||
{
|
||||
if (empty($this->apiKey)) {
|
||||
throw new Exception('Exophase API key not configured');
|
||||
}
|
||||
|
||||
$this->logProgress('Starting Exophase gaming data sync...');
|
||||
|
||||
// Sync games from all supported platforms
|
||||
$this->syncGames();
|
||||
|
||||
$this->logProgress("Processed {$this->processedCount} Exophase gaming items");
|
||||
}
|
||||
|
||||
private function syncGames(): void
|
||||
{
|
||||
try {
|
||||
$games = $this->getExophaseGames();
|
||||
|
||||
foreach ($games as $gameData) {
|
||||
$this->syncGame($gameData);
|
||||
$this->processedCount++;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress('Error syncing games: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getExophaseGames(): array
|
||||
{
|
||||
try {
|
||||
// Exophase API endpoint for user's games
|
||||
$response = $this->httpClient->get("{$this->baseUrl}/v1/user/games");
|
||||
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (!isset($data['games'])) {
|
||||
throw new Exception('No games found in Exophase');
|
||||
}
|
||||
|
||||
return $data['games'];
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Failed to fetch Exophase games: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function syncGame(array $gameData): void
|
||||
{
|
||||
$gameModel = new Game($this->pdo);
|
||||
|
||||
// Check if game already exists
|
||||
$existingGame = $gameModel->findAll([
|
||||
'source_id' => $this->sourceId
|
||||
]);
|
||||
|
||||
// Find existing game by platform-specific ID or title
|
||||
foreach ($existingGame as $game) {
|
||||
$metadata = json_decode($game['metadata'], true);
|
||||
if (isset($metadata['exophase_game_id']) && $metadata['exophase_game_id'] === $gameData['id']) {
|
||||
$existingGame = [$game];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$gameData = [
|
||||
'title' => $gameData['title'] ?: 'Untitled Game',
|
||||
'game_key' => Game::generateGameKey($gameData['title'], $gameData['platform'] ?? null),
|
||||
'platform' => $this->mapPlatform($gameData['platform'] ?? 'unknown'),
|
||||
'playtime_minutes' => $gameData['playtime_minutes'] ?? 0,
|
||||
'completion_percentage' => $gameData['completion_percentage'] ?? 0,
|
||||
'source_id' => $this->sourceId,
|
||||
'last_played_at' => isset($gameData['last_played']) ? date('Y-m-d H:i:s', strtotime($gameData['last_played'])) : null,
|
||||
'metadata' => json_encode([
|
||||
'exophase_game_id' => $gameData['id'],
|
||||
'exophase_platform' => $gameData['platform'] ?? null,
|
||||
'achievements_earned' => $gameData['achievements_earned'] ?? 0,
|
||||
'achievements_total' => $gameData['achievements_total'] ?? 0,
|
||||
'trophies_earned' => $gameData['trophies_earned'] ?? 0,
|
||||
'trophies_total' => $gameData['trophies_total'] ?? 0,
|
||||
'gamerscore_earned' => $gameData['gamerscore_earned'] ?? 0,
|
||||
'gamerscore_total' => $gameData['gamerscore_total'] ?? 0,
|
||||
'last_achievement' => $gameData['last_achievement'] ?? null,
|
||||
'first_achievement' => $gameData['first_achievement'] ?? null,
|
||||
'rating' => $gameData['rating'] ?? null,
|
||||
'genre' => $gameData['genre'] ?? null,
|
||||
'developer' => $gameData['developer'] ?? null,
|
||||
'publisher' => $gameData['publisher'] ?? null,
|
||||
'release_date' => $gameData['release_date'] ?? null
|
||||
])
|
||||
];
|
||||
|
||||
if (empty($existingGame)) {
|
||||
$gameModel->create($gameData);
|
||||
$this->newCount++;
|
||||
} else {
|
||||
$gameModel->update($existingGame[0]['id'], $gameData);
|
||||
$this->updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private function mapPlatform(string $platform): string
|
||||
{
|
||||
$platformMap = [
|
||||
'steam' => 'PC',
|
||||
'psn' => 'PlayStation',
|
||||
'ps4' => 'PlayStation 4',
|
||||
'ps5' => 'PlayStation 5',
|
||||
'xbox' => 'Xbox',
|
||||
'xbox360' => 'Xbox 360',
|
||||
'xboxone' => 'Xbox One',
|
||||
'xboxseries' => 'Xbox Series X/S',
|
||||
'nintendo' => 'Nintendo',
|
||||
'switch' => 'Nintendo Switch',
|
||||
'epic' => 'Epic Games',
|
||||
'gog' => 'GOG',
|
||||
'origin' => 'Origin',
|
||||
'uplay' => 'Ubisoft Connect',
|
||||
'battlenet' => 'Battle.net'
|
||||
];
|
||||
|
||||
return $platformMap[$platform] ?? ucfirst($platform);
|
||||
}
|
||||
|
||||
protected function getProcessedCount(): int
|
||||
{
|
||||
return $this->processedCount;
|
||||
}
|
||||
|
||||
protected function getNewCount(): int
|
||||
{
|
||||
return $this->newCount;
|
||||
}
|
||||
|
||||
protected function getUpdatedCount(): int
|
||||
{
|
||||
return $this->updatedCount;
|
||||
}
|
||||
|
||||
protected function getDeletedCount(): int
|
||||
{
|
||||
return 0; // Exophase doesn't provide deletion info in this context
|
||||
}
|
||||
}
|
||||
315
app/Services/JellyfinSyncService.php
Normal file
315
app/Services/JellyfinSyncService.php
Normal file
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Movie;
|
||||
use App\Models\TvShow;
|
||||
use App\Models\TvEpisode;
|
||||
use GuzzleHttp\Client;
|
||||
use Exception;
|
||||
|
||||
class JellyfinSyncService extends BaseSyncService
|
||||
{
|
||||
private Client $httpClient;
|
||||
private ?string $apiKey;
|
||||
private string $baseUrl;
|
||||
private int $processedCount = 0;
|
||||
private int $newCount = 0;
|
||||
private int $updatedCount = 0;
|
||||
|
||||
public function __construct(\PDO $pdo, array $source)
|
||||
{
|
||||
parent::__construct($pdo, $source);
|
||||
$this->httpClient = new Client([
|
||||
'timeout' => 30,
|
||||
'headers' => [
|
||||
'User-Agent' => 'MediaCollector/1.0',
|
||||
'X-MediaBrowser-Token' => $source['api_key']
|
||||
]
|
||||
]);
|
||||
$this->apiKey = $source['api_key'];
|
||||
$this->baseUrl = rtrim($source['api_url'], '/');
|
||||
}
|
||||
|
||||
protected function executeSync(string $syncType): void
|
||||
{
|
||||
if (empty($this->apiKey) || empty($this->baseUrl)) {
|
||||
throw new Exception('Jellyfin API key and URL not configured');
|
||||
}
|
||||
|
||||
$this->logProgress('Starting Jellyfin library sync...');
|
||||
$this->logProgress("Jellyfin URL: {$this->baseUrl}");
|
||||
$this->logProgress("API Key: " . (empty($this->apiKey) ? 'NOT SET' : 'SET'));
|
||||
|
||||
try {
|
||||
$userId = $this->getUserId();
|
||||
$this->logProgress("User ID: {$userId}");
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress('Error getting user ID: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Sync movies
|
||||
try {
|
||||
$this->logProgress('Fetching movies from Jellyfin...');
|
||||
$movies = $this->getJellyfinItems('Movie');
|
||||
$this->logProgress("Found " . count($movies) . " movies in Jellyfin");
|
||||
|
||||
if (empty($movies)) {
|
||||
$this->logProgress('No movies found in Jellyfin library');
|
||||
$this->logProgress("Processed {$this->processedCount} items");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($movies as $movieData) {
|
||||
$this->syncMovie($movieData);
|
||||
$this->processedCount++;
|
||||
}
|
||||
|
||||
$this->logProgress("Successfully processed {$this->processedCount} movies");
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress('Error syncing movies: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// TODO: Sync TV shows and episodes when TvShow model is implemented
|
||||
// $this->syncTvShows();
|
||||
|
||||
$this->logProgress("Processed {$this->processedCount} items");
|
||||
}
|
||||
|
||||
private function syncMovies(): void
|
||||
{
|
||||
try {
|
||||
$movies = $this->getJellyfinItems('Movie');
|
||||
|
||||
foreach ($movies as $movieData) {
|
||||
$this->syncMovie($movieData);
|
||||
$this->processedCount++;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress('Error syncing movies: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function syncTvShows(): void
|
||||
{
|
||||
try {
|
||||
$tvShows = $this->getJellyfinItems('Series');
|
||||
|
||||
foreach ($tvShows as $showData) {
|
||||
$this->syncTvShow($showData);
|
||||
$this->processedCount++;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress('Error syncing TV shows: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getJellyfinItems(string $type): array
|
||||
{
|
||||
try {
|
||||
$url = "{$this->baseUrl}/Users/{$this->getUserId()}/Items";
|
||||
$this->logProgress("Fetching {$type} from: {$url}");
|
||||
|
||||
$response = $this->httpClient->get($url, [
|
||||
'query' => [
|
||||
'IncludeItemTypes' => $type,
|
||||
'Recursive' => 'true',
|
||||
'Fields' => 'ProviderIds,Overview,PremiereDate,CommunityRating,OfficialRating,Genres,Studios,RunTimeTicks'
|
||||
]
|
||||
]);
|
||||
|
||||
$httpCode = $response->getStatusCode();
|
||||
$this->logProgress("HTTP Response Code: {$httpCode}");
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
throw new Exception("Jellyfin API returned HTTP {$httpCode}");
|
||||
}
|
||||
|
||||
$data = json_decode($response->getBody(), true);
|
||||
$itemCount = count($data['Items'] ?? []);
|
||||
$this->logProgress("Successfully fetched {$itemCount} {$type} items");
|
||||
|
||||
return $data['Items'] ?? [];
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress('Failed to fetch Jellyfin items: ' . $e->getMessage());
|
||||
throw new Exception('Failed to fetch Jellyfin items: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getUserId(): string
|
||||
{
|
||||
try {
|
||||
$url = "{$this->baseUrl}/Users";
|
||||
$this->logProgress("Getting user ID from: {$url}");
|
||||
|
||||
$response = $this->httpClient->get($url);
|
||||
$httpCode = $response->getStatusCode();
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
throw new Exception("Jellyfin Users API returned HTTP {$httpCode}");
|
||||
}
|
||||
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (empty($data) || !isset($data[0]['Id'])) {
|
||||
throw new Exception('No users found in Jellyfin or invalid response format');
|
||||
}
|
||||
|
||||
$userId = $data[0]['Id'];
|
||||
$this->logProgress("Using Jellyfin user ID: {$userId}");
|
||||
|
||||
return $userId;
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress('Failed to get Jellyfin user ID: ' . $e->getMessage());
|
||||
throw new Exception('Failed to get Jellyfin user ID: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function syncMovie(array $movieData): void
|
||||
{
|
||||
$movieModel = new Movie($this->pdo);
|
||||
|
||||
// Check if movie already exists
|
||||
$existingMovie = $movieModel->findAll([
|
||||
'tmdb_id' => $movieData['ProviderIds']['Tmdb'] ?? null,
|
||||
'imdb_id' => $movieData['ProviderIds']['Imdb'] ?? null,
|
||||
'source_id' => $this->source['id']
|
||||
]);
|
||||
|
||||
$movieData = [
|
||||
'title' => $movieData['Name'],
|
||||
'overview' => $movieData['Overview'] ?? null,
|
||||
'release_date' => $movieData['PremiereDate'] ? date('Y-m-d', strtotime($movieData['PremiereDate'])) : null,
|
||||
'runtime_minutes' => $movieData['RunTimeTicks'] ? intval($movieData['RunTimeTicks'] / (10000000 * 60)) : null,
|
||||
'rating' => $movieData['CommunityRating'] ?? null,
|
||||
'imdb_id' => $movieData['ProviderIds']['Imdb'] ?? null,
|
||||
'tmdb_id' => $movieData['ProviderIds']['Tmdb'] ?? null,
|
||||
'poster_url' => $this->getImageUrl($movieData['Id'], 'Primary'),
|
||||
'backdrop_url' => $this->getImageUrl($movieData['Id'], 'Backdrop'),
|
||||
'source_id' => $this->source['id'],
|
||||
'metadata' => json_encode([
|
||||
'jellyfin_id' => $movieData['Id'],
|
||||
'genres' => $movieData['Genres'] ?? [],
|
||||
'studios' => $movieData['Studios'] ?? []
|
||||
])
|
||||
];
|
||||
|
||||
if (empty($existingMovie)) {
|
||||
$movieModel->create($movieData);
|
||||
$this->newCount++;
|
||||
} else {
|
||||
$movieModel->update($existingMovie[0]['id'], $movieData);
|
||||
$this->updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement when TvShow model is created
|
||||
// private function syncTvShow(array $showData): void
|
||||
// {
|
||||
// $showModel = new TvShow($this->pdo);
|
||||
|
||||
// // Check if show already exists
|
||||
// $existingShow = $showModel->findAll([
|
||||
// 'tmdb_id' => $showData['ProviderIds']['Tmdb'] ?? null,
|
||||
// 'imdb_id' => $showData['ProviderIds']['Imdb'] ?? null,
|
||||
// 'source_id' => $this->source->id
|
||||
// ]);
|
||||
|
||||
// $showData = [
|
||||
// 'title' => $showData['Name'],
|
||||
// 'overview' => $showData['Overview'] ?? null,
|
||||
// 'first_air_date' => $showData['PremiereDate'] ? date('Y-m-d', strtotime($showData['PremiereDate'])) : null,
|
||||
// 'rating' => $showData['CommunityRating'] ?? null,
|
||||
// 'imdb_id' => $showData['ProviderIds']['Imdb'] ?? null,
|
||||
// 'tmdb_id' => $showData['ProviderIds']['Tmdb'] ?? null,
|
||||
// 'poster_url' => $this->getImageUrl($showData['Id'], 'Primary'),
|
||||
// 'backdrop_url' => $this->getImageUrl($showData['Id'], 'Backdrop'),
|
||||
// 'source_id' => $this->source->id,
|
||||
// 'metadata' => json_encode([
|
||||
// 'jellyfin_id' => $showData['Id'],
|
||||
// 'genres' => $showData['Genres'] ?? []
|
||||
// ])
|
||||
// ];
|
||||
|
||||
// if (empty($existingShow)) {
|
||||
// $showId = $showModel->create($showData);
|
||||
// $this->newCount++;
|
||||
// } else {
|
||||
// $showId = $existingShow[0]['id'];
|
||||
// $showModel->update($showId, $showData);
|
||||
// $this->updatedCount++;
|
||||
// }
|
||||
|
||||
// // Sync episodes for this show
|
||||
// $this->syncEpisodes($showId, $showData['Id']);
|
||||
// }
|
||||
|
||||
// TODO: Implement when TvEpisode model is created
|
||||
// private function syncEpisodes(int $showId, string $jellyfinShowId): void
|
||||
// {
|
||||
// try {
|
||||
// $episodes = $this->getShowEpisodes($jellyfinShowId);
|
||||
|
||||
// foreach ($episodes as $episodeData) {
|
||||
// $this->syncEpisode($showId, $episodeData);
|
||||
// }
|
||||
// } catch (Exception $e) {
|
||||
// $this->logProgress('Error syncing episodes for show ' . $jellyfinShowId . ': ' . $e->getMessage());
|
||||
// }
|
||||
// }
|
||||
|
||||
// TODO: Implement when TvEpisode model is created
|
||||
// private function syncEpisode(int $showId, array $episodeData): void
|
||||
// {
|
||||
// $episodeModel = new TvEpisode($this->pdo);
|
||||
|
||||
// $episodeData = [
|
||||
// 'title' => $episodeData['Name'],
|
||||
// 'overview' => $episodeData['Overview'] ?? null,
|
||||
// 'season_number' => $episodeData['ParentIndexNumber'] ?? 1,
|
||||
// 'episode_number' => $episodeData['IndexNumber'] ?? 1,
|
||||
// 'air_date' => $episodeData['PremiereDate'] ? date('Y-m-d', strtotime($episodeData['PremiereDate'])) : null,
|
||||
// 'runtime_minutes' => $episodeData['RunTimeTicks'] ? intval($episodeData['RunTimeTicks'] / (10000000 * 60)) : null,
|
||||
// 'rating' => $episodeData['CommunityRating'] ?? null,
|
||||
// 'tv_show_id' => $showId,
|
||||
// 'source_id' => $this->source->id,
|
||||
// 'metadata' => json_encode([
|
||||
// 'jellyfin_id' => $episodeData['Id']
|
||||
// ])
|
||||
// ];
|
||||
|
||||
// $episodeModel->create($episodeData);
|
||||
// }
|
||||
|
||||
private function getImageUrl(string $itemId, string $type): ?string
|
||||
{
|
||||
if (empty($itemId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "{$this->baseUrl}/Items/{$itemId}/Images/{$type}?maxWidth=400";
|
||||
}
|
||||
|
||||
protected function getProcessedCount(): int
|
||||
{
|
||||
return $this->processedCount;
|
||||
}
|
||||
|
||||
protected function getNewCount(): int
|
||||
{
|
||||
return $this->newCount;
|
||||
}
|
||||
|
||||
protected function getUpdatedCount(): int
|
||||
{
|
||||
return $this->updatedCount;
|
||||
}
|
||||
|
||||
protected function getDeletedCount(): int
|
||||
{
|
||||
return 0; // Jellyfin doesn't provide deletion info in this context
|
||||
}
|
||||
}
|
||||
486
app/Services/StashSyncService.php
Normal file
486
app/Services/StashSyncService.php
Normal file
@@ -0,0 +1,486 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Utils\ImageDownloader;
|
||||
use App\Models\AdultVideo;
|
||||
use GuzzleHttp\Client;
|
||||
use PDO;
|
||||
use Exception;
|
||||
|
||||
class StashSyncService extends BaseSyncService
|
||||
{
|
||||
private Client $httpClient;
|
||||
private ?string $apiKey;
|
||||
private string $baseUrl;
|
||||
private ImageDownloader $imageDownloader;
|
||||
private int $processedCount = 0;
|
||||
private int $newCount = 0;
|
||||
private int $updatedCount = 0;
|
||||
|
||||
public function __construct(PDO $pdo, array $source)
|
||||
{
|
||||
parent::__construct($pdo, $source);
|
||||
$this->httpClient = new Client([
|
||||
'timeout' => 60, // Stash can be slow
|
||||
'headers' => [
|
||||
'User-Agent' => 'MediaCollector/1.0',
|
||||
'Content-Type' => 'application/json'
|
||||
]
|
||||
]);
|
||||
$this->apiKey = $source['api_key'];
|
||||
$this->baseUrl = rtrim($source['api_url'], '/');
|
||||
$this->imageDownloader = new ImageDownloader();
|
||||
}
|
||||
|
||||
protected function executeSync(string $syncType): void
|
||||
{
|
||||
if (empty($this->apiKey) || empty($this->baseUrl)) {
|
||||
throw new Exception('Stash API key and URL not configured');
|
||||
}
|
||||
|
||||
$this->logProgress('Starting Stash library sync...');
|
||||
|
||||
// Sync scenes (movies)
|
||||
$this->syncScenes();
|
||||
|
||||
// Sync movies (if Stash has movie support)
|
||||
$this->syncMovies();
|
||||
|
||||
$this->logProgress("Processed {$this->processedCount} Stash items");
|
||||
}
|
||||
|
||||
private function syncScenes(): void
|
||||
{
|
||||
try {
|
||||
$this->logProgress('Fetching Stash scenes...');
|
||||
|
||||
// Use pagination to handle large libraries
|
||||
$page = 0;
|
||||
$perPage = 50; // Smaller batch size for reliability
|
||||
|
||||
do {
|
||||
$scenes = $this->getStashScenes($page * $perPage, $perPage);
|
||||
$this->logProgress("Processing page {$page} with " . count($scenes) . " scenes...");
|
||||
|
||||
foreach ($scenes as $sceneData) {
|
||||
$this->syncScene($sceneData);
|
||||
$this->processedCount++;
|
||||
}
|
||||
|
||||
$page++;
|
||||
} while (count($scenes) === $perPage); // Continue if we got a full page
|
||||
|
||||
$this->logProgress("Completed syncing Stash scenes");
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress('Error syncing scenes: ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function getStashScenes(int $offset = 0, int $limit = 50): array
|
||||
{
|
||||
try {
|
||||
$query = '
|
||||
query FindScenes($filter: FindFilterType) {
|
||||
findScenes(filter: $filter) {
|
||||
scenes {
|
||||
id
|
||||
title
|
||||
details
|
||||
url
|
||||
date
|
||||
rating100
|
||||
organized
|
||||
o_counter
|
||||
created_at
|
||||
updated_at
|
||||
paths {
|
||||
screenshot
|
||||
preview
|
||||
stream
|
||||
webp
|
||||
vtt
|
||||
sprite
|
||||
funscript
|
||||
caption
|
||||
}
|
||||
files {
|
||||
size
|
||||
duration
|
||||
video_codec
|
||||
audio_codec
|
||||
width
|
||||
height
|
||||
}
|
||||
paths {
|
||||
screenshot
|
||||
}
|
||||
performers {
|
||||
id
|
||||
name
|
||||
disambiguation
|
||||
url
|
||||
gender
|
||||
birthdate
|
||||
ethnicity
|
||||
country
|
||||
eye_color
|
||||
height_cm
|
||||
measurements
|
||||
fake_tits
|
||||
penis_length
|
||||
circumcised
|
||||
career_length
|
||||
tattoos
|
||||
piercings
|
||||
alias_list
|
||||
favorite
|
||||
ignore_auto_tag
|
||||
created_at
|
||||
updated_at
|
||||
details
|
||||
death_date
|
||||
hair_color
|
||||
weight
|
||||
image_path
|
||||
scene_count
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'filter' => [
|
||||
'per_page' => $limit,
|
||||
'page' => $offset / $limit + 1,
|
||||
'sort' => 'created_at',
|
||||
'direction' => 'DESC'
|
||||
]
|
||||
];
|
||||
|
||||
$response = $this->httpClient->post("{$this->baseUrl}/graphql", [
|
||||
'json' => [
|
||||
'query' => $query,
|
||||
'variables' => $variables
|
||||
],
|
||||
'timeout' => 30
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (!isset($data['data']['findScenes']['scenes'])) {
|
||||
$this->logProgress('No scenes data in response');
|
||||
return [];
|
||||
}
|
||||
|
||||
return $data['data']['findScenes']['scenes'];
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress('Failed to fetch Stash scenes: ' . $e->getMessage());
|
||||
throw new Exception('Failed to fetch Stash scenes: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function syncMovies(): void
|
||||
{
|
||||
try {
|
||||
$movies = $this->getStashMovies();
|
||||
|
||||
foreach ($movies as $movieData) {
|
||||
$this->syncMovie($movieData);
|
||||
$this->processedCount++;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress('Error syncing movies: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getStashMovies(): array
|
||||
{
|
||||
try {
|
||||
$query = '
|
||||
query FindMovies($filter: FindFilterType) {
|
||||
findMovies(filter: $filter) {
|
||||
movies {
|
||||
id
|
||||
name
|
||||
aliases
|
||||
duration
|
||||
date
|
||||
rating100
|
||||
director
|
||||
synopsis
|
||||
url
|
||||
created_at
|
||||
updated_at
|
||||
front_image_path
|
||||
back_image_path
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
$variables = [
|
||||
'filter' => [
|
||||
'per_page' => 100,
|
||||
'sort' => 'created_at',
|
||||
'direction' => 'DESC'
|
||||
]
|
||||
];
|
||||
|
||||
$response = $this->httpClient->post("{$this->baseUrl}/graphql", [
|
||||
'json' => [
|
||||
'query' => $query,
|
||||
'variables' => $variables
|
||||
]
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (!isset($data['data']['findMovies']['movies'])) {
|
||||
return []; // No movies found
|
||||
}
|
||||
|
||||
return $data['data']['findMovies']['movies'];
|
||||
} catch (Exception $e) {
|
||||
// Return empty array if movies can't be fetched
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function syncScene(array $sceneData): void
|
||||
{
|
||||
$adultVideoModel = new AdultVideo($this->pdo);
|
||||
|
||||
// Check if scene already exists by stash_id in metadata
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT id, metadata FROM adult_videos
|
||||
WHERE source_id = :source_id
|
||||
");
|
||||
$stmt->execute(['source_id' => $this->source['id']]);
|
||||
$existingScenes = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
$existingScene = null;
|
||||
foreach ($existingScenes as $scene) {
|
||||
$metadata = json_decode($scene['metadata'], true);
|
||||
if (isset($metadata['stash_id']) && $metadata['stash_id'] === $sceneData['id']) {
|
||||
$existingScene = $scene;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Download images locally
|
||||
$coverFilename = null;
|
||||
$screenshotFilename = null;
|
||||
|
||||
// Extract image URLs from Stash API response
|
||||
$coverUrl = null;
|
||||
$screenshotUrl = null;
|
||||
|
||||
// Stash provides paths.screenshot for screenshot
|
||||
if (!empty($sceneData['paths']['screenshot'])) {
|
||||
// Convert relative path to full URL
|
||||
$screenshotUrl = "{$this->baseUrl}/" . ltrim($sceneData['paths']['screenshot'], '/');
|
||||
}
|
||||
|
||||
// For cover, we might need to use a different approach or check if there's a primary image
|
||||
// For now, we'll use the screenshot as cover if available
|
||||
if ($screenshotUrl) {
|
||||
$coverUrl = $screenshotUrl;
|
||||
}
|
||||
|
||||
if (!empty($coverUrl)) {
|
||||
$coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover');
|
||||
$localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos');
|
||||
if ($localCoverPath) {
|
||||
$sceneData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($screenshotUrl)) {
|
||||
$screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot');
|
||||
$localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos');
|
||||
if ($localScreenshotPath) {
|
||||
$sceneData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle performers/actors
|
||||
$performers = $sceneData['performers'] ?? [];
|
||||
$actorNames = [];
|
||||
$performerImages = [];
|
||||
foreach ($performers as $performer) {
|
||||
$actorNames[] = $performer['name'];
|
||||
$performerImages[$performer['name']] = $performer['image_path'] ?? null;
|
||||
}
|
||||
$actors = $this->syncActors($actorNames, $performerImages);
|
||||
|
||||
$sceneData = [
|
||||
'title' => $sceneData['title'] ?: 'Untitled Scene',
|
||||
'overview' => $sceneData['details'] ?? null,
|
||||
'release_date' => $sceneData['date'] ? date('Y-m-d', strtotime($sceneData['date'])) : null,
|
||||
'runtime_minutes' => !empty($sceneData['files'][0]['duration']) ? round($sceneData['files'][0]['duration'] / 60) : null,
|
||||
'rating' => $sceneData['rating100'] ? $sceneData['rating100'] / 100 : null, // Convert from 0-100 to 0-10
|
||||
'source_id' => $this->source['id'],
|
||||
'external_id' => $sceneData['id'],
|
||||
'metadata' => json_encode([
|
||||
'stash_id' => $sceneData['id'],
|
||||
'stash_url' => $sceneData['url'] ?? null,
|
||||
'organized' => $sceneData['organized'] ?? false,
|
||||
'o_counter' => $sceneData['o_counter'] ?? 0,
|
||||
'performers' => $performers,
|
||||
'actors' => $actors,
|
||||
'file_info' => $sceneData['files'][0] ?? null,
|
||||
'paths' => $sceneData['paths'] ?? null,
|
||||
'cover_url' => $coverUrl,
|
||||
'local_cover_path' => $sceneData['local_cover_path'] ?? null,
|
||||
'screenshot_url' => $screenshotUrl,
|
||||
'local_screenshot_path' => $sceneData['local_screenshot_path'] ?? null
|
||||
])
|
||||
];
|
||||
|
||||
if ($existingScene) {
|
||||
$adultVideoModel->update($existingScene['id'], $sceneData);
|
||||
$this->updatedCount++;
|
||||
} else {
|
||||
$adultVideoModel->create($sceneData);
|
||||
$this->newCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private function syncMovie(array $movieData): void
|
||||
{
|
||||
$adultVideoModel = new AdultVideo($this->pdo);
|
||||
|
||||
// Check if movie already exists by stash_movie_id in metadata
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT id, metadata FROM adult_videos
|
||||
WHERE source_id = :source_id
|
||||
");
|
||||
$stmt->execute(['source_id' => $this->source['id']]);
|
||||
$existingMovies = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
$existingMovie = null;
|
||||
foreach ($existingMovies as $movie) {
|
||||
$metadata = json_decode($movie['metadata'], true);
|
||||
if (isset($metadata['stash_movie_id']) && $metadata['stash_movie_id'] === $movieData['id']) {
|
||||
$existingMovie = $movie;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$movieData = [
|
||||
'title' => $movieData['name'] ?: 'Untitled Movie',
|
||||
'overview' => $movieData['synopsis'] ?? null,
|
||||
'director' => $movieData['director'] ?? null,
|
||||
'release_date' => $movieData['date'] ? date('Y-m-d', strtotime($movieData['date'])) : null,
|
||||
'runtime_minutes' => $movieData['duration'] ?? null,
|
||||
'rating' => $movieData['rating100'] ? $movieData['rating100'] / 100 : null,
|
||||
'source_id' => $this->source['id'],
|
||||
'external_id' => $movieData['id'],
|
||||
'metadata' => json_encode([
|
||||
'stash_movie_id' => $movieData['id'],
|
||||
'aliases' => $movieData['aliases'] ?? null,
|
||||
'url' => $movieData['url'] ?? null
|
||||
])
|
||||
];
|
||||
|
||||
if ($existingMovie) {
|
||||
$adultVideoModel->update($existingMovie['id'], $movieData);
|
||||
$this->updatedCount++;
|
||||
} else {
|
||||
$adultVideoModel->create($movieData);
|
||||
$this->newCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private function syncActors(array $actorNames, array $performerImages = []): array
|
||||
{
|
||||
$actors = [];
|
||||
|
||||
foreach ($actorNames as $actorName) {
|
||||
if (empty($actorName)) continue;
|
||||
|
||||
$imagePath = $performerImages[$actorName] ?? null;
|
||||
$actor = $this->getOrCreateActor($actorName, $imagePath);
|
||||
if ($actor) {
|
||||
$actors[] = $actor;
|
||||
}
|
||||
}
|
||||
|
||||
return $actors;
|
||||
}
|
||||
|
||||
private function getOrCreateActor(string $name, ?string $imagePath = null): ?array
|
||||
{
|
||||
// Check if actor already exists
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT id, name, thumbnail_path FROM actors WHERE name = :name
|
||||
");
|
||||
$stmt->execute(['name' => $name]);
|
||||
$existingActor = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($existingActor) {
|
||||
return [
|
||||
'id' => $existingActor['id'],
|
||||
'name' => $existingActor['name'],
|
||||
'thumbnail_path' => $existingActor['thumbnail_path']
|
||||
];
|
||||
}
|
||||
|
||||
// Try to download performer image if available
|
||||
$thumbnailPath = null;
|
||||
if ($imagePath) {
|
||||
$imageUrl = "{$this->baseUrl}/" . ltrim($imagePath, '/');
|
||||
$thumbnailFilename = $this->imageDownloader->generateFilename($imageUrl, 'actor');
|
||||
$localImagePath = $this->imageDownloader->downloadImage($imageUrl, $thumbnailFilename, 'actors');
|
||||
if ($localImagePath) {
|
||||
$thumbnailPath = $this->imageDownloader->getPublicUrl($localImagePath);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $this->pdo->prepare("
|
||||
INSERT INTO actors (name, thumbnail_path, created_at, updated_at)
|
||||
VALUES (:name, :thumbnail_path, NOW(), NOW())
|
||||
");
|
||||
$stmt->execute([
|
||||
'name' => $name,
|
||||
'thumbnail_path' => $thumbnailPath
|
||||
]);
|
||||
$actorId = $this->pdo->lastInsertId();
|
||||
|
||||
return [
|
||||
'id' => $actorId,
|
||||
'name' => $name,
|
||||
'thumbnail_path' => $thumbnailPath
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress("Failed to create actor {$name}: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getProcessedCount(): int
|
||||
{
|
||||
return $this->processedCount;
|
||||
}
|
||||
|
||||
protected function getNewCount(): int
|
||||
{
|
||||
return $this->newCount;
|
||||
}
|
||||
|
||||
protected function getUpdatedCount(): int
|
||||
{
|
||||
return $this->updatedCount;
|
||||
}
|
||||
|
||||
protected function getDeletedCount(): int
|
||||
{
|
||||
return 0; // Stash doesn't provide deletion info in this context
|
||||
}
|
||||
}
|
||||
166
app/Services/SteamSyncService.php
Normal file
166
app/Services/SteamSyncService.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Game;
|
||||
use GuzzleHttp\Client;
|
||||
use Exception;
|
||||
|
||||
class SteamSyncService extends BaseSyncService
|
||||
{
|
||||
private Client $httpClient;
|
||||
private ?string $apiKey;
|
||||
private string $steamId;
|
||||
private int $processedCount = 0;
|
||||
private int $newCount = 0;
|
||||
private int $updatedCount = 0;
|
||||
|
||||
public function __construct(\PDO $pdo, array $source)
|
||||
{
|
||||
parent::__construct($pdo, $source);
|
||||
$this->httpClient = new Client([
|
||||
'timeout' => 30,
|
||||
'headers' => [
|
||||
'User-Agent' => 'MediaCollector/1.0'
|
||||
]
|
||||
]);
|
||||
$this->apiKey = $source['api_key'];
|
||||
|
||||
// Steam ID can be configured in source config or use a default test account
|
||||
$this->steamId = $source['config']['steam_id'] ?? '76561198000000000'; // Default test Steam ID
|
||||
}
|
||||
|
||||
protected function executeSync(string $syncType): void
|
||||
{
|
||||
if (empty($this->apiKey)) {
|
||||
throw new Exception('Steam API key not configured');
|
||||
}
|
||||
|
||||
$this->logProgress('Starting Steam library sync...');
|
||||
|
||||
// Get Steam user game library
|
||||
$games = $this->getSteamLibrary();
|
||||
|
||||
foreach ($games as $gameData) {
|
||||
$this->syncGame($gameData);
|
||||
$this->processedCount++;
|
||||
}
|
||||
|
||||
$this->logProgress("Processed {$this->processedCount} Steam games");
|
||||
}
|
||||
|
||||
private function getSteamLibrary(): array
|
||||
{
|
||||
try {
|
||||
// Steam Web API: GetOwnedGames
|
||||
$response = $this->httpClient->get('https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/', [
|
||||
'query' => [
|
||||
'key' => $this->apiKey,
|
||||
'steamid' => $this->steamId,
|
||||
'format' => 'json',
|
||||
'include_appinfo' => 'true',
|
||||
'include_played_free_games' => 'true'
|
||||
]
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (!isset($data['response']['games'])) {
|
||||
throw new Exception('No games found in Steam library');
|
||||
}
|
||||
|
||||
return $data['response']['games'];
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Failed to fetch Steam library: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function syncGame(array $gameData): void
|
||||
{
|
||||
$gameModel = new Game($this->pdo);
|
||||
|
||||
// Check if game already exists
|
||||
$existingGame = $gameModel->findAll([
|
||||
'steam_app_id' => $gameData['appid'],
|
||||
'source_id' => $this->source['id']
|
||||
]);
|
||||
|
||||
// Get additional game details from Steam API
|
||||
$gameDetails = $this->getGameDetails($gameData['appid']);
|
||||
|
||||
$gameData = [
|
||||
'title' => $gameData['name'],
|
||||
'game_key' => Game::generateGameKey($gameData['name'], 'steam'),
|
||||
'steam_app_id' => $gameData['appid'],
|
||||
'playtime_minutes' => intval($gameData['playtime_forever']),
|
||||
'platform' => 'PC',
|
||||
'source_id' => $this->source['id'],
|
||||
'last_played_at' => isset($gameData['rt_time_last_played']) && $gameData['rt_time_last_played'] > 0
|
||||
? date('Y-m-d H:i:s', $gameData['rt_time_last_played'])
|
||||
: null,
|
||||
'metadata' => json_encode([
|
||||
'appid' => $gameData['appid'],
|
||||
'playtime_windows' => $gameData['playtime_windows_forever'] ?? 0,
|
||||
'playtime_mac' => $gameData['playtime_mac_forever'] ?? 0,
|
||||
'playtime_linux' => $gameData['playtime_linux_forever'] ?? 0,
|
||||
'img_icon_url' => $gameDetails['img_icon_url'] ?? null,
|
||||
'img_logo_url' => $gameDetails['img_logo_url'] ?? null,
|
||||
'has_community_visible_stats' => $gameDetails['has_community_visible_stats'] ?? false
|
||||
])
|
||||
];
|
||||
|
||||
if (empty($existingGame)) {
|
||||
$gameModel->create($gameData);
|
||||
$this->newCount++;
|
||||
} else {
|
||||
$gameModel->update($existingGame[0]['id'], $gameData);
|
||||
$this->updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private function getGameDetails(int $appId): array
|
||||
{
|
||||
try {
|
||||
// Steam Web API: GetAppDetails
|
||||
$response = $this->httpClient->get('https://store.steampowered.com/api/appdetails/', [
|
||||
'query' => [
|
||||
'appids' => $appId,
|
||||
'cc' => 'US',
|
||||
'l' => 'english'
|
||||
]
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody(), true);
|
||||
$appData = $data[$appId] ?? [];
|
||||
|
||||
if (!$appData['success']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $appData['data'] ?? [];
|
||||
} catch (Exception $e) {
|
||||
// Return empty array if details can't be fetched
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
protected function getProcessedCount(): int
|
||||
{
|
||||
return $this->processedCount;
|
||||
}
|
||||
|
||||
protected function getNewCount(): int
|
||||
{
|
||||
return $this->newCount;
|
||||
}
|
||||
|
||||
protected function getUpdatedCount(): int
|
||||
{
|
||||
return $this->updatedCount;
|
||||
}
|
||||
|
||||
protected function getDeletedCount(): int
|
||||
{
|
||||
return 0; // Steam doesn't provide deletion info in this context
|
||||
}
|
||||
}
|
||||
257
app/Services/XbvrSyncService.php
Normal file
257
app/Services/XbvrSyncService.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Utils\ImageDownloader;
|
||||
use App\Models\AdultVideo;
|
||||
use GuzzleHttp\Client;
|
||||
use Exception;
|
||||
|
||||
class XbvrSyncService extends BaseSyncService
|
||||
{
|
||||
private Client $httpClient;
|
||||
private ?string $apiKey;
|
||||
private string $baseUrl;
|
||||
private ImageDownloader $imageDownloader;
|
||||
private int $processedCount = 0;
|
||||
private int $newCount = 0;
|
||||
private int $updatedCount = 0;
|
||||
|
||||
public function __construct(\PDO $pdo, array $source)
|
||||
{
|
||||
parent::__construct($pdo, $source);
|
||||
$this->httpClient = new Client([
|
||||
'timeout' => 30,
|
||||
'headers' => [
|
||||
'User-Agent' => 'MediaCollector/1.0',
|
||||
'X-API-Key' => $source['api_key']
|
||||
]
|
||||
]);
|
||||
$this->apiKey = $source['api_key'];
|
||||
$this->baseUrl = rtrim($source['api_url'], '/');
|
||||
$this->imageDownloader = new ImageDownloader();
|
||||
}
|
||||
|
||||
protected function executeSync(string $syncType): void
|
||||
{
|
||||
if (empty($this->apiKey) || empty($this->baseUrl)) {
|
||||
throw new Exception('XBVR API key and URL not configured');
|
||||
}
|
||||
|
||||
$this->logProgress('Starting XBVR library sync...');
|
||||
|
||||
// Sync VR scenes
|
||||
$this->syncScenes();
|
||||
|
||||
$this->logProgress("Processed {$this->processedCount} XBVR items");
|
||||
}
|
||||
|
||||
private function syncScenes(): void
|
||||
{
|
||||
try {
|
||||
$scenes = $this->getXbvrScenes();
|
||||
|
||||
foreach ($scenes as $sceneData) {
|
||||
$this->syncScene($sceneData);
|
||||
$this->processedCount++;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress('Error syncing XBVR scenes: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getXbvrScenes(): array
|
||||
{
|
||||
try {
|
||||
// XBVR API endpoint for scenes
|
||||
$response = $this->httpClient->get("{$this->baseUrl}/api/scene");
|
||||
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (!isset($data['scenes'])) {
|
||||
throw new Exception('No scenes found in XBVR');
|
||||
}
|
||||
|
||||
return $data['scenes'];
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Failed to fetch XBVR scenes: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function syncScene(array $sceneData): void
|
||||
{
|
||||
$adultVideoModel = new AdultVideo($this->pdo);
|
||||
|
||||
// Check if scene already exists by xbvr_id in metadata
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT id, metadata FROM adult_videos
|
||||
WHERE source_id = :source_id
|
||||
");
|
||||
$stmt->execute(['source_id' => $this->source['id']]);
|
||||
$existingScenes = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
$existingScene = null;
|
||||
foreach ($existingScenes as $scene) {
|
||||
$metadata = json_decode($scene['metadata'], true);
|
||||
if (isset($metadata['xbvr_id']) && $metadata['xbvr_id'] === $sceneData['id']) {
|
||||
$existingScene = $scene;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private function syncScene(array $sceneData): void
|
||||
{
|
||||
$adultVideoModel = new AdultVideo($this->pdo);
|
||||
|
||||
// Check if scene already exists by xbvr_id in metadata
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT id, metadata FROM adult_videos
|
||||
WHERE source_id = :source_id
|
||||
");
|
||||
$stmt->execute(['source_id' => $this->source['id']]);
|
||||
$existingScenes = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
$existingScene = null;
|
||||
foreach ($existingScenes as $scene) {
|
||||
$metadata = json_decode($scene['metadata'], true);
|
||||
if (isset($metadata['xbvr_id']) && $metadata['xbvr_id'] === $sceneData['id']) {
|
||||
$existingScene = $scene;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Download images locally
|
||||
$coverFilename = null;
|
||||
$screenshotFilename = null;
|
||||
|
||||
if (!empty($sceneData['cover_url'])) {
|
||||
$coverFilename = $this->imageDownloader->generateFilename($sceneData['cover_url'], 'cover');
|
||||
$localCoverPath = $this->imageDownloader->downloadImage($sceneData['cover_url'], $coverFilename, 'adult_videos');
|
||||
if ($localCoverPath) {
|
||||
$sceneData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($sceneData['screenshot_url'])) {
|
||||
$screenshotFilename = $this->imageDownloader->generateFilename($sceneData['screenshot_url'], 'screenshot');
|
||||
$localScreenshotPath = $this->imageDownloader->downloadImage($sceneData['screenshot_url'], $screenshotFilename, 'adult_videos');
|
||||
if ($localScreenshotPath) {
|
||||
$sceneData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle actors
|
||||
$actors = $this->syncActors($sceneData['cast'] ?? []);
|
||||
|
||||
$sceneData = [
|
||||
'title' => $sceneData['title'] ?: 'Untitled VR Scene',
|
||||
'overview' => $sceneData['synopsis'] ?? null,
|
||||
'release_date' => $sceneData['release_date'] ? date('Y-m-d', strtotime($sceneData['release_date'])) : null,
|
||||
'runtime_minutes' => $sceneData['duration'] ?? null,
|
||||
'rating' => $sceneData['rating'] ?? null,
|
||||
'source_id' => $this->source['id'],
|
||||
'external_id' => $sceneData['id'],
|
||||
'metadata' => json_encode([
|
||||
'xbvr_id' => $sceneData['id'],
|
||||
'xbvr_url' => $sceneData['scene_url'] ?? null,
|
||||
'cast' => $sceneData['cast'] ?? [],
|
||||
'actors' => $actors,
|
||||
'tags' => $sceneData['tags'] ?? [],
|
||||
'is_available' => $sceneData['is_available'] ?? true,
|
||||
'is_watched' => $sceneData['is_watched'] ?? false,
|
||||
'watch_count' => $sceneData['watch_count'] ?? 0,
|
||||
'video_length' => $sceneData['video_length'] ?? null,
|
||||
'video_width' => $sceneData['video_width'] ?? null,
|
||||
'video_height' => $sceneData['video_height'] ?? null,
|
||||
'video_codec' => $sceneData['video_codec'] ?? null,
|
||||
'file_path' => $sceneData['file_path'] ?? null,
|
||||
'cover_url' => $sceneData['cover_url'] ?? null,
|
||||
'local_cover_path' => $sceneData['local_cover_path'] ?? null,
|
||||
'screenshot_url' => $sceneData['screenshot_url'] ?? null,
|
||||
'local_screenshot_path' => $sceneData['local_screenshot_path'] ?? null
|
||||
])
|
||||
];
|
||||
|
||||
if ($existingScene) {
|
||||
$adultVideoModel->update($existingScene['id'], $sceneData);
|
||||
$this->updatedCount++;
|
||||
} else {
|
||||
$adultVideoModel->create($sceneData);
|
||||
$this->newCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private function syncActors(array $cast): array
|
||||
{
|
||||
$actors = [];
|
||||
|
||||
foreach ($cast as $actorName) {
|
||||
if (empty($actorName)) continue;
|
||||
|
||||
$actor = $this->getOrCreateActor($actorName);
|
||||
if ($actor) {
|
||||
$actors[] = $actor;
|
||||
}
|
||||
}
|
||||
|
||||
return $actors;
|
||||
}
|
||||
|
||||
private function getOrCreateActor(string $name): ?array
|
||||
{
|
||||
// Check if actor already exists
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT id, name, thumbnail_path FROM actors WHERE name = :name
|
||||
");
|
||||
$stmt->execute(['name' => $name]);
|
||||
$existingActor = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
if ($existingActor) {
|
||||
return [
|
||||
'id' => $existingActor['id'],
|
||||
'name' => $existingActor['name'],
|
||||
'thumbnail_path' => $existingActor['thumbnail_path']
|
||||
];
|
||||
}
|
||||
|
||||
// For now, we'll create actor without thumbnail
|
||||
// In a full implementation, you'd fetch actor details from XBVR API
|
||||
try {
|
||||
$stmt = $this->pdo->prepare("
|
||||
INSERT INTO actors (name, created_at, updated_at)
|
||||
VALUES (:name, NOW(), NOW())
|
||||
");
|
||||
$stmt->execute(['name' => $name]);
|
||||
$actorId = $this->pdo->lastInsertId();
|
||||
|
||||
return [
|
||||
'id' => $actorId,
|
||||
'name' => $name,
|
||||
'thumbnail_path' => null
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
$this->logProgress("Failed to create actor {$name}: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getProcessedCount(): int
|
||||
{
|
||||
return $this->processedCount;
|
||||
}
|
||||
|
||||
protected function getNewCount(): int
|
||||
{
|
||||
return $this->newCount;
|
||||
}
|
||||
|
||||
protected function getUpdatedCount(): int
|
||||
{
|
||||
return $this->updatedCount;
|
||||
}
|
||||
|
||||
protected function getDeletedCount(): int
|
||||
{
|
||||
return 0; // XBVR doesn't provide deletion info in this context
|
||||
}
|
||||
}
|
||||
94
app/Utils/ImageDownloader.php
Normal file
94
app/Utils/ImageDownloader.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Utils;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use Exception;
|
||||
|
||||
class ImageDownloader
|
||||
{
|
||||
private Client $httpClient;
|
||||
private string $basePath;
|
||||
|
||||
public function __construct(string $basePath = 'public/images')
|
||||
{
|
||||
$this->httpClient = new Client([
|
||||
'timeout' => 30,
|
||||
'headers' => [
|
||||
'User-Agent' => 'MediaCollector/1.0'
|
||||
]
|
||||
]);
|
||||
$this->basePath = rtrim($basePath, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image from URL and save it locally
|
||||
*/
|
||||
public function downloadImage(string $url, string $filename, string $subfolder = ''): ?string
|
||||
{
|
||||
if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$folderPath = $this->basePath;
|
||||
if (!empty($subfolder)) {
|
||||
$folderPath .= '/' . trim($subfolder, '/');
|
||||
}
|
||||
|
||||
// Create folder if it doesn't exist
|
||||
if (!is_dir($folderPath)) {
|
||||
mkdir($folderPath, 0755, true);
|
||||
}
|
||||
|
||||
$filePath = $folderPath . '/' . $filename;
|
||||
|
||||
// Check if file already exists
|
||||
if (file_exists($filePath)) {
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
$response = $this->httpClient->get($url, ['sink' => $filePath]);
|
||||
|
||||
if ($response->getStatusCode() === 200) {
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (Exception $e) {
|
||||
// Log error but don't throw - images are not critical
|
||||
error_log("Failed to download image {$url}: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique filename for an image
|
||||
*/
|
||||
public function generateFilename(string $url, string $prefix = ''): string
|
||||
{
|
||||
$extension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
|
||||
if (empty($extension)) {
|
||||
$extension = 'jpg'; // Default fallback
|
||||
}
|
||||
|
||||
$hash = substr(md5($url . time()), 0, 8);
|
||||
|
||||
return ($prefix ? $prefix . '_' : '') . $hash . '.' . $extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public URL for a local image
|
||||
*/
|
||||
public function getPublicUrl(string $localPath): ?string
|
||||
{
|
||||
if (empty($localPath) || !file_exists($localPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove the public/ prefix to get the web-accessible path
|
||||
$relativePath = str_replace($this->basePath . '/', '', $localPath);
|
||||
|
||||
return '/' . $relativePath;
|
||||
}
|
||||
}
|
||||
172
app/helpers.php
Normal file
172
app/helpers.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Get the base URL for the application
|
||||
*/
|
||||
function base_url(): string
|
||||
{
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
return $protocol . '://' . $host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path for a named route
|
||||
*/
|
||||
function path_for(string $name, array $params = []): string
|
||||
{
|
||||
// This would be implemented with Slim's URL generator in a real application
|
||||
$routes = [
|
||||
'home' => '/',
|
||||
'games.index' => '/media/games',
|
||||
'movies.index' => '/media/movies',
|
||||
'tvshows.index' => '/media/tv-shows',
|
||||
'music.index' => '/media/music',
|
||||
'auth.login' => '/login',
|
||||
'auth.logout' => '/logout'
|
||||
];
|
||||
|
||||
$path = $routes[$name] ?? '/';
|
||||
|
||||
// Replace parameters in path
|
||||
foreach ($params as $key => $value) {
|
||||
$path = str_replace('{' . $key . '}', $value, $path);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSRF token for forms
|
||||
*/
|
||||
function csrf_token(): string
|
||||
{
|
||||
if (isset($_SESSION['auth']) && $_SESSION['auth'] instanceof \App\Services\AuthService) {
|
||||
return $_SESSION['auth']->generateCSRFToken();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current authenticated user
|
||||
*/
|
||||
function current_user(): ?array
|
||||
{
|
||||
if (isset($_SESSION['auth']) && $_SESSION['auth'] instanceof \App\Services\AuthService) {
|
||||
return $_SESSION['auth']->getCurrentUser();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user is authenticated
|
||||
*/
|
||||
function is_logged_in(): bool
|
||||
{
|
||||
if (isset($_SESSION['auth']) && $_SESSION['auth'] instanceof \App\Services\AuthService) {
|
||||
return $_SESSION['auth']->isLoggedIn();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user is admin
|
||||
*/
|
||||
function is_admin(): bool
|
||||
{
|
||||
if (isset($_SESSION['auth']) && $_SESSION['auth'] instanceof \App\Services\AuthService) {
|
||||
return $_SESSION['auth']->isAdmin();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable format
|
||||
*/
|
||||
function format_bytes(int $bytes, int $precision = 2): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to human readable format
|
||||
*/
|
||||
function format_duration(int $seconds): string
|
||||
{
|
||||
$hours = floor($seconds / 3600);
|
||||
$minutes = floor(($seconds % 3600) / 60);
|
||||
$seconds = $seconds % 60;
|
||||
|
||||
if ($hours > 0) {
|
||||
return sprintf('%dh %dm', $hours, $minutes);
|
||||
} elseif ($minutes > 0) {
|
||||
return sprintf('%dm %ds', $minutes, $seconds);
|
||||
} else {
|
||||
return sprintf('%ds', $seconds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random string
|
||||
*/
|
||||
function generate_random_string(int $length = 32): string
|
||||
{
|
||||
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
$charactersLength = strlen($characters);
|
||||
$randomString = '';
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$randomString .= $characters[random_int(0, $charactersLength - 1)];
|
||||
}
|
||||
|
||||
return $randomString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file exists and is readable
|
||||
*/
|
||||
function file_exists_and_readable(string $path): bool
|
||||
{
|
||||
return file_exists($path) && is_readable($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from path
|
||||
*/
|
||||
function get_file_extension(string $path): string
|
||||
{
|
||||
return strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string is valid JSON
|
||||
*/
|
||||
function is_json(string $string): bool
|
||||
{
|
||||
json_decode($string);
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert array to object recursively
|
||||
*/
|
||||
function array_to_object(array $array): object
|
||||
{
|
||||
return json_decode(json_encode($array), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert object to array recursively
|
||||
*/
|
||||
function object_to_array(object $object): array
|
||||
{
|
||||
return json_decode(json_encode($object), true);
|
||||
}
|
||||
Reference in New Issue
Block a user