This commit is contained in:
Lars Behrends
2025-10-19 22:05:21 +02:00
parent 0f95458466
commit 552bb72370
8 changed files with 560 additions and 70 deletions

View File

@@ -51,10 +51,20 @@ abstract class BaseSyncService
{
if ($this->logFileHandle) {
$this->logProgress("=== Sync completed at " . date('Y-m-d H:i:s') . " ===");
$this->updateSyncLog($this->currentSyncLogId, 'completed', [
'processed_items' => $this->getProcessedCount(),
'new_items' => $this->getNewCount(),
'updated_items' => $this->getUpdatedCount(),
'deleted_items' => $this->getDeletedCount(),
'message' => $this->getCompletionMessage()
]);
fclose($this->logFileHandle);
}
}
protected $deletedCount = 0;
protected $totalItems = 0;
public function startSync(string $syncType = 'full'): int
{
// Set higher limits for long-running syncs
@@ -79,7 +89,18 @@ abstract class BaseSyncService
try {
$this->logProgress("Starting {$syncType} sync for source: " . ($this->source['display_name'] ?? $this->source['name']));
$this->executeSync($syncType);
// Execute cleanup if requested
if ($syncType === 'cleanup') {
$this->executeCleanup();
} else {
$this->executeSync($syncType);
// Optionally run cleanup after regular sync
if (in_array($syncType, ['full', 'incremental'])) {
$this->logProgress("Running cleanup after sync...");
$this->executeCleanup();
}
}
// Update sync log as completed
$this->updateSyncLog($syncLogId, 'completed', [
@@ -87,10 +108,12 @@ abstract class BaseSyncService
'new_items' => $this->getNewCount(),
'updated_items' => $this->getUpdatedCount(),
'deleted_items' => $this->getDeletedCount(),
'message' => "Successfully completed sync"
'total_items' => $this->getTotalItems(),
'message' => $this->getCompletionMessage()
]);
$this->logProgress("Sync completed successfully");
// Log completion to file but don't update database (already completed above)
$this->logProgressToFile("Sync completed successfully");
} catch (Exception $e) {
// Log the full error details
@@ -119,6 +142,12 @@ abstract class BaseSyncService
return $syncLogId;
}
protected function executeCleanup(): void
{
// Override in subclasses to implement cleanup logic
$this->logProgress("Cleanup not implemented for this sync service");
}
private function createSyncLog(string $syncType, string $status): int
{
$data = [
@@ -162,6 +191,10 @@ abstract class BaseSyncService
$data['message'] = $stats['message'];
}
if (isset($stats['total_items'])) {
$data['total_items'] = $stats['total_items'];
}
$setClause = array_map(fn($col) => "$col = :$col", array_keys($data));
$sql = "UPDATE sync_logs SET " . implode(', ', $setClause) . " WHERE id = :id";
$data['id'] = $syncLogId;
@@ -189,11 +222,53 @@ abstract class BaseSyncService
protected function getDeletedCount(): int
{
return 0; // Override in subclasses
return $this->deletedCount; // Return the deleted count
}
protected function getTotalItems(): int
{
return $this->totalItems;
}
protected function setTotalItems(int $total): void
{
$this->totalItems = $total;
}
protected function getCompletionMessage(): string
{
$new = $this->getNewCount();
$updated = $this->getUpdatedCount();
$deleted = $this->getDeletedCount();
$message = "Sync completed";
if ($new > 0 || $updated > 0 || $deleted > 0) {
$parts = [];
if ($new > 0) $parts[] = "{$new} new";
if ($updated > 0) $parts[] = "{$updated} updated";
if ($deleted > 0) $parts[] = "{$deleted} deleted";
$message .= ": " . implode(", ", $parts);
}
return $message;
}
protected $currentSyncLogId = null;
protected function logProgressToFile(string $message): void
{
$timestamp = date('H:i:s');
$logMessage = "[{$timestamp}] {$message}\n";
// Write to log file if available
if ($this->logFileHandle) {
fwrite($this->logFileHandle, $logMessage);
}
// Also write to error log for immediate visibility
error_log($message);
}
protected function logProgress(string $message): void
{
$timestamp = date('H:i:s');
@@ -209,9 +284,23 @@ abstract class BaseSyncService
// Update sync log with progress message if we have a current sync log
if ($this->currentSyncLogId) {
$this->updateSyncLog($this->currentSyncLogId, 'running', [
'message' => $message
]);
$updateData = [
'message' => $message,
'processed_items' => $this->getProcessedCount(),
'new_items' => $this->getNewCount(),
'updated_items' => $this->getUpdatedCount(),
'deleted_items' => $this->getDeletedCount()
];
// Only update total_items if it's greater than 0 (to avoid overwriting with 0)
if ($this->getTotalItems() > 0) {
$updateData['total_items'] = $this->getTotalItems();
}
// Don't update status for completion messages - status should remain as set by completion logic
$newStatus = 'running';
$this->updateSyncLog($this->currentSyncLogId, $newStatus, $updateData);
}
}

View File

@@ -56,7 +56,9 @@ class JellyfinSyncService extends BaseSyncService
try {
$this->logProgress('Fetching movies from Jellyfin...');
$movies = $this->getJellyfinItems('Movie');
$this->logProgress("Found " . count($movies) . " movies in Jellyfin");
$movieCount = count($movies);
$this->setTotalItems($movieCount);
$this->logProgress("Found {$movieCount} movies in Jellyfin");
if (empty($movies)) {
$this->logProgress('No movies found in Jellyfin library');
@@ -118,7 +120,9 @@ class JellyfinSyncService extends BaseSyncService
$this->logProgress('=== Starting TV Shows Sync ===');
$this->logProgress('Fetching TV shows from Jellyfin...');
$tvShows = $this->getJellyfinItems('Series');
$this->logProgress("Found " . count($tvShows) . " TV shows in Jellyfin");
$tvShowCount = count($tvShows);
$this->setTotalItems($tvShowCount);
$this->logProgress("Found {$tvShowCount} TV shows in Jellyfin");
if (empty($tvShows)) {
$this->logProgress('No TV shows found in Jellyfin library');
@@ -131,7 +135,8 @@ class JellyfinSyncService extends BaseSyncService
foreach ($tvShows as $showData) {
$processedShows++;
$this->logProgress("Processing TV show {$processedShows}/" . count($tvShows) . ": {$showData['Name']} (ID: {$showData['Id']})");
$this->processedCount++; // Increment processed count for each TV show
$this->logProgress("Processing TV show {$processedShows}/{$tvShowCount}: {$showData['Name']} (ID: {$showData['Id']})");
try {
$this->syncTvShow($showData);
@@ -858,8 +863,196 @@ class JellyfinSyncService extends BaseSyncService
return $this->updatedCount;
}
protected function getDeletedCount(): int
protected function executeCleanup(): void
{
return 0; // Jellyfin doesn't provide deletion info in this context
$this->logProgress("Starting cleanup - detecting deleted media in Jellyfin...");
// Clean up movies
$this->cleanupMovies();
// Clean up TV shows and episodes
$this->cleanupTvShows();
$this->logProgress("Cleanup completed. Deleted {$this->deletedCount} items.");
}
private function cleanupMovies(): void
{
$this->logProgress("Checking for deleted movies...");
try {
// Get all movies from Jellyfin
$jellyfinMovies = $this->getJellyfinItems('Movie');
$jellyfinMovieIds = array_column($jellyfinMovies, 'Id');
$this->logProgress("Found " . count($jellyfinMovieIds) . " movies in Jellyfin");
// Get all movies from local database for this source
$stmt = $this->pdo->prepare("
SELECT id, metadata FROM movies WHERE source_id = :source_id
");
$stmt->execute(['source_id' => $this->source['id']]);
$localMovies = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$this->logProgress("Found " . count($localMovies) . " movies in local database");
$deletedCount = 0;
foreach ($localMovies as $localMovie) {
$metadata = json_decode($localMovie['metadata'], true);
$jellyfinId = $metadata['jellyfin_id'] ?? null;
if ($jellyfinId && !in_array($jellyfinId, $jellyfinMovieIds)) {
// Movie exists in local DB but not in Jellyfin - delete it
$this->deleteMovie($localMovie['id']);
$deletedCount++;
$this->deletedCount++;
}
}
$this->logProgress("Deleted {$deletedCount} movies that no longer exist in Jellyfin");
} catch (Exception $e) {
$this->logProgress("Error during movie cleanup: " . $e->getMessage());
}
}
private function cleanupTvShows(): void
{
$this->logProgress("Checking for deleted TV shows and episodes...");
try {
// Get all TV shows from Jellyfin
$jellyfinShows = $this->getJellyfinItems('Series');
$jellyfinShowIds = array_column($jellyfinShows, 'Id');
$this->logProgress("Found " . count($jellyfinShowIds) . " TV shows in Jellyfin");
// Get all TV shows from local database for this source
$stmt = $this->pdo->prepare("
SELECT id, metadata FROM tv_shows WHERE source_id = :source_id
");
$stmt->execute(['source_id' => $this->source['id']]);
$localShows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$this->logProgress("Found " . count($localShows) . " TV shows in local database");
// Check for deleted shows
$deletedShows = 0;
foreach ($localShows as $localShow) {
$metadata = json_decode($localShow['metadata'], true);
$jellyfinId = $metadata['jellyfin_id'] ?? null;
if ($jellyfinId && !in_array($jellyfinId, $jellyfinShowIds)) {
// Show exists in local DB but not in Jellyfin - delete it
$this->deleteTvShow($localShow['id']);
$deletedShows++;
$this->deletedCount++;
}
}
$this->logProgress("Deleted {$deletedShows} TV shows that no longer exist in Jellyfin");
// Also clean up episodes that might be orphaned (show deleted but episodes remain)
$this->cleanupOrphanedEpisodes();
} catch (Exception $e) {
$this->logProgress("Error during TV show cleanup: " . $e->getMessage());
}
}
private function cleanupOrphanedEpisodes(): void
{
$this->logProgress("Checking for orphaned episodes...");
try {
// Get all episode IDs that belong to shows from this source
$stmt = $this->pdo->prepare("
SELECT te.id, te.metadata, ts.metadata as show_metadata
FROM tv_episodes te
JOIN tv_shows ts ON te.tv_show_id = ts.id
WHERE ts.source_id = :source_id
");
$stmt->execute(['source_id' => $this->source['id']]);
$episodes = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$deletedEpisodes = 0;
foreach ($episodes as $episode) {
$episodeMetadata = json_decode($episode['metadata'], true);
$showMetadata = json_decode($episode['show_metadata'], true);
$episodeJellyfinId = $episodeMetadata['jellyfin_id'] ?? null;
$showJellyfinId = $showMetadata['jellyfin_id'] ?? null;
// If either the episode or its parent show doesn't exist in Jellyfin, delete the episode
if (!$episodeJellyfinId || !$showJellyfinId) {
$this->deleteTvEpisode($episode['id']);
$deletedEpisodes++;
$this->deletedCount++;
}
}
if ($deletedEpisodes > 0) {
$this->logProgress("Deleted {$deletedEpisodes} orphaned episodes");
}
} catch (Exception $e) {
$this->logProgress("Error during orphaned episode cleanup: " . $e->getMessage());
}
}
private function deleteMovie(int $movieId): void
{
try {
// Delete actor relationships first
$stmt = $this->pdo->prepare("DELETE FROM actor_movie WHERE movie_id = :movie_id");
$stmt->execute(['movie_id' => $movieId]);
// Delete the movie
$stmt = $this->pdo->prepare("DELETE FROM movies WHERE id = :id");
$stmt->execute(['id' => $movieId]);
$this->logProgress("Deleted movie with ID: {$movieId}");
} catch (Exception $e) {
$this->logProgress("Error deleting movie {$movieId}: " . $e->getMessage());
}
}
private function deleteTvShow(int $showId): void
{
try {
// Delete actor relationships first
$stmt = $this->pdo->prepare("DELETE FROM actor_tv_show WHERE tv_show_id = :tv_show_id");
$stmt->execute(['tv_show_id' => $showId]);
// Delete episodes (which will also delete episode-actor relationships via CASCADE)
$stmt = $this->pdo->prepare("DELETE FROM tv_episodes WHERE tv_show_id = :tv_show_id");
$stmt->execute(['tv_show_id' => $showId]);
// Delete the show
$stmt = $this->pdo->prepare("DELETE FROM tv_shows WHERE id = :id");
$stmt->execute(['id' => $showId]);
$this->logProgress("Deleted TV show with ID: {$showId}");
} catch (Exception $e) {
$this->logProgress("Error deleting TV show {$showId}: " . $e->getMessage());
}
}
private function deleteTvEpisode(int $episodeId): void
{
try {
// Delete actor relationships first
$stmt = $this->pdo->prepare("DELETE FROM actor_tv_episode WHERE tv_episode_id = :tv_episode_id");
$stmt->execute(['tv_episode_id' => $episodeId]);
// Delete the episode
$stmt = $this->pdo->prepare("DELETE FROM tv_episodes WHERE id = :id");
$stmt->execute(['id' => $episodeId]);
$this->logProgress("Deleted TV episode with ID: {$episodeId}");
} catch (Exception $e) {
$this->logProgress("Error deleting TV episode {$episodeId}: " . $e->getMessage());
}
}
}

View File

@@ -750,8 +750,112 @@ class StashSyncService extends BaseSyncService
}
}
protected function getDeletedCount(): int
protected function executeCleanup(): void
{
return 0; // Stash doesn't provide deletion info in this context
$this->logProgress("Starting cleanup - detecting deleted media in Stash...");
// Clean up scenes
$this->cleanupScenes();
// Clean up movies
$this->cleanupMovies();
$this->logProgress("Cleanup completed. Deleted {$this->deletedCount} items.");
}
private function cleanupScenes(): void
{
$this->logProgress("Checking for deleted scenes...");
try {
// Get all scenes from Stash
$stashScenes = $this->getStashScenes(0, 1000); // Get up to 1000 scenes for cleanup
$stashSceneIds = array_column($stashScenes, 'id');
$this->logProgress("Found " . count($stashSceneIds) . " scenes in Stash");
// Get all scenes from local database for this source
$stmt = $this->pdo->prepare("
SELECT id, metadata FROM adult_videos WHERE source_id = :source_id
");
$stmt->execute(['source_id' => $this->source['id']]);
$localScenes = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$this->logProgress("Found " . count($localScenes) . " scenes in local database");
$deletedCount = 0;
foreach ($localScenes as $localScene) {
$metadata = json_decode($localScene['metadata'], true);
$stashId = $metadata['stash_id'] ?? null;
if ($stashId && !in_array($stashId, $stashSceneIds)) {
// Scene exists in local DB but not in Stash - delete it
$this->deleteAdultVideo($localScene['id']);
$deletedCount++;
$this->deletedCount++;
}
}
$this->logProgress("Deleted {$deletedCount} scenes that no longer exist in Stash");
} catch (Exception $e) {
$this->logProgress("Error during scene cleanup: " . $e->getMessage());
}
}
private function cleanupMovies(): void
{
$this->logProgress("Checking for deleted movies...");
try {
// Get all movies from Stash
$stashMovies = $this->getStashMovies();
$stashMovieIds = array_column($stashMovies, 'id');
$this->logProgress("Found " . count($stashMovieIds) . " movies in Stash");
// Get all movies from local database for this source
$stmt = $this->pdo->prepare("
SELECT id, metadata FROM adult_videos WHERE source_id = :source_id
");
$stmt->execute(['source_id' => $this->source['id']]);
$localVideos = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$this->logProgress("Found " . count($localVideos) . " videos in local database");
$deletedCount = 0;
foreach ($localVideos as $localVideo) {
$metadata = json_decode($localVideo['metadata'], true);
$stashMovieId = $metadata['stash_movie_id'] ?? null;
if ($stashMovieId && !in_array($stashMovieId, $stashMovieIds)) {
// Movie exists in local DB but not in Stash - delete it
$this->deleteAdultVideo($localVideo['id']);
$deletedCount++;
$this->deletedCount++;
}
}
$this->logProgress("Deleted {$deletedCount} movies that no longer exist in Stash");
} catch (Exception $e) {
$this->logProgress("Error during movie cleanup: " . $e->getMessage());
}
}
private function deleteAdultVideo(int $videoId): void
{
try {
// Delete actor relationships first
$stmt = $this->pdo->prepare("DELETE FROM actor_adult_video WHERE adult_video_id = :adult_video_id");
$stmt->execute(['adult_video_id' => $videoId]);
// Delete the video
$stmt = $this->pdo->prepare("DELETE FROM adult_videos WHERE id = :id");
$stmt->execute(['id' => $videoId]);
$this->logProgress("Deleted adult video with ID: {$videoId}");
} catch (Exception $e) {
$this->logProgress("Error deleting adult video {$videoId}: " . $e->getMessage());
}
}
}

View File

@@ -493,9 +493,91 @@ class XbvrSyncService extends BaseSyncService
return $this->updatedCount;
}
protected function getDeletedCount(): int
protected function executeCleanup(): void
{
return 0; // XBVR doesn't provide deletion info in this context
$this->logProgress("Starting cleanup - detecting deleted VR scenes in XBVR...");
// Clean up VR scenes
$this->cleanupScenes();
$this->logProgress("Cleanup completed. Deleted {$this->deletedCount} VR scenes.");
}
private function cleanupScenes(): void
{
$this->logProgress("Checking for deleted VR scenes...");
try {
// Get all scenes from XBVR
$xbvrScenes = $this->getXbvrScenesForCleanup();
$xbvrSceneIds = array_column($xbvrScenes, 'id');
$this->logProgress("Found " . count($xbvrSceneIds) . " VR scenes in XBVR");
// Get all scenes from local database for this source
$stmt = $this->pdo->prepare("
SELECT id, metadata FROM adult_videos WHERE source_id = :source_id
");
$stmt->execute(['source_id' => $this->source['id']]);
$localScenes = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$this->logProgress("Found " . count($localScenes) . " VR scenes in local database");
$deletedCount = 0;
foreach ($localScenes as $localScene) {
$metadata = json_decode($localScene['metadata'], true);
$xbvrId = $metadata['xbvr_id'] ?? null;
if ($xbvrId && !in_array($xbvrId, $xbvrSceneIds)) {
// Scene exists in local DB but not in XBVR - delete it
$this->deleteAdultVideo($localScene['id']);
$deletedCount++;
$this->deletedCount++;
}
}
$this->logProgress("Deleted {$deletedCount} VR scenes that no longer exist in XBVR");
} catch (Exception $e) {
$this->logProgress("Error during VR scene cleanup: " . $e->getMessage());
}
}
private function getXbvrScenesForCleanup(): array
{
try {
$response = $this->httpClient->get("{$this->baseUrl}/api/scene/list", [
'timeout' => 30,
'connect_timeout' => 10
]);
if ($response->getStatusCode() === 200) {
$data = json_decode($response->getBody(), true);
return $data['scenes'] ?? [];
}
return [];
} catch (Exception $e) {
$this->logProgress("Error fetching XBVR scenes: " . $e->getMessage());
return [];
}
}
private function deleteAdultVideo(int $videoId): void
{
try {
// Delete actor relationships first
$stmt = $this->pdo->prepare("DELETE FROM actor_adult_video WHERE adult_video_id = :adult_video_id");
$stmt->execute(['adult_video_id' => $videoId]);
// Delete the video
$stmt = $this->pdo->prepare("DELETE FROM adult_videos WHERE id = :id");
$stmt->execute(['id' => $videoId]);
$this->logProgress("Deleted adult video with ID: {$videoId}");
} catch (Exception $e) {
$this->logProgress("Error deleting adult video {$videoId}: " . $e->getMessage());
}
}
private function createActorRelationships(int $adultVideoId, array $actors): void