first commit

This commit is contained in:
Lars Behrends
2025-10-17 13:29:28 +02:00
commit 929ee43001
85 changed files with 10361 additions and 0 deletions

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