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