diff --git a/app/Controllers/ActorController.php b/app/Controllers/ActorController.php index df3d815..5b9aca6 100644 --- a/app/Controllers/ActorController.php +++ b/app/Controllers/ActorController.php @@ -489,6 +489,90 @@ class ActorController extends Controller } } + public function getMissingStashReports(Request $request, Response $response, $args) + { + try { + $logDir = __DIR__ . '/../../storage/logs'; + $reports = []; + + if (is_dir($logDir)) { + $files = glob($logDir . '/missing_stash_actors_*.json'); + + foreach ($files as $file) { + $filename = basename($file); + $fileData = json_decode(file_get_contents($file), true); + + if ($fileData) { + $reports[] = [ + 'filename' => $filename, + 'generated_at' => $fileData['generated_at'] ?? 'Unknown', + 'total_missing' => $fileData['total_missing'] ?? 0, + 'description' => $fileData['description'] ?? '', + 'file_path' => $file + ]; + } + } + + // Sort by generation date (newest first) + usort($reports, function($a, $b) { + return strtotime($b['generated_at']) <=> strtotime($a['generated_at']); + }); + } + + return $this->jsonResponse($response, [ + 'reports' => $reports, + 'total_reports' => count($reports) + ]); + + } catch (\Exception $e) { + return $this->jsonResponse($response->withStatus(500), [ + 'error' => 'Internal server error: ' . $e->getMessage() + ]); + } + } + + public function syncExistingPerformers(Request $request, Response $response, $args) + { + // Set PHP timeouts for long-running operations + ini_set('memory_limit', '2G'); + ini_set('max_execution_time', 0); // Allow unlimited execution time + ini_set('max_input_time', 3600); // 1 hour for input processing + ini_set('default_socket_timeout', 3600); // 1 hour for socket operations + + // Set headers to prevent timeouts + $response = $response->withHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering + + try { + // Get Stash configuration from database + $stmt = $this->pdo->prepare('SELECT * FROM sources WHERE name = ?'); + $stmt->execute(['stash']); + $stashSource = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$stashSource) { + return $this->jsonResponse($response->withStatus(500), [ + 'error' => 'Stash source not configured in database' + ]); + } + + // Create Stash sync service + $stashSyncService = new \App\Services\StashSyncService($this->pdo, $stashSource); + + // Run the existing performers sync + $results = $stashSyncService->syncExistingPerformers(); + + return $this->jsonResponse($response, [ + 'success' => true, + 'message' => 'Existing performers sync completed', + 'results' => $results + ]); + + } catch (\Exception $e) { + return $this->jsonResponse($response->withStatus(500), [ + 'error' => 'Internal server error: ' . $e->getMessage() + ]); + } + } + public function index(Request $request, Response $response, $args) { $queryParams = $request->getQueryParams(); diff --git a/app/Database/Database.php b/app/Database/Database.php index e4601c1..4ec3349 100644 --- a/app/Database/Database.php +++ b/app/Database/Database.php @@ -97,7 +97,8 @@ class Database require_once $file; $className = self::getMigrationClassName($file); - $migration = new $className(); + $pdo = self::getInstance(); + $migration = new $className($pdo); $migration->up(); // Record the migration @@ -112,14 +113,20 @@ class Database { $content = file_get_contents($file); + // Extract namespace and class name from PHP file + $namespace = ''; + if (preg_match('/namespace\s+([^;]+);/', $content, $namespaceMatches)) { + $namespace = $namespaceMatches[1] . '\\'; + } + // Extract class name from PHP file if (preg_match('/class\s+(\w+)\s+extends\s+Migration/', $content, $matches)) { - return $matches[1]; + return $namespace . $matches[1]; } // Fallback: convert filename to class name $filename = basename($file, '.php'); - return str_replace(' ', '', ucwords(str_replace('_', ' ', $filename))); + return $namespace . str_replace(' ', '', ucwords(str_replace('_', ' ', $filename))); } public static function seed(): void diff --git a/app/Models/AdultVideo.php b/app/Models/AdultVideo.php index 9ef028b..1b12aec 100644 --- a/app/Models/AdultVideo.php +++ b/app/Models/AdultVideo.php @@ -9,7 +9,9 @@ class AdultVideo extends Model 'title', 'overview', 'poster_url', + 'poster_aspect_ratio', 'backdrop_url', + 'backdrop_aspect_ratio', 'rating', 'runtime_minutes', 'release_date', diff --git a/app/Models/Game.php b/app/Models/Game.php index c3d40b4..24c3e7f 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -17,6 +17,7 @@ class Game extends Model 'platform_game_id', 'steam_app_id', 'image_url', + 'image_aspect_ratio', 'banner_url', 'rating', 'playtime_minutes', @@ -27,6 +28,7 @@ class Game extends Model 'platform_achievements', 'background_image', 'cover_image', + 'cover_aspect_ratio', 'icon', 'genres_json', 'developers_json', diff --git a/app/Services/PlayniteImportService.php b/app/Services/PlayniteImportService.php index 0cb7dbb..8047cb0 100644 --- a/app/Services/PlayniteImportService.php +++ b/app/Services/PlayniteImportService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\Game; use App\Models\Source; +use App\Utils\ImageAspectRatioDetector; class PlayniteImportService { @@ -100,6 +101,7 @@ class PlayniteImportService // Rich media 'background_image' => $game['BackgroundImage'] ?? null, 'cover_image' => $game['CoverImage'] ?? null, + 'cover_aspect_ratio' => $this->detectAspectRatio($game['CoverImage'] ?? null), 'icon' => $game['Icon'] ?? null, // Multiple entities as JSON @@ -410,6 +412,18 @@ class PlayniteImportService $gameModel->update($gameId, $gameData); } + /** + * Detect aspect ratio for an image URL + */ + private function detectAspectRatio(?string $imageUrl): ?float + { + if (!$imageUrl) { + return null; + } + + return ImageAspectRatioDetector::detectAspectRatio($imageUrl); + } + /** * Convert a value to boolean, handling empty strings properly */ diff --git a/app/Services/StashSyncService.php b/app/Services/StashSyncService.php index 007d075..ea3ecf8 100644 --- a/app/Services/StashSyncService.php +++ b/app/Services/StashSyncService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Utils\ImageDownloader; +use App\Utils\ImageAspectRatioDetector; use App\Models\AdultVideo; use GuzzleHttp\Client; use PDO; @@ -469,9 +470,31 @@ class StashSyncService extends BaseSyncService $performers = $sceneData['performers'] ?? []; $actors = $this->syncActors($performers); + // Detect aspect ratios for downloaded images + $posterAspectRatio = null; + $backdropAspectRatio = null; + + if (!empty($sceneData['local_cover_path'])) { + $posterAspectRatio = ImageAspectRatioDetector::detectAspectRatio($sceneData['local_cover_path']); + if ($posterAspectRatio) { + $this->logProgress("Detected poster aspect ratio: {$posterAspectRatio}"); + } + } + + if (!empty($sceneData['local_screenshot_path'])) { + $backdropAspectRatio = ImageAspectRatioDetector::detectAspectRatio($sceneData['local_screenshot_path']); + if ($backdropAspectRatio) { + $this->logProgress("Detected backdrop aspect ratio: {$backdropAspectRatio}"); + } + } + $sceneData = [ 'title' => $sceneData['title'] ?: 'Untitled Scene', 'overview' => $sceneData['details'] ?? null, + 'poster_url' => $sceneData['local_cover_path'] ?? null, + 'poster_aspect_ratio' => $posterAspectRatio, + 'backdrop_url' => $sceneData['local_screenshot_path'] ?? null, + 'backdrop_aspect_ratio' => $backdropAspectRatio, '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 @@ -869,6 +892,313 @@ class StashSyncService extends BaseSyncService } } + /** + * Sync existing performers with Stash data + */ + public function syncExistingPerformers(): array + { + $results = [ + 'processed' => 0, + 'updated' => 0, + 'skipped' => 0, + 'not_found_in_stash' => [], + 'errors' => [] + ]; + + try { + $this->logProgress('Starting existing performers sync with Stash...'); + + // Get all existing actors from database + $stmt = $this->pdo->prepare("SELECT id, name, metadata FROM actors ORDER BY name ASC"); + $stmt->execute(); + $existingActors = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + $this->logProgress("Found " . count($existingActors) . " existing actors to check"); + + foreach ($existingActors as $actor) { + try { + $this->logProgress("Processing actor: {$actor['name']} (ID: {$actor['id']})"); + + // Search for this actor in Stash + $stashPerformers = $this->searchStashPerformer($actor['name']); + + if (empty($stashPerformers)) { + $this->logProgress("No matching performer found in Stash for: {$actor['name']}"); + $results['not_found_in_stash'][] = [ + 'id' => $actor['id'], + 'name' => $actor['name'], + 'local_metadata' => json_decode($actor['metadata'] ?? '{}', true) + ]; + $results['skipped']++; + continue; + } + + // Find the best match (exact name match preferred) + $bestMatch = null; + foreach ($stashPerformers as $performer) { + if (strtolower(trim($performer['name'])) === strtolower(trim($actor['name']))) { + $bestMatch = $performer; + break; + } + } + + // If no exact match, use the first result + if (!$bestMatch && !empty($stashPerformers)) { + $bestMatch = $stashPerformers[0]; + $this->logProgress("Using closest match for {$actor['name']}: {$bestMatch['name']}"); + } + + if ($bestMatch) { + // Update the actor with Stash data + $this->updateActorWithStashData($actor['id'], $bestMatch); + $results['updated']++; + $this->logProgress("Updated actor {$actor['name']} with Stash data"); + } else { + $results['skipped']++; + } + + $results['processed']++; + + // Add a small delay to avoid overwhelming the Stash server + usleep(100000); // 0.1 seconds + + } catch (Exception $e) { + $errorMsg = "Failed to sync actor {$actor['name']}: " . $e->getMessage(); + $results['errors'][] = $errorMsg; + $this->logProgress("ERROR: " . $errorMsg); + $results['processed']++; + } + } + + $this->logProgress("Existing performers sync completed: {$results['updated']} updated, {$results['skipped']} skipped, " . count($results['errors']) . " errors"); + + // Save missing actors report + $this->saveMissingActorsReport($results['not_found_in_stash']); + + } catch (Exception $e) { + $this->logProgress("Error during existing performers sync: " . $e->getMessage()); + throw $e; + } + + return $results; + } + + /** + * Save a report of actors not found in Stash + */ + private function saveMissingActorsReport(array $missingActors): void + { + if (empty($missingActors)) { + $this->logProgress("No missing actors to report"); + return; + } + + $reportPath = __DIR__ . '/../../storage/logs/missing_stash_actors_' . date('Y-m-d_H-i-s') . '.json'; + + // Create logs directory if it doesn't exist + $logDir = dirname($reportPath); + if (!is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $reportData = [ + 'generated_at' => date('Y-m-d H:i:s'), + 'total_missing' => count($missingActors), + 'missing_actors' => $missingActors, + 'description' => 'These actors exist in your local database but were not found in Stash. You can create them in Stash for future syncs.' + ]; + + $jsonReport = json_encode($reportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + if (file_put_contents($reportPath, $jsonReport)) { + $this->logProgress("Missing actors report saved to: {$reportPath}"); + $this->logProgress("Found " . count($missingActors) . " actors not in Stash"); + } else { + $this->logProgress("Failed to save missing actors report"); + } + } + + /** + * Search for a performer in Stash by name + */ + private function searchStashPerformer(string $name): array + { + try { + $query = ' + query FindPerformers($filter: FindFilterType) { + findPerformers(filter: $filter) { + 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' => [ + 'q' => $name, + 'per_page' => 5, // Get a few results to find the best match + 'sort' => 'name', + 'direction' => 'ASC' + ] + ]; + + $response = $this->httpClient->post("{$this->baseUrl}/graphql", [ + 'json' => [ + 'query' => $query, + 'variables' => $variables + ], + 'timeout' => 30 + ]); + + $data = json_decode($response->getBody(), true); + + if (!isset($data['data']['findPerformers']['performers'])) { + return []; + } + + return $data['data']['findPerformers']['performers']; + } catch (Exception $e) { + $this->logProgress('Failed to search Stash for performer: ' . $e->getMessage()); + return []; + } + } + + /** + * Update an existing actor with Stash performer data + */ + private function updateActorWithStashData(int $actorId, array $performer): void + { + // Get existing actor data + $stmt = $this->pdo->prepare("SELECT metadata FROM actors WHERE id = :id"); + $stmt->execute(['id' => $actorId]); + $existingActor = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$existingActor) { + throw new Exception("Actor with ID {$actorId} not found"); + } + + $existingMetadata = json_decode($existingActor['metadata'] ?? '{}', true); + + // Prepare updated metadata from Stash performer data + $updatedMetadata = [ + 'stash_id' => $performer['id'] ?? null, + 'stash_url' => $performer['url'] ?? null, + 'disambiguation' => $performer['disambiguation'] ?? '', + 'gender' => $performer['gender'] ?? null, + 'birth_date' => $performer['birthdate'] ?? null, + 'death_date' => $performer['death_date'] ?? null, + 'ethnicity' => $performer['ethnicity'] ?? null, + 'country' => $performer['country'] ?? null, + 'nationality' => $performer['country'] ?? null, // Map country to nationality + 'eye_color' => $performer['eye_color'] ?? null, + 'hair_color' => $performer['hair_color'] ?? null, + 'height' => $performer['height_cm'] ? $performer['height_cm'] . 'cm' : null, + 'measurements' => $performer['measurements'] ?? null, + 'cup_size' => $this->extractCupSize($performer['measurements'] ?? ''), + 'weight' => $performer['weight'] ? $performer['weight'] . 'kg' : null, + 'piercings' => $performer['piercings'] ?? null, + 'tattoos' => $performer['tattoos'] ?? null, + 'fake_tits' => $performer['fake_tits'] ?? null, + 'penis_length' => $performer['penis_length'] ?? null, + 'circumcised' => $performer['circumcised'] ?? null, + 'career_length' => $performer['career_length'] ?? null, + 'aliases' => $performer['alias_list'] ?? [], + 'favorite' => $performer['favorite'] ?? false, + 'ignore_auto_tag' => $performer['ignore_auto_tag'] ?? false, + 'scene_count' => $performer['scene_count'] ?? 0, + 'details' => $performer['details'] ?? null, + 'stash_created_at' => $performer['created_at'] ?? null, + 'stash_updated_at' => $performer['updated_at'] ?? null, + 'social_media' => [ + 'website' => $performer['url'] ?? null + ], + 'adult_specific' => [ + 'debut_year' => $this->extractDebutYear($performer['career_length'] ?? ''), + 'retirement_year' => $this->extractRetirementYear($performer['career_length'] ?? ''), + 'active' => $this->isActivePerformer($performer['career_length'] ?? ''), + 'genres' => [], + 'specialties' => [] + ] + ]; + + // Merge with existing metadata, preferring new Stash data but keeping any custom fields + $finalMetadata = array_merge($existingMetadata, $updatedMetadata); + + // Try to download/update performer image if available and not already set + $thumbnailPath = null; + $imagePath = $performer['image_path'] ?? null; + if ($imagePath && empty($existingMetadata['local_image_path'])) { + try { + // Handle different image path formats from Stash + if (strpos($imagePath, 'http') === 0) { + // Already a full URL + $imageUrl = $imagePath; + } elseif (strpos($imagePath, '/') === 0) { + // Absolute path from Stash root + $imageUrl = "{$this->baseUrl}" . $imagePath; + } else { + // Relative path - assume it's in performer images directory + $imageUrl = "{$this->baseUrl}/performer/" . $performer['id'] . "/" . $imagePath; + } + + // Validate the constructed URL + if (filter_var($imageUrl, FILTER_VALIDATE_URL)) { + $this->logProgress("Downloading image for performer {$performer['name']}: " . $imageUrl); + $thumbnailFilename = $this->imageDownloader->generateFilename($imageUrl, 'actor'); + $localImagePath = $this->imageDownloader->downloadImage($imageUrl, $thumbnailFilename, 'actors'); + if ($localImagePath) { + $thumbnailPath = $this->imageDownloader->getPublicUrl($localImagePath); + $this->logProgress("Downloaded performer image: " . $localImagePath); + } + } + } catch (Exception $e) { + $this->logProgress("Exception downloading performer image for {$performer['name']}: " . $e->getMessage()); + } + } + + // Update the actor record + $stmt = $this->pdo->prepare(" + UPDATE actors + SET thumbnail_path = COALESCE(:thumbnail_path, thumbnail_path), + metadata = :metadata, + updated_at = NOW() + WHERE id = :id + "); + $stmt->execute([ + 'id' => $actorId, + 'thumbnail_path' => $thumbnailPath, + 'metadata' => json_encode($finalMetadata) + ]); + } + protected function executeCleanup(): void { $this->logProgress("Starting cleanup - detecting deleted media in Stash..."); diff --git a/app/Utils/ImageAspectRatioDetector.php b/app/Utils/ImageAspectRatioDetector.php new file mode 100644 index 0000000..12a9ce4 --- /dev/null +++ b/app/Utils/ImageAspectRatioDetector.php @@ -0,0 +1,164 @@ + 10) { + return null; + } + + return $aspectRatio; + + } catch (\Exception $e) { + // Log error but don't throw - aspect ratio detection should be non-blocking + error_log("Failed to detect aspect ratio for image {$imageUrl}: " . $e->getMessage()); + return null; + } + } + + /** + * Resolve local image path for getimagesize + * + * @param string $relativeUrl The relative URL of the image + * @return string The absolute file path + */ + private static function resolveLocalImagePath(string $relativeUrl): string + { + // Remove leading slash if present + $relativeUrl = ltrim($relativeUrl, '/'); + + // Define common image directories + $possiblePaths = [ + __DIR__ . '/../../public/' . $relativeUrl, + __DIR__ . '/../../public/images/' . $relativeUrl, + __DIR__ . '/../../public/media/' . $relativeUrl, + __DIR__ . '/../../storage/' . $relativeUrl, + ]; + + foreach ($possiblePaths as $path) { + if (file_exists($path)) { + return $path; + } + } + + // If no local file found, return the original URL (might be external) + return $relativeUrl; + } + + /** + * Get a CSS aspect ratio class based on the aspect ratio value + * + * @param float|null $aspectRatio The aspect ratio value + * @param string $defaultClass The default class to use if aspect ratio is null + * @return string The CSS class for the aspect ratio + */ + public static function getAspectRatioClass(?float $aspectRatio, string $defaultClass = 'aspect-[1/1]'): string + { + if ($aspectRatio === null) { + return $defaultClass; + } + + // Common aspect ratios with nice round numbers + if (abs($aspectRatio - 1.333) < 0.05) { // 4:3 + return 'aspect-[4/3]'; + } elseif (abs($aspectRatio - 1.5) < 0.05) { // 3:2 + return 'aspect-[3/2]'; + } elseif (abs($aspectRatio - 1.667) < 0.05) { // 5:3 + return 'aspect-[5/3]'; + } elseif (abs($aspectRatio - 1.778) < 0.05) { // 16:9 + return 'aspect-[16/9]'; + } elseif (abs($aspectRatio - 0.667) < 0.05) { // 2:3 + return 'aspect-[2/3]'; + } elseif (abs($aspectRatio - 0.75) < 0.05) { // 3:4 + return 'aspect-[3/4]'; + } elseif (abs($aspectRatio - 1.0) < 0.05) { // 1:1 + return 'aspect-[1/1]'; + } + + // For other ratios, use a custom aspect ratio class + // Tailwind doesn't support dynamic aspect ratios, so we'll use a data attribute approach + return 'aspect-custom'; + } + + /** + * Get inline styles for custom aspect ratios + * + * @param float|null $aspectRatio The aspect ratio value + * @return string CSS style string for custom aspect ratios + */ + public static function getCustomAspectRatioStyle(?float $aspectRatio): string + { + if ($aspectRatio === null) { + return ''; + } + + // Convert aspect ratio to padding-bottom percentage for aspect ratio simulation + $paddingBottom = (1 / $aspectRatio) * 100; + return "padding-bottom: {$paddingBottom}%;"; + } + + /** + * Batch detect aspect ratios for multiple images + * + * @param array $imageUrls Array of image URLs + * @return array Array of aspect ratios corresponding to the input URLs + */ + public static function batchDetectAspectRatios(array $imageUrls): array + { + $aspectRatios = []; + + foreach ($imageUrls as $url) { + $aspectRatios[] = self::detectAspectRatio($url); + } + + return $aspectRatios; + } + + /** + * Validate if an aspect ratio value is reasonable + * + * @param float $aspectRatio The aspect ratio to validate + * @return bool True if the aspect ratio is valid + */ + public static function isValidAspectRatio(float $aspectRatio): bool + { + return $aspectRatio > 0.1 && $aspectRatio < 10; + } +} diff --git a/database/migrations/2025_11_06_000001_add_aspect_ratio_fields.php b/database/migrations/2025_11_06_000001_add_aspect_ratio_fields.php new file mode 100644 index 0000000..2cf0d1c --- /dev/null +++ b/database/migrations/2025_11_06_000001_add_aspect_ratio_fields.php @@ -0,0 +1,40 @@ +schema()->table('games', function (Blueprint $table) { + $table->decimal('cover_aspect_ratio', 4, 3)->nullable()->after('cover_image'); + $table->decimal('image_aspect_ratio', 4, 3)->nullable()->after('image_url'); + }); + + // Add aspect_ratio field to adult_videos table + $capsule->schema()->table('adult_videos', function (Blueprint $table) { + $table->decimal('poster_aspect_ratio', 4, 3)->nullable()->after('poster_url'); + $table->decimal('backdrop_aspect_ratio', 4, 3)->nullable()->after('backdrop_url'); + }); + } + + public function down() + { + $capsule = Database::getCapsule(); + + // Remove aspect_ratio fields from games table + $capsule->schema()->table('games', function (Blueprint $table) { + $table->dropColumn(['cover_aspect_ratio', 'image_aspect_ratio']); + }); + + // Remove aspect_ratio fields from adult_videos table + $capsule->schema()->table('adult_videos', function (Blueprint $table) { + $table->dropColumn(['poster_aspect_ratio', 'backdrop_aspect_ratio']); + }); + } +} diff --git a/docker/nginx.conf b/docker/nginx.conf index cf569d2..9240a7e 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -28,6 +28,17 @@ server { fastcgi_param HTTP_X_FORWARDED_PROTO $scheme; fastcgi_param HTTP_X_REAL_IP $remote_addr; fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for; + + # Timeout settings for long-running operations (like sync processes) + fastcgi_read_timeout 1800; # 30 minutes + fastcgi_send_timeout 1800; # 30 minutes + fastcgi_connect_timeout 300; # 5 minutes + + # Buffer settings for large responses + fastcgi_buffer_size 128k; + fastcgi_buffers 256 16k; + fastcgi_busy_buffers_size 256k; + fastcgi_temp_file_write_size 256k; } # Security headers diff --git a/resources/views/admin/index.twig b/resources/views/admin/index.twig index cc57f86..1c8f7a2 100644 --- a/resources/views/admin/index.twig +++ b/resources/views/admin/index.twig @@ -85,6 +85,28 @@ Cleanup + {% elseif source.name == 'stash' %} + +
Last sync: Never