httpClient = new Client([ 'timeout' => 30, 'headers' => [ 'User-Agent' => 'MediaCollector/1.0', 'X-MediaBrowser-Token' => $source['api_key'] ] ]); $this->apiKey = $source['api_key']; $this->baseUrl = rtrim($source['api_url'], '/'); } protected function executeSync(string $syncType = 'all'): void { if (empty($this->apiKey) || empty($this->baseUrl)) { throw new Exception('Jellyfin API key and URL not configured'); } $this->logProgress('Starting Jellyfin library sync...'); $this->logProgress("Jellyfin URL: {$this->baseUrl}"); $this->logProgress("API Key: " . (empty($this->apiKey) ? 'NOT SET' : 'SET')); $this->logProgress("Sync Type: {$syncType}"); try { $userId = $this->getUserId(); $this->logProgress("User ID: {$userId}"); } catch (Exception $e) { $this->logProgress('Error getting user ID: ' . $e->getMessage()); throw $e; } // Sync movies if requested if (in_array($syncType, ['all', 'movies'])) { try { $this->logProgress('Fetching movies from Jellyfin...'); $movies = $this->getJellyfinItems('Movie'); $movieCount = count($movies); $this->setTotalItems($movieCount); $this->logProgress("Found {$movieCount} movies in Jellyfin"); if (empty($movies)) { $this->logProgress('No movies found in Jellyfin library'); } else { foreach ($movies as $movieData) { $this->syncMovie($movieData); $this->processedCount++; } $this->logProgress("Successfully processed {$this->processedCount} movies"); } } catch (Exception $e) { $this->logProgress('Error syncing movies: ' . $e->getMessage()); if ($syncType === 'movies') { throw $e; } } } else { $this->logProgress('Skipping movies sync (sync type: ' . $syncType . ')'); } // Sync TV shows and episodes if requested if (in_array($syncType, ['all', 'tvshows'])) { try { $this->syncTvShows(); } catch (Exception $e) { $this->logProgress('Error syncing TV shows: ' . $e->getMessage()); if ($syncType === 'tvshows') { throw $e; } } } else { $this->logProgress('Skipping TV shows sync (sync type: ' . $syncType . ')'); } // Sync music (artists, albums, tracks) - TODO: Implement when music models are created // $this->syncMusic(); $this->logProgress("Processed {$this->processedCount} items"); } private function syncMovies(): void { try { $movies = $this->getJellyfinItems('Movie'); foreach ($movies as $movieData) { $this->syncMovie($movieData); $this->processedCount++; } } catch (Exception $e) { $this->logProgress('Error syncing movies: ' . $e->getMessage()); } } private function syncTvShows(): void { try { $this->logProgress('=== Starting TV Shows Sync ==='); $this->logProgress('Fetching TV shows from Jellyfin...'); $tvShows = $this->getJellyfinItems('Series'); $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'); return; } $processedShows = 0; $successfulShows = 0; $failedShows = 0; foreach ($tvShows as $showData) { $processedShows++; $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); $successfulShows++; $this->logProgress("✓ Successfully synced TV show: {$showData['Name']}"); } catch (Exception $e) { $failedShows++; $this->logProgress("✗ Failed to sync TV show {$showData['Name']}: " . $e->getMessage()); $this->logProgress("Stack trace: " . $e->getTraceAsString()); } } $this->logProgress("=== TV Shows Sync Summary ==="); $this->logProgress("Processed: {$processedShows}, Successful: {$successfulShows}, Failed: {$failedShows}"); $this->logProgress("Successfully processed {$this->processedCount} TV shows"); } catch (Exception $e) { $this->logProgress('CRITICAL ERROR in TV shows sync: ' . $e->getMessage()); $this->logProgress('Stack trace: ' . $e->getTraceAsString()); throw $e; } } private function getJellyfinItems(string $type): array { try { $url = "{$this->baseUrl}/Users/{$this->getUserId()}/Items"; $this->logProgress("Fetching {$type} from: {$url}"); $response = $this->httpClient->get($url, [ 'query' => [ 'IncludeItemTypes' => $type, 'Recursive' => 'true', 'Fields' => 'ProviderIds,Overview,PremiereDate,CommunityRating,OfficialRating,Genres,Studios,RunTimeTicks' ] ]); $httpCode = $response->getStatusCode(); $this->logProgress("HTTP Response Code: {$httpCode}"); if ($httpCode !== 200) { throw new Exception("Jellyfin API returned HTTP {$httpCode}"); } $data = json_decode($response->getBody(), true); $itemCount = count($data['Items'] ?? []); $this->logProgress("Successfully fetched {$itemCount} {$type} items"); return $data['Items'] ?? []; } catch (Exception $e) { $this->logProgress('Failed to fetch Jellyfin items: ' . $e->getMessage()); throw new Exception('Failed to fetch Jellyfin items: ' . $e->getMessage()); } } private function getUserId(): string { try { $url = "{$this->baseUrl}/Users"; $this->logProgress("Getting user ID from: {$url}"); $response = $this->httpClient->get($url); $httpCode = $response->getStatusCode(); if ($httpCode !== 200) { throw new Exception("Jellyfin Users API returned HTTP {$httpCode}"); } $data = json_decode($response->getBody(), true); if (empty($data) || !isset($data[0]['Id'])) { throw new Exception('No users found in Jellyfin or invalid response format'); } $userId = $data[0]['Id']; $this->logProgress("Using Jellyfin user ID: {$userId}"); return $userId; } catch (Exception $e) { $this->logProgress('Failed to get Jellyfin user ID: ' . $e->getMessage()); throw new Exception('Failed to get Jellyfin user ID: ' . $e->getMessage()); } } private function syncMovie(array $movieData): void { $movieModel = new Movie($this->pdo); // Check if movie already exists $existingMovie = $movieModel->findAll([ 'tmdb_id' => $movieData['ProviderIds']['Tmdb'] ?? null, 'imdb_id' => $movieData['ProviderIds']['Imdb'] ?? null, 'source_id' => $this->source['id'] ]); $movieDataForDb = [ 'title' => $movieData['Name'], 'overview' => $movieData['Overview'] ?? null, 'release_date' => $movieData['PremiereDate'] ? date('Y-m-d', strtotime($movieData['PremiereDate'])) : null, 'runtime_minutes' => $movieData['RunTimeTicks'] ? intval($movieData['RunTimeTicks'] / (10000000 * 60)) : null, 'rating' => $movieData['CommunityRating'] ?? null, 'imdb_id' => $movieData['ProviderIds']['Imdb'] ?? null, 'tmdb_id' => $movieData['ProviderIds']['Tmdb'] ?? null, 'source_id' => $this->source['id'], 'metadata' => json_encode([ 'jellyfin_id' => $movieData['Id'], 'genres' => $movieData['Genres'] ?? [], 'studios' => $movieData['Studios'] ?? [], 'poster_url' => $this->getImageUrl($movieData['Id'], 'Primary'), 'backdrop_url' => $this->getImageUrl($movieData['Id'], 'Backdrop') ]) ]; // Download poster image $posterPath = $this->downloadPosterImage($movieData['Id'], $movieData['Name']); if ($posterPath) { $movieDataForDb['poster_url'] = $posterPath; } else { $movieDataForDb['poster_url'] = $this->getImageUrl($movieData['Id'], 'Primary'); } // Download backdrop image $backdropPath = $this->downloadBackdropImage($movieData['Id'], $movieData['Name']); if ($backdropPath) { $movieDataForDb['backdrop_url'] = $backdropPath; } else { $movieDataForDb['backdrop_url'] = $this->getImageUrl($movieData['Id'], 'Backdrop'); } if (empty($existingMovie)) { $movieModel->create($movieDataForDb); $this->newCount++; } else { $movieModel->update($existingMovie[0]['id'], $movieDataForDb); $this->updatedCount++; } // Sync actors for this movie and create relationships try { $actors = $this->syncActors($movieData); $this->createMovieActorRelationships($existingMovie ? $existingMovie[0]['id'] : $this->pdo->lastInsertId(), $actors); } catch (Exception $e) { $this->logProgress("Warning: Failed to sync actors for movie {$movieData['Name']}: " . $e->getMessage()); } } private function syncTvShow(array $showData): void { $showName = $showData['Name'] ?? 'Unknown Show'; $this->logProgress("--- Starting sync for TV show: {$showName} ---"); $showModel = new TvShow($this->pdo); // Check if show already exists $this->logProgress("Checking if TV show already exists in database..."); $existingShow = $showModel->findAll([ 'tmdb_id' => $showData['ProviderIds']['Tmdb'] ?? null, 'imdb_id' => $showData['ProviderIds']['Imdb'] ?? null, 'tvdb_id' => $showData['ProviderIds']['Tvdb'] ?? null, 'source_id' => $this->source['id'] ]); $this->logProgress("Found " . count($existingShow) . " existing TV show(s) in database"); // Prepare show data for database $this->logProgress("Preparing TV show data for database..."); $showDataForDb = [ 'title' => $showData['Name'], 'overview' => $showData['Overview'] ?? null, 'first_air_date' => $showData['PremiereDate'] ? date('Y-m-d', strtotime($showData['PremiereDate'])) : null, 'rating' => $showData['CommunityRating'] ?? null, 'imdb_id' => $showData['ProviderIds']['Imdb'] ?? null, 'tmdb_id' => $showData['ProviderIds']['Tmdb'] ?? null, 'tvdb_id' => $showData['ProviderIds']['Tvdb'] ?? null, 'source_id' => $this->source['id'], 'metadata' => json_encode([ 'jellyfin_id' => $showData['Id'], 'genres' => $showData['Genres'] ?? [] ]) ]; // Download poster image $this->logProgress("Downloading poster image for {$showName}..."); $posterPath = $this->downloadPosterImage($showData['Id'], $showData['Name']); if ($posterPath) { $showDataForDb['poster_url'] = $posterPath; $this->logProgress("✓ Poster downloaded successfully: {$posterPath}"); } else { $showDataForDb['poster_url'] = $this->getImageUrl($showData['Id'], 'Primary'); $this->logProgress("⚠ Poster download failed, using URL instead"); } // Download backdrop image $this->logProgress("Downloading backdrop image for {$showName}..."); $backdropPath = $this->downloadBackdropImage($showData['Id'], $showData['Name']); if ($backdropPath) { $showDataForDb['backdrop_url'] = $backdropPath; $this->logProgress("✓ Backdrop downloaded successfully: {$backdropPath}"); } else { $showDataForDb['backdrop_url'] = $this->getImageUrl($showData['Id'], 'Backdrop'); $this->logProgress("⚠ Backdrop download failed, using URL instead"); } try { if (empty($existingShow)) { $this->logProgress("Creating new TV show in database..."); $showId = $showModel->create($showDataForDb); $this->newCount++; $this->logProgress("✓ Created new TV show with ID: {$showId}"); } else { $showId = $existingShow[0]['id']; $this->logProgress("Updating existing TV show (ID: {$showId})..."); $showModel->update($showId, $showDataForDb); $this->updatedCount++; $this->logProgress("✓ Updated existing TV show"); } } catch (Exception $e) { $this->logProgress("✗ Failed to save TV show {$showName} to database: " . $e->getMessage()); throw $e; } // Sync actors for this show and create relationships try { $this->logProgress("Syncing actors for {$showName}..."); $actors = $this->syncActors($showData); $this->logProgress("Found " . count($actors) . " actors for {$showName}"); $this->createShowActorRelationships($showId, $actors); $this->logProgress("✓ Actor relationships created for {$showName}"); } catch (Exception $e) { $this->logProgress("Warning: Failed to sync actors for TV show {$showName}: " . $e->getMessage()); } // Sync episodes for this show try { $this->logProgress("Syncing episodes for {$showName}..."); $this->syncEpisodes($showId, $showData['Id']); $this->logProgress("✓ Episodes sync completed for {$showName}"); } catch (Exception $e) { $this->logProgress("✗ Failed to sync episodes for {$showName}: " . $e->getMessage()); $this->logProgress("Stack trace: " . $e->getTraceAsString()); } $this->logProgress("--- Completed sync for TV show: {$showName} ---"); } private function syncEpisodes(int $showId, string $jellyfinShowId): void { try { $this->logProgress("=== Starting episodes sync for show ID: {$jellyfinShowId} ==="); $episodes = $this->getShowEpisodes($jellyfinShowId); $episodeCount = count($episodes); $this->logProgress("Found {$episodeCount} episodes for show ID: {$jellyfinShowId}"); if (empty($episodes)) { $this->logProgress("No episodes found for show ID: {$jellyfinShowId}"); return; } $processedEpisodes = 0; $successfulEpisodes = 0; $failedEpisodes = 0; foreach ($episodes as $episodeData) { $processedEpisodes++; $episodeName = $episodeData['Name'] ?? 'Unknown Episode'; $this->logProgress("Processing episode {$processedEpisodes}/{$episodeCount}: {$episodeName}"); try { $this->syncEpisode($showId, $episodeData); $successfulEpisodes++; $this->logProgress("✓ Successfully synced episode: {$episodeName}"); } catch (Exception $e) { $failedEpisodes++; $this->logProgress("✗ Failed to sync episode {$episodeName}: " . $e->getMessage()); $this->logProgress("Stack trace: " . $e->getTraceAsString()); } } $this->logProgress("=== Episodes Sync Summary ==="); $this->logProgress("Processed: {$processedEpisodes}, Successful: {$successfulEpisodes}, Failed: {$failedEpisodes}"); $this->logProgress("Successfully processed {$this->processedCount} episodes"); } catch (Exception $e) { $this->logProgress('CRITICAL ERROR in episodes sync: ' . $e->getMessage()); $this->logProgress('Stack trace: ' . $e->getTraceAsString()); throw $e; } } private function syncEpisode(int $showId, array $episodeData): void { $episodeName = $episodeData['Name'] ?? 'Unknown Episode'; $episodeSeason = $episodeData['ParentIndexNumber'] ?? 1; $episodeNumber = $episodeData['IndexNumber'] ?? 1; $this->logProgress("--- Starting sync for episode: S{$episodeSeason}E{$episodeNumber} - {$episodeName} ---"); $episodeModel = new TvEpisode($this->pdo); // Check if episode already exists by jellyfin_id in metadata $this->logProgress("Checking if episode already exists in database..."); $stmt = $this->pdo->prepare(" SELECT id, metadata FROM tv_episodes WHERE tv_show_id = :tv_show_id AND source_id = :source_id "); $stmt->execute([ 'tv_show_id' => $showId, 'source_id' => $this->source['id'] ]); $existingEpisodes = $stmt->fetchAll(\PDO::FETCH_ASSOC); $this->logProgress("Found " . count($existingEpisodes) . " existing episodes for this show"); $existingEpisode = null; foreach ($existingEpisodes as $episode) { $metadata = json_decode($episode['metadata'], true); if (isset($metadata['jellyfin_id']) && $metadata['jellyfin_id'] === $episodeData['Id']) { $existingEpisode = $episode; $this->logProgress("Found existing episode with Jellyfin ID: {$episodeData['Id']}"); break; } } if (!$existingEpisode) { $this->logProgress("Episode not found, will create new episode"); } // Prepare episode data for database $this->logProgress("Preparing episode data for database..."); $episodeDataForDb = [ 'title' => $episodeData['Name'], 'overview' => $episodeData['Overview'] ?? null, 'season_number' => $episodeSeason, 'episode_number' => $episodeNumber, 'air_date' => $episodeData['PremiereDate'] ? date('Y-m-d', strtotime($episodeData['PremiereDate'])) : null, 'runtime_minutes' => $episodeData['RunTimeTicks'] ? intval($episodeData['RunTimeTicks'] / (10000000 * 60)) : null, 'rating' => $episodeData['CommunityRating'] ?? null, 'tv_show_id' => $showId, 'source_id' => $this->source['id'], 'metadata' => json_encode([ 'jellyfin_id' => $episodeData['Id'], 'tmdb_id' => $episodeData['ProviderIds']['Tmdb'] ?? null, 'imdb_id' => $episodeData['ProviderIds']['Imdb'] ?? null, 'tvdb_id' => $episodeData['ProviderIds']['Tvdb'] ?? null // Note: Episodes don't have dedicated provider ID columns in the database, // so we store them in metadata for reference ]) ]; try { if ($existingEpisode) { $this->logProgress("Updating existing episode in database..."); $episodeModel->update($existingEpisode['id'], $episodeDataForDb); $episodeId = $existingEpisode['id']; $this->updatedCount++; $this->logProgress("✓ Updated episode: {$episodeName}"); } else { $this->logProgress("Creating new episode in database..."); $episodeModel->create($episodeDataForDb); $episodeId = $this->pdo->lastInsertId(); $this->newCount++; $this->logProgress("✓ Created new episode with ID: {$episodeId}"); } } catch (Exception $e) { $this->logProgress("✗ Failed to save episode {$episodeName} to database: " . $e->getMessage()); throw $e; } // Sync actors for this episode and create relationships try { $this->logProgress("Syncing actors for episode {$episodeName}..."); $actors = $this->syncActors($episodeData); $this->logProgress("Found " . count($actors) . " actors for episode {$episodeName}"); $this->createActorRelationships($episodeId, $actors); $this->logProgress("✓ Actor relationships created for episode {$episodeName}"); } catch (Exception $e) { $this->logProgress("Warning: Failed to sync actors for episode {$episodeName}: " . $e->getMessage()); } $this->logProgress("--- Completed sync for episode: {$episodeName} ---"); } private function getShowEpisodes(string $jellyfinShowId): array { $this->logProgress("--- Fetching episodes for show ID: {$jellyfinShowId} ---"); try { $url = "{$this->baseUrl}/Shows/{$jellyfinShowId}/Episodes"; $this->logProgress("Fetching episodes from Jellyfin API: {$url}"); $response = $this->httpClient->get($url, [ 'query' => [ 'Fields' => 'ProviderIds,Overview,PremiereDate,CommunityRating,OfficialRating,RunTimeTicks,People' ] ]); $httpCode = $response->getStatusCode(); $this->logProgress("Jellyfin API response code: {$httpCode}"); if ($httpCode !== 200) { $errorMsg = "Jellyfin Episodes API returned HTTP {$httpCode}"; $this->logProgress("✗ {$errorMsg}"); throw new Exception($errorMsg); } $data = json_decode($response->getBody(), true); $episodeCount = count($data['Items'] ?? []); $this->logProgress("✓ Successfully fetched {$episodeCount} episodes from Jellyfin"); if ($episodeCount === 0) { $this->logProgress("⚠ No episodes found in Jellyfin response"); } $episodes = $data['Items'] ?? []; // Log episode details for debugging if (!empty($episodes)) { $this->logProgress("Episode details:"); foreach ($episodes as $index => $episode) { $episodeName = $episode['Name'] ?? 'Unknown'; $episodeId = $episode['Id'] ?? 'No ID'; $season = $episode['ParentIndexNumber'] ?? 'No season'; $episodeNum = $episode['IndexNumber'] ?? 'No number'; $this->logProgress(" " . ($index + 1) . ". {$episodeName} (S{$season}E{$episodeNum}) - ID: {$episodeId}"); } } return $episodes; } catch (Exception $e) { $this->logProgress('✗ Failed to fetch episodes: ' . $e->getMessage()); $this->logProgress('Stack trace: ' . $e->getTraceAsString()); return []; } } private function syncActors(array $mediaData): array { // Jellyfin doesn't have a direct actors API, so we extract from media data // This is a simplified implementation - in a full implementation, // you'd need to fetch detailed cast information from Jellyfin $cast = []; // Try to extract cast information from different fields if (isset($mediaData['People']) && is_array($mediaData['People'])) { foreach ($mediaData['People'] as $person) { if (isset($person['Type']) && $person['Type'] === 'Actor') { $cast[] = [ 'name' => $person['Name'], 'jellyfin_id' => $person['Id'] ?? null, 'image_url' => isset($person['PrimaryImageTag']) ? $this->getActorImageUrl($person['Id'], $person['PrimaryImageTag']) : null ]; } } } // If no cast found in People array, try other fields if (empty($cast)) { if (isset($mediaData['Cast']) && is_array($mediaData['Cast'])) { foreach ($mediaData['Cast'] as $actorName) { if (empty($actorName)) continue; $cast[] = [ 'name' => $actorName, 'jellyfin_id' => null, 'image_url' => null ]; } } elseif (isset($mediaData['Actors']) && is_array($mediaData['Actors'])) { foreach ($mediaData['Actors'] as $actorName) { if (empty($actorName)) continue; $cast[] = [ 'name' => $actorName, 'jellyfin_id' => null, 'image_url' => null ]; } } } // Create/sync actors and return actor objects $actors = []; foreach ($cast as $actorData) { if (empty($actorData['name'])) continue; $actor = $this->getOrCreateActor($actorData['name'], $actorData['jellyfin_id'], $actorData['image_url']); if ($actor) { $actors[] = $actor; } } return $actors; } private function getOrCreateActor(string $name, ?string $jellyfinId = null, ?string $imageUrl = null): ?array { try { // 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) { // Update thumbnail if we have a new image URL and no existing thumbnail if ($imageUrl && empty($existingActor['thumbnail_path'])) { $thumbnailPath = $this->downloadImage($imageUrl, 'actors', $name); if ($thumbnailPath) { try { $updateStmt = $this->pdo->prepare(' UPDATE actors SET thumbnail_path = :thumbnail_path, updated_at = NOW() WHERE id = :id '); $updateStmt->execute([ 'thumbnail_path' => $thumbnailPath, 'id' => $existingActor['id'] ]); $existingActor['thumbnail_path'] = $thumbnailPath; } catch (Exception $e) { $this->logProgress("Warning: Could not update thumbnail for existing actor {$name}: " . $e->getMessage()); } } } return [ 'id' => $existingActor['id'], 'name' => $existingActor['name'], 'thumbnail_path' => $existingActor['thumbnail_path'] ]; } // Create new actor $thumbnailPath = null; if ($imageUrl) { $thumbnailPath = $this->downloadImage($imageUrl, 'actors', $name); } $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/find actor {$name}: " . $e->getMessage()); return null; } } private function createActorRelationships(int $episodeId, array $actors): void { foreach ($actors as $actor) { if (!isset($actor['id'])) continue; try { // Insert relationship into pivot table (ignore duplicates) $stmt = $this->pdo->prepare(" INSERT IGNORE INTO actor_tv_episode (tv_episode_id, actor_id, created_at, updated_at) VALUES (:tv_episode_id, :actor_id, NOW(), NOW()) "); $stmt->execute([ 'tv_episode_id' => $episodeId, 'actor_id' => $actor['id'] ]); $this->logProgress("Created relationship: TV Episode {$episodeId} -> Actor {$actor['name']} ({$actor['id']})"); } catch (Exception $e) { $this->logProgress("Failed to create relationship for TV Episode {$episodeId} and Actor {$actor['name']}: " . $e->getMessage()); } } } private function createShowActorRelationships(int $showId, array $actors): void { foreach ($actors as $actor) { if (!isset($actor['id'])) continue; try { // Insert relationship into pivot table (ignore duplicates) $stmt = $this->pdo->prepare(" INSERT IGNORE INTO actor_tv_show (tv_show_id, actor_id, created_at, updated_at) VALUES (:tv_show_id, :actor_id, NOW(), NOW()) "); $stmt->execute([ 'tv_show_id' => $showId, 'actor_id' => $actor['id'] ]); $this->logProgress("Created relationship: TV Show {$showId} -> Actor {$actor['name']} ({$actor['id']})"); } catch (Exception $e) { $this->logProgress("Failed to create relationship for TV Show {$showId} and Actor {$actor['name']}: " . $e->getMessage()); } } } private function createMovieActorRelationships(int $movieId, array $actors): void { foreach ($actors as $actor) { if (!isset($actor['id'])) continue; try { // Insert relationship into pivot table (ignore duplicates) $stmt = $this->pdo->prepare(" INSERT IGNORE INTO actor_movie (movie_id, actor_id, created_at, updated_at) VALUES (:movie_id, :actor_id, NOW(), NOW()) "); $stmt->execute([ 'movie_id' => $movieId, 'actor_id' => $actor['id'] ]); $this->logProgress("Created relationship: Movie {$movieId} -> Actor {$actor['name']} ({$actor['id']})"); } catch (Exception $e) { $this->logProgress("Failed to create relationship for Movie {$movieId} and Actor {$actor['name']}: " . $e->getMessage()); } } } private function getImageUrl(string $itemId, string $type): ?string { if (empty($itemId)) { return null; } return "{$this->baseUrl}/Items/{$itemId}/Images/{$type}?maxWidth=400"; } private function getActorImageUrl(string $personId, string $imageTag): ?string { if (empty($personId) || empty($imageTag)) { return null; } // Ensure baseUrl doesn't have trailing slash $baseUrl = rtrim($this->baseUrl, '/'); return "{$baseUrl}/Items/{$personId}/Images/Primary?maxWidth=300&tag={$imageTag}&quality=90"; } private function downloadImage(string $imageUrl, string $type, string $itemName): ?string { if (empty($imageUrl)) { return null; } try { // Create images directory structure if it doesn't exist $imagesDir = "public/images/{$type}"; if (!is_dir($imagesDir)) { if (!mkdir($imagesDir, 0755, true)) { $this->logProgress("Warning: Could not create images directory: {$imagesDir}"); return null; } } // Create a safe filename from item name and hash of URL for consistency $safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $itemName); if ($safeName === null) { $safeName = $type . '_unknown'; } $safeName = substr($safeName, 0, 30); // Limit length for hash part // Use hash of the image URL to ensure same image always gets same filename $urlHash = substr(md5($imageUrl), 0, 8); $filename = $safeName . '_' . $urlHash . '.jpg'; $filepath = $imagesDir . '/' . $filename; // Check if file already exists if (file_exists($filepath)) { $this->logProgress("Image already exists for {$itemName}, skipping download: {$filepath}"); return "{$type}/{$filename}"; } // Download the image $this->logProgress("Downloading {$type} image for {$itemName} from: {$imageUrl}"); $response = $this->httpClient->get($imageUrl, [ 'sink' => $filepath ]); if ($response->getStatusCode() === 200) { $this->logProgress("Successfully downloaded {$type} image for {$itemName} to: {$filepath}"); return "{$type}/{$filename}"; } else { $this->logProgress("Failed to download {$type} image for {$itemName}: HTTP " . $response->getStatusCode()); return null; } } catch (Exception $e) { $this->logProgress("Error downloading {$type} image for {$itemName}: " . $e->getMessage()); return null; } } private function downloadPosterImage(string $itemId, string $itemName): ?string { $posterUrl = $this->getImageUrl($itemId, 'Primary'); return $this->downloadImage($posterUrl, 'posters', $itemName); } private function downloadBackdropImage(string $itemId, string $itemName): ?string { $backdropUrl = $this->getImageUrl($itemId, 'Backdrop'); return $this->downloadImage($backdropUrl, 'backdrops', $itemName); } protected function getProcessedCount(): int { return $this->processedCount; } protected function getNewCount(): int { return $this->newCount; } protected function getUpdatedCount(): int { return $this->updatedCount; } protected function executeCleanup(): void { $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()); } } }