diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php index bd1aeef..aa3f235 100644 --- a/app/Controllers/AdminController.php +++ b/app/Controllers/AdminController.php @@ -54,7 +54,7 @@ class AdminController extends Controller // Validate sync type based on source type if ($source['name'] === 'jellyfin') { - $validSyncTypes = ['full', 'incremental', 'all', 'movies', 'tvshows']; + $validSyncTypes = ['full', 'incremental', 'all', 'movies', 'tvshows', 'cleanup']; if (!in_array($syncType, $validSyncTypes)) { return $this->json($response, [ 'success' => false, diff --git a/app/Controllers/TvShowController.php b/app/Controllers/TvShowController.php index e953810..91d9b6d 100644 --- a/app/Controllers/TvShowController.php +++ b/app/Controllers/TvShowController.php @@ -2,6 +2,7 @@ namespace App\Controllers; +use App\Models\TvShow; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Views\Twig; @@ -30,14 +31,16 @@ class TvShowController extends Controller // Get view mode $viewMode = $queryParams['view'] ?? 'grid'; // grid, list, covers - // For now, return empty arrays since TV Shows aren't implemented yet - $tvshows = []; - $totalCount = 0; + // Get TV shows with pagination and search + $tvshows = TvShow::getAllWithPagination($this->pdo, $page, $perPage, $search); + + // Get total count for pagination + $totalCount = TvShow::getTotalCount($this->pdo, $search); // Calculate pagination info - $totalPages = 0; - $hasNextPage = false; - $hasPrevPage = false; + $totalPages = ceil($totalCount / $perPage); + $hasNextPage = $page < $totalPages; + $hasPrevPage = $page > 1; return $this->view->render($response, 'tvshows/index.twig', [ 'title' => 'TV Shows', @@ -91,31 +94,9 @@ class TvShowController extends Controller "); $stmt->execute(['tv_show_id' => $tvShowId]); $actors = $stmt->fetchAll(\PDO::FETCH_ASSOC); -/* - // Get seasons for this TV show - $stmt = $this->pdo->prepare(" - SELECT * FROM tv_seasons - WHERE tv_show_id = :tv_show_id - ORDER BY season_number ASC - "); - $stmt->execute(['tv_show_id' => $tvShowId]); - $seasons = $stmt->fetchAll(\PDO::FETCH_ASSOC); -*//* - // Get episodes for each season - foreach ($seasons as &$season) { - $stmt = $this->pdo->prepare(" - SELECT * FROM tv_episodes - WHERE tv_show_id = :tv_show_id AND season_number = :season_number - ORDER BY episode_number ASC - "); - $stmt->execute([ - 'tv_show_id' => $tvShowId, - 'season_number' => $season['season_number'] - ]); - $season['episodes'] = $stmt->fetchAll(\PDO::FETCH_ASSOC); - } - unset($season); // Unset reference -*/ + // Get seasons and episodes for this TV show + $tvShowModel = new TvShow($this->pdo, $tvShow); + $seasons = $tvShowModel->getSeasonsWithEpisodes(); return $this->view->render($response, 'tvshows/show.twig', [ 'title' => $tvShow['title'], 'tvshow' => $tvShow, diff --git a/app/Services/BaseSyncService.php b/app/Services/BaseSyncService.php index ef72d6a..c01c2ed 100644 --- a/app/Services/BaseSyncService.php +++ b/app/Services/BaseSyncService.php @@ -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); } } diff --git a/app/Services/JellyfinSyncService.php b/app/Services/JellyfinSyncService.php index 8ad2356..c9b371a 100644 --- a/app/Services/JellyfinSyncService.php +++ b/app/Services/JellyfinSyncService.php @@ -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()); + } } } diff --git a/app/Services/StashSyncService.php b/app/Services/StashSyncService.php index 130420f..69ae9c4 100644 --- a/app/Services/StashSyncService.php +++ b/app/Services/StashSyncService.php @@ -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()); + } } } diff --git a/app/Services/XbvrSyncService.php b/app/Services/XbvrSyncService.php index 83c03a4..41f47d6 100644 --- a/app/Services/XbvrSyncService.php +++ b/app/Services/XbvrSyncService.php @@ -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 diff --git a/resources/views/admin/index.twig b/resources/views/admin/index.twig index 3415b9d..a17ee0d 100644 --- a/resources/views/admin/index.twig +++ b/resources/views/admin/index.twig @@ -34,37 +34,49 @@