mirror of
https://github.com/ceratic/MediaCollectorLibary.git
synced 2026-05-13 23:56:46 +02:00
1059 lines
43 KiB
PHP
1059 lines
43 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Movie;
|
|
use App\Models\TvShow;
|
|
use App\Models\TvEpisode;
|
|
use GuzzleHttp\Client;
|
|
use GuzzleHttp\Exception\RequestException;
|
|
use Exception;
|
|
|
|
class JellyfinSyncService extends BaseSyncService
|
|
{
|
|
private Client $httpClient;
|
|
private ?string $apiKey;
|
|
private string $baseUrl;
|
|
private int $processedCount = 0;
|
|
private int $newCount = 0;
|
|
private int $updatedCount = 0;
|
|
|
|
public function __construct(\PDO $pdo, array $source, ?int $existingSyncLogId = null)
|
|
{
|
|
parent::__construct($pdo, $source, $existingSyncLogId);
|
|
$this->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 = __DIR__ . "/../../storage/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());
|
|
}
|
|
}
|
|
}
|