actor sync

This commit is contained in:
Lars Behrends
2025-11-06 13:08:02 +01:00
parent 3f56625205
commit a44c311e89
14 changed files with 773 additions and 9 deletions

View File

@@ -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();

View File

@@ -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

View File

@@ -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',

View File

@@ -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',

View File

@@ -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
*/

View File

@@ -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...");

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Utils;
class ImageAspectRatioDetector
{
/**
* Detect the aspect ratio of an image from its URL
*
* @param string $imageUrl The URL of the image to analyze
* @return float|null The aspect ratio (width/height) or null if detection fails
*/
public static function detectAspectRatio(string $imageUrl): ?float
{
if (empty($imageUrl)) {
return null;
}
// Handle local images (relative URLs)
if (!filter_var($imageUrl, FILTER_VALIDATE_URL)) {
$imageUrl = self::resolveLocalImagePath($imageUrl);
}
try {
// Get image dimensions
$imageInfo = @getimagesize($imageUrl);
if (!$imageInfo) {
return null;
}
$width = $imageInfo[0];
$height = $imageInfo[1];
if ($width <= 0 || $height <= 0) {
return null;
}
// Calculate aspect ratio (width/height)
$aspectRatio = round($width / $height, 3);
// Validate aspect ratio is reasonable (between 0.1 and 10)
if ($aspectRatio < 0.1 || $aspectRatio > 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;
}
}