From ca2d3a69604a68373f592fc11199442ec7643b4f Mon Sep 17 00:00:00 2001 From: Lars Behrends Date: Sat, 18 Oct 2025 22:03:30 +0200 Subject: [PATCH] ... --- .gitignore | 8 +- analyze_deovr.php | 74 ++ app/Controllers/ActorController.php | 118 +++ app/Controllers/AdminController.php | 40 +- app/Controllers/AdultController.php | 26 +- app/Controllers/MovieController.php | 14 +- app/Controllers/TvShowController.php | 65 +- app/Models/Actor.php | 179 +++++ app/Models/AdultVideo.php | 60 ++ app/Models/Model.php | 29 +- app/Models/Movie.php | 70 +- app/Models/Source.php | 15 +- app/Models/TvEpisode.php | 178 +++++ app/Models/TvShow.php | 256 ++++++ app/Services/BaseSyncService.php | 95 ++- app/Services/JellyfinSyncService.php | 750 +++++++++++++++--- app/Services/StashSyncService.php | 339 ++++++-- app/Services/XbvrSyncService.php | 375 +++++++-- app/Utils/ImageDownloader.php | 163 ++-- check_sync_logs.php | 96 +++ ...000014_create_actor_media_pivot_tables.php | 56 ++ debug_jellyfin_sync.php | 90 +++ public/.htaccess | 11 + public/build/assets/app-45137c72.js | 10 + public/build/manifest.json | 7 + public/index.php | 257 ++++++ public/resources/js/app.js | 20 + public/resources/scss/app.scss | 126 +++ public/resources/views/admin/index.twig | 248 ++++++ public/resources/views/auth/login.twig | 78 ++ public/resources/views/dashboard/index.twig | 316 ++++++++ public/resources/views/games/index.twig | 76 ++ public/resources/views/games/show.twig | 212 +++++ public/resources/views/layouts/app.twig | 74 ++ resources/views/actor/index.twig | 55 ++ resources/views/actor/show.twig | 118 +++ resources/views/admin/index.twig | 61 +- resources/views/adult/show.twig | 24 +- resources/views/layouts/app.twig | 10 +- routes/web.php | 6 + run_migrations.php | 18 + test_episode_sync.php | 42 + test_jellyfin_execution.php | 45 ++ test_stash.php | 136 ++++ test_xbvr.php | 107 +++ 45 files changed, 4827 insertions(+), 326 deletions(-) create mode 100644 analyze_deovr.php create mode 100644 app/Controllers/ActorController.php create mode 100644 app/Models/Actor.php create mode 100644 app/Models/TvEpisode.php create mode 100644 app/Models/TvShow.php create mode 100644 check_sync_logs.php create mode 100644 database/migrations/2023_10_15_000014_create_actor_media_pivot_tables.php create mode 100644 debug_jellyfin_sync.php create mode 100644 public/.htaccess create mode 100644 public/build/assets/app-45137c72.js create mode 100644 public/build/manifest.json create mode 100644 public/index.php create mode 100644 public/resources/js/app.js create mode 100644 public/resources/scss/app.scss create mode 100644 public/resources/views/admin/index.twig create mode 100644 public/resources/views/auth/login.twig create mode 100644 public/resources/views/dashboard/index.twig create mode 100644 public/resources/views/games/index.twig create mode 100644 public/resources/views/games/show.twig create mode 100644 public/resources/views/layouts/app.twig create mode 100644 resources/views/actor/index.twig create mode 100644 resources/views/actor/show.twig create mode 100644 run_migrations.php create mode 100644 test_episode_sync.php create mode 100644 test_jellyfin_execution.php create mode 100644 test_stash.php create mode 100644 test_xbvr.php diff --git a/.gitignore b/.gitignore index 65f8744..0575642 100644 --- a/.gitignore +++ b/.gitignore @@ -82,7 +82,7 @@ dist # Gatsby files .cache/ -public + # Storybook build outputs .out @@ -142,3 +142,9 @@ composer.lock # Temporary files *.tmp *.temp +/public/build/images/actors +/public/build/images/adult_videos +/public/public/images/actors +/public/public/images/adult_videos +/public/public/images/backdrops +/public/public/images/posters diff --git a/analyze_deovr.php b/analyze_deovr.php new file mode 100644 index 0000000..92be8ea --- /dev/null +++ b/analyze_deovr.php @@ -0,0 +1,74 @@ + 0) { + echo "Sample item structure:\n"; + echo json_encode(array_slice($data[$key][0], 0, 5), JSON_PRETTY_PRINT) . "\n"; + } + } +} + +// Check if the structure contains video URLs for detail fetching +if (isset($data['Recent']) && count($data['Recent']) > 0) { + echo "\n=== Looking for detail URLs in Recent items ===\n"; + $firstItem = $data['Recent'][0]; + + $possibleUrlFields = ['url', 'detail_url', 'video_url', 'scene_url', 'link']; + $foundUrls = []; + + foreach ($possibleUrlFields as $field) { + if (isset($firstItem[$field])) { + $foundUrls[] = "{$field}: {$firstItem[$field]}"; + } + } + + if (!empty($foundUrls)) { + echo "Found potential detail URLs:\n"; + foreach ($foundUrls as $urlInfo) { + echo " - {$urlInfo}\n"; + } + } else { + echo "No obvious detail URL fields found in Recent items.\n"; + echo "Available fields in first Recent item:\n"; + foreach (array_keys($firstItem) as $field) { + echo " - {$field}\n"; + } + } +} + +echo "\n=== Summary ===\n"; +echo "To properly implement XBVR sync, I need to:\n"; +echo "1. Fetch the main DeoVR API response\n"; +echo "2. Extract video list from: " . (isset($data['Recent']) ? 'Recent' : 'unknown') . " array\n"; +echo "3. For each video, fetch detail URL to get complete information\n"; +echo "4. Map the detailed fields to our database structure\n"; diff --git a/app/Controllers/ActorController.php b/app/Controllers/ActorController.php new file mode 100644 index 0000000..e4997a5 --- /dev/null +++ b/app/Controllers/ActorController.php @@ -0,0 +1,118 @@ +pdo = $pdo; + } + + public function show(Request $request, Response $response, $args) + { + $actorId = $args['id']; + + // Get actor details with counts from all media types + $stmt = $this->pdo->prepare(" + SELECT a.*, + COUNT(DISTINCT am.movie_id) as movie_count, + COUNT(DISTINCT ats.tv_show_id) as tv_show_count, + COUNT(DISTINCT aav.adult_video_id) as adult_video_count, + (COUNT(DISTINCT am.movie_id) + COUNT(DISTINCT ats.tv_show_id) + COUNT(DISTINCT aav.adult_video_id)) as total_media_count + FROM actors a + LEFT JOIN actor_movie am ON a.id = am.actor_id + LEFT JOIN actor_tv_show ats ON a.id = ats.actor_id + LEFT JOIN actor_adult_video aav ON a.id = aav.actor_id + WHERE a.id = :actor_id + GROUP BY a.id + "); + $stmt->execute(['actor_id' => $actorId]); + $actor = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$actor) { + return $response->withStatus(404)->withHeader('Content-Type', 'text/html'); + } + + // Get actor's adult videos (scenes) + $stmt = $this->pdo->prepare(" + SELECT av.*, s.display_name as source_name + FROM adult_videos av + JOIN sources s ON av.source_id = s.id + JOIN actor_adult_video aav ON av.id = aav.adult_video_id + WHERE aav.actor_id = :actor_id + ORDER BY av.release_date DESC, av.title ASC + "); + $stmt->execute(['actor_id' => $actorId]); + $scenes = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Get actor's movies + $stmt = $this->pdo->prepare(" + SELECT m.*, s.display_name as source_name + FROM movies m + JOIN sources s ON m.source_id = s.id + JOIN actor_movie am ON m.id = am.movie_id + WHERE am.actor_id = :actor_id + ORDER BY m.release_date DESC, m.title ASC + "); + $stmt->execute(['actor_id' => $actorId]); + $movies = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Get actor's TV shows + $stmt = $this->pdo->prepare(" + SELECT ts.*, s.display_name as source_name + FROM tv_shows ts + JOIN sources s ON ts.source_id = s.id + JOIN actor_tv_show ats ON ts.id = ats.tv_show_id + WHERE ats.actor_id = :actor_id + ORDER BY ts.first_air_date DESC, ts.title ASC + "); + $stmt->execute(['actor_id' => $actorId]); + $tvShows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + return $this->view->render($response, 'actor/show.twig', [ + 'title' => $actor['name'], + 'actor' => $actor, + 'scenes' => $scenes, + 'movies' => $movies, + 'tv_shows' => $tvShows + ]); + } + + public function index(Request $request, Response $response, $args) + { + // Get all actors with their media counts from all types + $stmt = $this->pdo->prepare(" + SELECT a.*, + COUNT(DISTINCT aav.adult_video_id) as adult_video_count, + COUNT(DISTINCT am.movie_id) as movie_count, + COUNT(DISTINCT ats.tv_show_id) as tv_show_count, + (COUNT(DISTINCT aav.adult_video_id) + COUNT(DISTINCT am.movie_id) + COUNT(DISTINCT ats.tv_show_id)) as total_media_count, + MAX(COALESCE(av.release_date, m.release_date, ts.first_air_date)) as latest_media_date + FROM actors a + LEFT JOIN actor_adult_video aav ON a.id = aav.actor_id + LEFT JOIN adult_videos av ON aav.adult_video_id = av.id + LEFT JOIN actor_movie am ON a.id = am.actor_id + LEFT JOIN movies m ON am.movie_id = m.id + LEFT JOIN actor_tv_show ats ON a.id = ats.actor_id + LEFT JOIN tv_shows ts ON ats.tv_show_id = ts.id + GROUP BY a.id + ORDER BY total_media_count DESC, a.name ASC + "); + $stmt->execute(); + $actors = $stmt->fetchAll(PDO::FETCH_ASSOC); + + return $this->view->render($response, 'actor/index.twig', [ + 'title' => 'Actors & Performers', + 'actors' => $actors + ]); + } +} diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php index 7f066ad..aff1235 100644 --- a/app/Controllers/AdminController.php +++ b/app/Controllers/AdminController.php @@ -52,6 +52,26 @@ class AdminController extends Controller return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); } + // Validate sync type based on source type + if ($source['name'] === 'jellyfin') { + $validSyncTypes = ['full', 'incremental', 'all', 'movies', 'tvshows']; + if (!in_array($syncType, $validSyncTypes)) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Invalid sync type for Jellyfin source. Valid types: ' . implode(', ', $validSyncTypes) + ], 400); + } + } else { + // For other sources, only allow full/incremental + $validSyncTypes = ['full', 'incremental']; + if (!in_array($syncType, $validSyncTypes)) { + return $this->json($response, [ + 'success' => false, + 'message' => 'Invalid sync type. Valid types: ' . implode(', ', $validSyncTypes) + ], 400); + } + } + // Start sync in background (simplified - in production you'd use queues) $syncLogId = $this->startSync($source, $syncType); @@ -77,7 +97,7 @@ class AdminController extends Controller 'id' => $syncLog['id'], 'status' => $syncLog['status'], 'sync_type' => $syncLog['sync_type'], - 'total_items' => $syncLog['total_items'], + 'total_items' => $syncLog['total_items'] ?? 0, 'processed_items' => $syncLog['processed_items'], 'new_items' => $syncLog['new_items'], 'updated_items' => $syncLog['updated_items'], @@ -85,10 +105,20 @@ class AdminController extends Controller 'started_at' => $syncLog['started_at'], 'completed_at' => $syncLog['completed_at'], 'message' => $syncLog['message'], - 'errors' => $syncLog['errors'] ? json_decode($syncLog['errors'], true) : [] + 'errors' => $syncLog['errors'] ? json_decode($syncLog['errors'], true) : [], + 'progress_percentage' => $this->calculateProgressPercentage($syncLog) ]); } + private function calculateProgressPercentage(array $syncLog): float + { + $total = $syncLog['total_items'] ?? 0; + if ($total <= 0) return 0; + + $processed = $syncLog['processed_items'] ?? 0; + return min(100, round(($processed / $total) * 100, 2)); + } + public function sources(Request $request, Response $response, $args) { $sourceModel = new Source($this->pdo); @@ -116,11 +146,9 @@ class AdminController extends Controller case 'adult': $syncService = new AdultSyncService($this->pdo, $source); break; - case 'exophase': - $syncService = new ExophaseSyncService($this->pdo, $source); + case 'xbvr': + $syncService = new XbvrSyncService($this->pdo, $source); break; - default: - throw new \Exception('Unsupported source type: ' . $source['name']); } // Start sync (this would typically be queued in production) diff --git a/app/Controllers/AdultController.php b/app/Controllers/AdultController.php index cfbf93b..8ffb526 100644 --- a/app/Controllers/AdultController.php +++ b/app/Controllers/AdultController.php @@ -46,11 +46,6 @@ class AdultController extends Controller $video['poster_url'] = $metadata['cover_url']; } - // Add other local paths if needed - if (!empty($metadata['local_screenshot_path'])) { - $video['screenshot_url'] = $metadata['local_screenshot_path']; - } - // Add actors data if available if (!empty($metadata['actors'])) { $video['actors'] = $metadata['actors']; @@ -106,7 +101,7 @@ class AdultController extends Controller // Decode metadata for display $metadata = json_decode($adultVideo['metadata'], true); - // Add local image paths to the video data for template compatibility + // Add local image paths and other metadata to the video data for template compatibility if (!empty($metadata['local_cover_path'])) { $adultVideo['poster_url'] = '/public/images/'.$metadata['local_cover_path']; } elseif (!empty($metadata['cover_url'])) { @@ -117,10 +112,27 @@ class AdultController extends Controller $adultVideo['screenshot_url'] = '/public/images/'.$metadata['local_screenshot_path']; } + // Add actors data if available + if (!empty($metadata['actors'])) { + $adultVideo['actors'] = $metadata['actors']; + } + + // Get actors for this adult video from the pivot table + $stmt = $this->pdo->prepare(" + SELECT a.* + FROM actors a + JOIN actor_adult_video aav ON a.id = aav.actor_id + WHERE aav.adult_video_id = :adult_video_id + ORDER BY a.name ASC + "); + $stmt->execute(['adult_video_id' => $adultVideoId]); + $actors = $stmt->fetchAll(\PDO::FETCH_ASSOC); + return $this->view->render($response, 'adult/show.twig', [ 'title' => $adultVideo['title'], 'movie' => $adultVideo, // Keep same variable name for template compatibility - 'metadata' => $metadata + 'metadata' => $metadata, + 'actors' => $actors ]); } diff --git a/app/Controllers/MovieController.php b/app/Controllers/MovieController.php index 209c3a8..e020ac6 100644 --- a/app/Controllers/MovieController.php +++ b/app/Controllers/MovieController.php @@ -82,10 +82,22 @@ class MovieController extends Controller // Decode metadata for display $metadata = json_decode($movie['metadata'], true); + // Get actors for this movie + $stmt = $this->pdo->prepare(" + SELECT a.* + FROM actors a + JOIN actor_movie am ON a.id = am.actor_id + WHERE am.movie_id = :movie_id + ORDER BY a.name ASC + "); + $stmt->execute(['movie_id' => $movieId]); + $actors = $stmt->fetchAll(\PDO::FETCH_ASSOC); + return $this->view->render($response, 'movies/show.twig', [ 'title' => $movie['title'], 'movie' => $movie, - 'metadata' => $metadata + 'metadata' => $metadata, + 'actors' => $actors ]); } } diff --git a/app/Controllers/TvShowController.php b/app/Controllers/TvShowController.php index 62e4819..e953810 100644 --- a/app/Controllers/TvShowController.php +++ b/app/Controllers/TvShowController.php @@ -62,11 +62,68 @@ class TvShowController extends Controller { $tvShowId = (int) $args['id']; - // For now, return a placeholder since TV Shows aren't implemented yet + // Get TV show details + $stmt = $this->pdo->prepare(" + SELECT t.*, s.display_name as source_name + FROM tv_shows t + JOIN sources s ON t.source_id = s.id + WHERE t.id = :id + "); + $stmt->execute(['id' => $tvShowId]); + $tvShow = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$tvShow) { + return $response->withStatus(404); + } + + // Decode metadata and other JSON fields + $metadata = json_decode($tvShow['metadata'] ?? '{}', true); + $cast = json_decode($tvShow['cast'] ?? '[]', true); + $genre = json_decode($tvShow['genre'] ?? '[]', true); + + // Get actors for this TV show + $stmt = $this->pdo->prepare(" + SELECT a.* + FROM actors a + JOIN actor_tv_show ats ON a.id = ats.actor_id + WHERE ats.tv_show_id = :tv_show_id + ORDER BY a.name ASC + "); + $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 +*/ return $this->view->render($response, 'tvshows/show.twig', [ - 'title' => 'TV Show Details', - 'tvshow' => ['id' => $tvShowId, 'title' => 'Coming Soon'], - 'message' => 'TV show details page is not yet implemented.' + 'title' => $tvShow['title'], + 'tvshow' => $tvShow, + 'metadata' => $metadata, + 'cast' => $cast, + 'genre' => $genre, + 'actors' => $actors, + 'seasons' => $seasons ]); } } diff --git a/app/Models/Actor.php b/app/Models/Actor.php new file mode 100644 index 0000000..b8cea2d --- /dev/null +++ b/app/Models/Actor.php @@ -0,0 +1,179 @@ + 'array' + ]; + + /** + * Get all movies this actor is associated with + */ + public function movies() + { + $stmt = $this->pdo->prepare(" + SELECT m.*, s.display_name as source_name + FROM movies m + JOIN sources s ON m.source_id = s.id + JOIN actor_movie am ON m.id = am.movie_id + WHERE am.actor_id = :actor_id + ORDER BY m.release_date DESC, m.title ASC + "); + $stmt->execute(['actor_id' => $this->id]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Get all TV shows this actor is associated with + */ + public function tvShows() + { + $stmt = $this->pdo->prepare(" + SELECT ts.*, s.display_name as source_name + FROM tv_shows ts + JOIN sources s ON ts.source_id = s.id + JOIN actor_tv_show ats ON ts.id = ats.tv_show_id + WHERE ats.actor_id = :actor_id + ORDER BY ts.first_air_date DESC, ts.title ASC + "); + $stmt->execute(['actor_id' => $this->id]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Get all adult videos this actor is associated with + */ + public function adultVideos() + { + $stmt = $this->pdo->prepare(" + SELECT av.*, s.display_name as source_name + FROM adult_videos av + JOIN sources s ON av.source_id = s.id + JOIN actor_adult_video aav ON av.id = aav.adult_video_id + WHERE aav.actor_id = :actor_id + ORDER BY av.release_date DESC, av.title ASC + "); + $stmt->execute(['actor_id' => $this->id]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Get actor statistics + */ + public function getStats(): array + { + $stmt = $this->pdo->prepare(" + SELECT + COUNT(DISTINCT am.movie_id) as movie_count, + COUNT(DISTINCT ats.tv_show_id) as tv_show_count, + COUNT(DISTINCT aav.adult_video_id) as adult_video_count, + COUNT(DISTINCT am.movie_id) + COUNT(DISTINCT ats.tv_show_id) + COUNT(DISTINCT aav.adult_video_id) as total_media_count + FROM actors a + LEFT JOIN actor_movie am ON a.id = am.actor_id + LEFT JOIN actor_tv_show ats ON a.id = ats.actor_id + LEFT JOIN actor_adult_video aav ON a.id = aav.actor_id + WHERE a.id = :actor_id + "); + $stmt->execute(['actor_id' => $this->id]); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + /** + * Link actor to a movie + */ + public function linkToMovie(int $movieId): bool + { + $stmt = $this->pdo->prepare(" + INSERT IGNORE INTO actor_movie (actor_id, movie_id) + VALUES (:actor_id, :movie_id) + "); + return $stmt->execute([ + 'actor_id' => $this->id, + 'movie_id' => $movieId + ]); + } + + /** + * Link actor to a TV show + */ + public function linkToTvShow(int $tvShowId): bool + { + $stmt = $this->pdo->prepare(" + INSERT IGNORE INTO actor_tv_show (actor_id, tv_show_id) + VALUES (:actor_id, :tv_show_id) + "); + return $stmt->execute([ + 'actor_id' => $this->id, + 'tv_show_id' => $tvShowId + ]); + } + + /** + * Link actor to an adult video + */ + public function linkToAdultVideo(int $adultVideoId): bool + { + $stmt = $this->pdo->prepare(" + INSERT IGNORE INTO actor_adult_video (actor_id, adult_video_id) + VALUES (:actor_id, :adult_video_id) + "); + return $stmt->execute([ + 'actor_id' => $this->id, + 'adult_video_id' => $adultVideoId + ]); + } + + /** + * Unlink actor from a movie + */ + public function unlinkFromMovie(int $movieId): bool + { + $stmt = $this->pdo->prepare(" + DELETE FROM actor_movie + WHERE actor_id = :actor_id AND movie_id = :movie_id + "); + return $stmt->execute([ + 'actor_id' => $this->id, + 'movie_id' => $movieId + ]); + } + + /** + * Unlink actor from a TV show + */ + public function unlinkFromTvShow(int $tvShowId): bool + { + $stmt = $this->pdo->prepare(" + DELETE FROM actor_tv_show + WHERE actor_id = :actor_id AND tv_show_id = :tv_show_id + "); + return $stmt->execute([ + 'actor_id' => $this->id, + 'tv_show_id' => $tvShowId + ]); + } + + /** + * Unlink actor from an adult video + */ + public function unlinkFromAdultVideo(int $adultVideoId): bool + { + $stmt = $this->pdo->prepare(" + DELETE FROM actor_adult_video + WHERE actor_id = :actor_id AND adult_video_id = :adult_video_id + "); + return $stmt->execute([ + 'actor_id' => $this->id, + 'adult_video_id' => $adultVideoId + ]); + } +} diff --git a/app/Models/AdultVideo.php b/app/Models/AdultVideo.php index d823493..2a9699e 100644 --- a/app/Models/AdultVideo.php +++ b/app/Models/AdultVideo.php @@ -106,4 +106,64 @@ class AdultVideo extends Model return $sourceData ? new Source($this->pdo, $sourceData) : null; } + + /** + * Get all actors associated with this adult video + */ + public function actors() + { + $stmt = $this->pdo->prepare(" + SELECT a.* + FROM actors a + JOIN actor_adult_video aav ON a.id = aav.actor_id + WHERE aav.adult_video_id = :adult_video_id + ORDER BY a.name ASC + "); + $stmt->execute(['adult_video_id' => $this->id]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Add an actor to this adult video + */ + public function addActor(int $actorId): bool + { + $stmt = $this->pdo->prepare(" + INSERT IGNORE INTO actor_adult_video (adult_video_id, actor_id) + VALUES (:adult_video_id, :actor_id) + "); + return $stmt->execute([ + 'adult_video_id' => $this->id, + 'actor_id' => $actorId + ]); + } + + /** + * Remove an actor from this adult video + */ + public function removeActor(int $actorId): bool + { + $stmt = $this->pdo->prepare(" + DELETE FROM actor_adult_video + WHERE adult_video_id = :adult_video_id AND actor_id = :actor_id + "); + return $stmt->execute([ + 'adult_video_id' => $this->id, + 'actor_id' => $actorId + ]); + } + + /** + * Update the cast field with actor names + */ + public function updateCastField(): bool + { + $actors = $this->actors(); + $actorNames = array_column($actors, 'name'); + $castString = implode(', ', $actorNames); + + return $this->update($this->id, [ + 'cast' => $castString + ]); + } } diff --git a/app/Models/Model.php b/app/Models/Model.php index 078fe67..405f5c4 100644 --- a/app/Models/Model.php +++ b/app/Models/Model.php @@ -11,10 +11,37 @@ abstract class Model protected array $fillable = []; protected array $hidden = []; protected array $casts = []; + protected array $attributes = []; - public function __construct(\PDO $pdo) + public function __construct(\PDO $pdo, array $data = []) { $this->pdo = $pdo; + $this->attributes = $data; + } + + public function __get(string $key) + { + return $this->attributes[$key] ?? null; + } + + public function __set(string $key, $value) + { + $this->attributes[$key] = $value; + } + + public function __isset(string $key): bool + { + return isset($this->attributes[$key]); + } + + public function getAttributes(): array + { + return $this->attributes; + } + + public function setAttributes(array $data) + { + $this->attributes = $data; } public function find(int $id): ?array diff --git a/app/Models/Movie.php b/app/Models/Movie.php index 4b1821b..b121005 100644 --- a/app/Models/Movie.php +++ b/app/Models/Movie.php @@ -39,7 +39,11 @@ class Movie extends Model public function source() { - return new Source($this->pdo); + $stmt = $this->pdo->prepare("SELECT * FROM sources WHERE id = :source_id"); + $stmt->execute(['source_id' => $this->source_id]); + $sourceData = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $sourceData ? new Source($this->pdo, $sourceData) : null; } public function markAsWatched(): bool @@ -88,7 +92,7 @@ class Movie extends Model SUM(runtime_minutes) as total_runtime FROM movies "); - return $stmt->fetch(PDO::FETCH_ASSOC); + return $stmt->fetch(\PDO::FETCH_ASSOC); } public static function getRecent(\PDO $pdo, int $limit = 10): array @@ -102,7 +106,7 @@ class Movie extends Model LIMIT :limit "); $stmt->execute(['limit' => $limit]); - return $stmt->fetchAll(PDO::FETCH_ASSOC); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); } public static function getTotalCount(\PDO $pdo, string $search = ''): int @@ -162,4 +166,64 @@ class Movie extends Model $stmt->execute(['limit' => $limit]); return $stmt->fetchAll(\PDO::FETCH_ASSOC); } + + /** + * Get all actors associated with this movie + */ + public function actors() + { + $stmt = $this->pdo->prepare(" + SELECT a.* + FROM actors a + JOIN actor_movie am ON a.id = am.actor_id + WHERE am.movie_id = :movie_id + ORDER BY a.name ASC + "); + $stmt->execute(['movie_id' => $this->id]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Add an actor to this movie + */ + public function addActor(int $actorId): bool + { + $stmt = $this->pdo->prepare(" + INSERT IGNORE INTO actor_movie (movie_id, actor_id) + VALUES (:movie_id, :actor_id) + "); + return $stmt->execute([ + 'movie_id' => $this->id, + 'actor_id' => $actorId + ]); + } + + /** + * Remove an actor from this movie + */ + public function removeActor(int $actorId): bool + { + $stmt = $this->pdo->prepare(" + DELETE FROM actor_movie + WHERE movie_id = :movie_id AND actor_id = :actor_id + "); + return $stmt->execute([ + 'movie_id' => $this->id, + 'actor_id' => $actorId + ]); + } + + /** + * Update the cast field with actor names + */ + public function updateCastField(): bool + { + $actors = $this->actors(); + $actorNames = array_column($actors, 'name'); + $castString = implode(', ', $actorNames); + + return $this->update($this->id, [ + 'cast' => $castString + ]); + } } diff --git a/app/Models/Source.php b/app/Models/Source.php index 18c6a22..fe70fd6 100644 --- a/app/Models/Source.php +++ b/app/Models/Source.php @@ -15,39 +15,44 @@ class Source extends Model 'last_sync_at' ]; + public function __construct(\PDO $pdo, array $data = []) + { + parent::__construct($pdo, $data); + } + public function games(): array { $stmt = $this->pdo->prepare("SELECT * FROM games WHERE source_id = :source_id"); $stmt->execute(['source_id' => $this->id]); - return $stmt->fetchAll(PDO::FETCH_ASSOC); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); } public function movies(): array { $stmt = $this->pdo->prepare("SELECT * FROM movies WHERE source_id = :source_id"); $stmt->execute(['source_id' => $this->id]); - return $stmt->fetchAll(PDO::FETCH_ASSOC); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); } public function tvShows(): array { $stmt = $this->pdo->prepare("SELECT * FROM tv_shows WHERE source_id = :source_id"); $stmt->execute(['source_id' => $this->id]); - return $stmt->fetchAll(PDO::FETCH_ASSOC); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); } public function musicArtists(): array { $stmt = $this->pdo->prepare("SELECT * FROM music_artists WHERE source_id = :source_id"); $stmt->execute(['source_id' => $this->id]); - return $stmt->fetchAll(PDO::FETCH_ASSOC); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); } public function getSyncLogs(): array { $stmt = $this->pdo->prepare("SELECT * FROM sync_logs WHERE source_id = :source_id ORDER BY created_at DESC"); $stmt->execute(['source_id' => $this->id]); - return $stmt->fetchAll(PDO::FETCH_ASSOC); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); } public function createSyncLog(string $syncType, string $status): int diff --git a/app/Models/TvEpisode.php b/app/Models/TvEpisode.php new file mode 100644 index 0000000..610046f --- /dev/null +++ b/app/Models/TvEpisode.php @@ -0,0 +1,178 @@ + 'int', + 'episode_number' => 'int', + 'runtime_minutes' => 'int', + 'rating' => 'float', + 'is_watched' => 'bool', + 'is_favorite' => 'bool', + 'air_date' => 'date', + 'metadata' => 'array' + ]; + + /** + * Get all actors associated with this TV episode + */ + public function actors() + { + $stmt = $this->pdo->prepare(" + SELECT a.* + FROM actors a + JOIN actor_tv_episode ate ON a.id = ate.actor_id + WHERE ate.tv_episode_id = :tv_episode_id + ORDER BY a.name ASC + "); + $stmt->execute(['tv_episode_id' => $this->id]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Get the TV show this episode belongs to + */ + public function tvShow() + { + $stmt = $this->pdo->prepare(" + SELECT * FROM tv_shows WHERE id = :tv_show_id + "); + $stmt->execute(['tv_show_id' => $this->tv_show_id]); + $showData = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $showData ? new TvShow($this->pdo, $showData) : null; + } + + /** + * Get TV episode statistics + */ + public static function getStats(\PDO $pdo): array + { + $stmt = $pdo->query(" + SELECT + COUNT(*) as total_episodes, + COUNT(CASE WHEN is_watched = 1 THEN 1 END) as watched_episodes, + COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_episodes, + AVG(rating) as avg_rating + FROM tv_episodes + "); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + /** + * Get total count with optional search + */ + public static function getTotalCount(\PDO $pdo, string $search = ''): int + { + $sql = "SELECT COUNT(*) as count FROM tv_episodes te JOIN tv_shows ts ON te.tv_show_id = ts.id JOIN sources s ON te.source_id = s.id"; + $params = []; + + if (!empty($search)) { + $sql .= " WHERE te.title LIKE :search OR ts.title LIKE :search"; + $params['search'] = "%{$search}%"; + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + return (int) $stmt->fetch()['count']; + } + + /** + * Get all TV episodes with pagination and optional search + */ + public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array + { + $offset = ($page - 1) * $perPage; + + $sql = " + SELECT te.*, ts.title as show_title, s.display_name as source_name + FROM tv_episodes te + JOIN tv_shows ts ON te.tv_show_id = ts.id + JOIN sources s ON te.source_id = s.id + "; + $params = []; + + if (!empty($search)) { + $sql .= " WHERE te.title LIKE :search OR ts.title LIKE :search"; + $params['search'] = "%{$search}%"; + } + + $sql .= " ORDER BY ts.title ASC, te.season_number ASC, te.episode_number ASC LIMIT :limit OFFSET :offset"; + + $stmt = $pdo->prepare($sql); + $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT); + + foreach ($params as $key => $value) { + $stmt->bindValue(":{$key}", $value); + } + + $stmt->execute(); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Toggle watched status + */ + public function toggleWatched(): bool + { + return $this->update($this->id, [ + 'is_watched' => !$this->is_watched + ]); + } + + /** + * Toggle favorite status + */ + public function toggleFavorite(): bool + { + return $this->update($this->id, [ + 'is_favorite' => !$this->is_favorite + ]); + } + + /** + * Update rating + */ + public function updateRating(float $rating): bool + { + return $this->update($this->id, [ + 'rating' => min(10.0, max(0.0, $rating)) + ]); + } + + /** + * Get the source relationship + */ + public function source(): ?Source + { + $stmt = $this->pdo->prepare("SELECT * FROM sources WHERE id = :source_id"); + $stmt->execute(['source_id' => $this->source_id]); + $sourceData = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $sourceData ? new Source($this->pdo, $sourceData) : null; + } +} diff --git a/app/Models/TvShow.php b/app/Models/TvShow.php new file mode 100644 index 0000000..4b9b3de --- /dev/null +++ b/app/Models/TvShow.php @@ -0,0 +1,256 @@ + 'int', + 'number_of_episodes' => 'int', + 'rating' => 'float', + 'is_favorite' => 'bool', + 'first_air_date' => 'date', + 'last_air_date' => 'date', + 'metadata' => 'array' + ]; + + /** + * Get all actors associated with this TV show + */ + public function actors() + { + $stmt = $this->pdo->prepare(" + SELECT a.* + FROM actors a + JOIN actor_tv_show ats ON a.id = ats.actor_id + WHERE ats.tv_show_id = :tv_show_id + ORDER BY a.name ASC + "); + $stmt->execute(['tv_show_id' => $this->id]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Get TV show statistics + */ + public static function getStats(\PDO $pdo): array + { + $stmt = $pdo->query(" + SELECT + COUNT(*) as total_tv_shows, + COUNT(CASE WHEN is_favorite = 1 THEN 1 END) as favorite_tv_shows, + AVG(rating) as avg_rating + FROM tv_shows + "); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + /** + * Get total count with optional search + */ + public static function getTotalCount(\PDO $pdo, string $search = ''): int + { + $sql = "SELECT COUNT(*) as count FROM tv_shows t JOIN sources s ON t.source_id = s.id"; + $params = []; + + if (!empty($search)) { + $sql .= " WHERE t.title LIKE :search"; + $params['search'] = "%{$search}%"; + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + return (int) $stmt->fetch()['count']; + } + + /** + * Get all TV shows with pagination and optional search + */ + public static function getAllWithPagination(\PDO $pdo, int $page, int $perPage, string $search = ''): array + { + $offset = ($page - 1) * $perPage; + + $sql = " + SELECT t.*, s.display_name as source_name + FROM tv_shows t + JOIN sources s ON t.source_id = s.id + "; + $params = []; + + if (!empty($search)) { + $sql .= " WHERE t.title LIKE :search"; + $params['search'] = "%{$search}%"; + } + + $sql .= " ORDER BY t.title ASC LIMIT :limit OFFSET :offset"; + + $stmt = $pdo->prepare($sql); + $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT); + + foreach ($params as $key => $value) { + $stmt->bindValue(":{$key}", $value); + } + + $stmt->execute(); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Toggle favorite status + */ + public function toggleFavorite(): bool + { + return $this->update($this->id, [ + 'is_favorite' => !$this->is_favorite + ]); + } + + /** + * Update rating + */ + public function updateRating(float $rating): bool + { + return $this->update($this->id, [ + 'rating' => min(10.0, max(0.0, $rating)) + ]); + } + + /** + * Get the source relationship + */ + public function source(): ?Source + { + $stmt = $this->pdo->prepare("SELECT * FROM sources WHERE id = :source_id"); + $stmt->execute(['source_id' => $this->source_id]); + $sourceData = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $sourceData ? new Source($this->pdo, $sourceData) : null; + } + + public function getSeasonsWithEpisodes(): array +{ + $stmt = $this->pdo->prepare(" + SELECT s.*, + COUNT(e.id) as episode_count, + SUM(CASE WHEN e.watched = 1 THEN 1 ELSE 0 END) as watched_episodes + FROM seasons s + LEFT JOIN episodes e ON s.id = e.season_id + WHERE s.tv_show_id = :tv_show_id + GROUP BY s.id + ORDER BY s.season_number ASC + "); + $stmt->execute(['tv_show_id' => $this->id]); + $seasons = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + // Get episodes for each season + foreach ($seasons as &$season) { + $stmt = $this->pdo->prepare(" + SELECT e.*, + (SELECT COUNT(*) FROM user_episodes WHERE episode_id = e.id AND watched = 1) as watch_count + FROM episodes e + WHERE e.season_id = :season_id + ORDER BY e.episode_number ASC + "); + $stmt->execute(['season_id' => $season['id']]); + $season['episodes'] = $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + return $seasons; +} + +/** + * Get similar TV shows based on genres + */ +public function getSimilarShows(int $limit = 6): array +{ + $genres = $this->genre ? explode(',', $this->genre) : []; + $placeholders = str_repeat('?,', count($genres) - 1) . '?'; + + $sql = " + SELECT t.*, + COUNT(DISTINCT g.genre) as matching_genres + FROM tv_shows t + CROSS JOIN (SELECT TRIM(value) as genre + FROM json_each('[\"" . str_replace(',', '","', str_replace('"', '\\"', $this->genre)) . "\"]') + WHERE value != '') g + WHERE t.id != ? + AND t.genre LIKE '%' || g.genre || '%' + GROUP BY t.id + HAVING matching_genres > 0 + ORDER BY matching_genres DESC, t.rating DESC + LIMIT ? + "; + + $params = array_merge([$this->id, $limit]); + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC); +} + +/** + * Record that this TV show was viewed + */ +public function recordView(): bool +{ + $stmt = $this->pdo->prepare(" + INSERT OR REPLACE INTO tv_show_views + (tv_show_id, view_count, last_viewed_at) + VALUES (?, COALESCE((SELECT view_count FROM tv_show_views WHERE tv_show_id = ?), 0) + 1, CURRENT_TIMESTAMP) + "); + return $stmt->execute([$this->id, $this->id]); +} + +/** + * Get all available genres from TV shows + */ +public static function getAvailableGenres(\PDO $pdo): array +{ + $stmt = $pdo->query(" + SELECT DISTINCT TRIM(value) as genre + FROM tv_shows, + json_each('[\"' || REPLACE(genre, ',', '\",\"') || '\"]') + WHERE genre IS NOT NULL AND genre != '' + ORDER BY genre + "); + return $stmt->fetchAll(\PDO::FETCH_COLUMN); +} + +/** + * Get all available years from TV shows' first_air_date + */ +public static function getAvailableYears(\PDO $pdo): array +{ + $stmt = $pdo->query(" + SELECT DISTINCT strftime('%Y', first_air_date) as year + FROM tv_shows + WHERE first_air_date IS NOT NULL + ORDER BY year DESC + "); + return $stmt->fetchAll(\PDO::FETCH_COLUMN); +} + +} diff --git a/app/Services/BaseSyncService.php b/app/Services/BaseSyncService.php index 7b62bc9..d973bbb 100644 --- a/app/Services/BaseSyncService.php +++ b/app/Services/BaseSyncService.php @@ -12,6 +12,9 @@ abstract class BaseSyncService protected SyncLog $syncLog; protected int $sourceId; + protected $logFileHandle; + protected $logFilePath; + public function __construct(\PDO $pdo, array $source) { $this->pdo = $pdo; @@ -22,17 +25,50 @@ abstract class BaseSyncService } $this->sourceId = (int) $source['id']; + + // Create log file for this sync operation + $this->initializeLogFile(); + } + + private function initializeLogFile(): void + { + $timestamp = date('Y-m-d_H-i-s'); + $sourceName = strtolower($this->source['name'] ?? 'unknown'); + $this->logFilePath = "logs/{$sourceName}_sync_{$timestamp}.log"; + + // Create logs directory if it doesn't exist + $logDir = dirname($this->logFilePath); + if (!is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + + $this->logFileHandle = fopen($this->logFilePath, 'w'); + if ($this->logFileHandle) { + $this->logProgress("=== Starting {$sourceName} sync at " . date('Y-m-d H:i:s') . " ==="); + } + } + + public function __destruct() + { + if ($this->logFileHandle) { + $this->logProgress("=== Sync completed at " . date('Y-m-d H:i:s') . " ==="); + fclose($this->logFileHandle); + } } public function startSync(string $syncType = 'full'): int { - // Create sync log entry - $this->syncLog = new SyncLog($this->pdo); - $syncLogId = $this->createSyncLog($syncType, 'started'); + // Set higher limits for long-running syncs + ini_set('max_execution_time', 3600); // 1 hour + ini_set('memory_limit', '512M'); - $this->syncLog->id = $syncLogId; + // Create sync log entry + $syncLogId = $this->createSyncLog($syncType, 'started'); + $this->currentSyncLogId = $syncLogId; try { + $this->logProgress("Starting {$syncType} sync for source: " . ($this->source['display_name'] ?? $this->source['name'])); + $this->executeSync($syncType); // Update sync log as completed @@ -40,14 +76,31 @@ abstract class BaseSyncService 'processed_items' => $this->getProcessedCount(), 'new_items' => $this->getNewCount(), 'updated_items' => $this->getUpdatedCount(), - 'deleted_items' => $this->getDeletedCount() + 'deleted_items' => $this->getDeletedCount(), + 'message' => "Successfully completed sync" ]); + $this->logProgress("Sync completed successfully"); + } catch (Exception $e) { - // Update sync log as failed + // Log the full error details + $errorMessage = $e->getMessage(); + $errorFile = $e->getFile(); + $errorLine = $e->getLine(); + $errorTrace = $e->getTraceAsString(); + + $this->logProgress("CRITICAL ERROR - Sync failed: {$errorMessage}"); + $this->logProgress("Error location: {$errorFile}:{$errorLine}"); + $this->logProgress("Stack trace: {$errorTrace}"); + + // Update sync log as failed with full error details $this->updateSyncLog($syncLogId, 'failed', [ - 'message' => $e->getMessage(), - 'errors' => [$e->getMessage()] + 'message' => $errorMessage, + 'errors' => [ + $errorMessage, + "File: {$errorFile}:{$errorLine}", + "Stack: " . substr($errorTrace, 0, 1000) // Limit trace size + ] ]); throw $e; @@ -80,7 +133,7 @@ abstract class BaseSyncService return (int) $this->pdo->lastInsertId(); } - private function updateSyncLog(int $syncLogId, string $status, array $stats = []): bool + protected function updateSyncLog(int $syncLogId, string $status, array $stats = []): bool { $data = [ 'status' => $status, @@ -129,13 +182,31 @@ abstract class BaseSyncService return 0; // Override in subclasses } + protected $currentSyncLogId = null; + protected function logProgress(string $message): void { - // Update sync log with progress message - if ($this->syncLog) { - $this->updateSyncLog($this->syncLog->id, 'running', [ + $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); + + // Update sync log with progress message if we have a current sync log + if ($this->currentSyncLogId) { + $this->updateSyncLog($this->currentSyncLogId, 'running', [ 'message' => $message ]); } } + + public function getLogFilePath(): string + { + return $this->logFilePath ?? ''; + } } diff --git a/app/Services/JellyfinSyncService.php b/app/Services/JellyfinSyncService.php index 4058139..39fbb47 100644 --- a/app/Services/JellyfinSyncService.php +++ b/app/Services/JellyfinSyncService.php @@ -6,6 +6,7 @@ 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 @@ -31,7 +32,7 @@ class JellyfinSyncService extends BaseSyncService $this->baseUrl = rtrim($source['api_url'], '/'); } - protected function executeSync(string $syncType): void + protected function executeSync(string $syncType = 'all'): void { if (empty($this->apiKey) || empty($this->baseUrl)) { throw new Exception('Jellyfin API key and URL not configured'); @@ -40,6 +41,7 @@ class JellyfinSyncService extends BaseSyncService $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(); @@ -49,32 +51,49 @@ class JellyfinSyncService extends BaseSyncService throw $e; } - // Sync movies - try { - $this->logProgress('Fetching movies from Jellyfin...'); - $movies = $this->getJellyfinItems('Movie'); - $this->logProgress("Found " . count($movies) . " movies in Jellyfin"); + // Sync movies if requested + if (in_array($syncType, ['all', 'movies'])) { + try { + $this->logProgress('Fetching movies from Jellyfin...'); + $movies = $this->getJellyfinItems('Movie'); + $this->logProgress("Found " . count($movies) . " movies in Jellyfin"); - if (empty($movies)) { - $this->logProgress('No movies found in Jellyfin library'); - $this->logProgress("Processed {$this->processedCount} items"); - return; + 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; + } } - - 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()); - throw $e; + } else { + $this->logProgress('Skipping movies sync (sync type: ' . $syncType . ')'); } - // TODO: Sync TV shows and episodes when TvShow model is implemented - // $this->syncTvShows(); + // 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"); } @@ -96,14 +115,43 @@ class JellyfinSyncService extends BaseSyncService private function syncTvShows(): void { try { + $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"); + + if (empty($tvShows)) { + $this->logProgress('No TV shows found in Jellyfin library'); + return; + } + + $processedShows = 0; + $successfulShows = 0; + $failedShows = 0; foreach ($tvShows as $showData) { - $this->syncTvShow($showData); - $this->processedCount++; + $processedShows++; + $this->logProgress("Processing TV show {$processedShows}/" . count($tvShows) . ": {$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('Error syncing TV shows: ' . $e->getMessage()); + $this->logProgress('CRITICAL ERROR in TV shows sync: ' . $e->getMessage()); + $this->logProgress('Stack trace: ' . $e->getTraceAsString()); + throw $e; } } @@ -179,7 +227,7 @@ class JellyfinSyncService extends BaseSyncService 'source_id' => $this->source['id'] ]); - $movieData = [ + $movieDataForDb = [ 'title' => $movieData['Name'], 'overview' => $movieData['Overview'] ?? null, 'release_date' => $movieData['PremiereDate'] ? date('Y-m-d', strtotime($movieData['PremiereDate'])) : null, @@ -187,102 +235,527 @@ class JellyfinSyncService extends BaseSyncService 'rating' => $movieData['CommunityRating'] ?? null, 'imdb_id' => $movieData['ProviderIds']['Imdb'] ?? null, 'tmdb_id' => $movieData['ProviderIds']['Tmdb'] ?? null, - 'poster_url' => $this->getImageUrl($movieData['Id'], 'Primary'), - 'backdrop_url' => $this->getImageUrl($movieData['Id'], 'Backdrop'), 'source_id' => $this->source['id'], 'metadata' => json_encode([ 'jellyfin_id' => $movieData['Id'], 'genres' => $movieData['Genres'] ?? [], - 'studios' => $movieData['Studios'] ?? [] + '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($movieData); + $movieModel->create($movieDataForDb); $this->newCount++; } else { - $movieModel->update($existingMovie[0]['id'], $movieData); + $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()); + } } - // TODO: Implement when TvShow model is created - // private function syncTvShow(array $showData): void - // { - // $showModel = new TvShow($this->pdo); + private function syncTvShow(array $showData): void + { + $showName = $showData['Name'] ?? 'Unknown Show'; + $this->logProgress("--- Starting sync for TV show: {$showName} ---"); - // // Check if show already exists - // $existingShow = $showModel->findAll([ - // 'tmdb_id' => $showData['ProviderIds']['Tmdb'] ?? null, - // 'imdb_id' => $showData['ProviderIds']['Imdb'] ?? null, - // 'source_id' => $this->source->id - // ]); + $showModel = new TvShow($this->pdo); - // $showData = [ - // '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, - // 'poster_url' => $this->getImageUrl($showData['Id'], 'Primary'), - // 'backdrop_url' => $this->getImageUrl($showData['Id'], 'Backdrop'), - // 'source_id' => $this->source->id, - // 'metadata' => json_encode([ - // 'jellyfin_id' => $showData['Id'], - // 'genres' => $showData['Genres'] ?? [] - // ]) - // ]; + // 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'] + ]); - // if (empty($existingShow)) { - // $showId = $showModel->create($showData); - // $this->newCount++; - // } else { - // $showId = $existingShow[0]['id']; - // $showModel->update($showId, $showData); - // $this->updatedCount++; - // } + $this->logProgress("Found " . count($existingShow) . " existing TV show(s) in database"); - // // Sync episodes for this show - // $this->syncEpisodes($showId, $showData['Id']); - // } + // 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'] ?? [] + ]) + ]; - // TODO: Implement when TvEpisode model is created - // private function syncEpisodes(int $showId, string $jellyfinShowId): void - // { - // try { - // $episodes = $this->getShowEpisodes($jellyfinShowId); + // 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"); + } - // foreach ($episodes as $episodeData) { - // $this->syncEpisode($showId, $episodeData); - // } - // } catch (Exception $e) { - // $this->logProgress('Error syncing episodes for show ' . $jellyfinShowId . ': ' . $e->getMessage()); - // } - // } + // 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"); + } - // TODO: Implement when TvEpisode model is created - // private function syncEpisode(int $showId, array $episodeData): void - // { - // $episodeModel = new TvEpisode($this->pdo); + 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; + } - // $episodeData = [ - // 'title' => $episodeData['Name'], - // 'overview' => $episodeData['Overview'] ?? null, - // 'season_number' => $episodeData['ParentIndexNumber'] ?? 1, - // 'episode_number' => $episodeData['IndexNumber'] ?? 1, - // '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'] - // ]) - // ]; + // 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()); + } - // $episodeModel->create($episodeData); - // } + // 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 { @@ -293,6 +766,83 @@ class JellyfinSyncService extends BaseSyncService 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; diff --git a/app/Services/StashSyncService.php b/app/Services/StashSyncService.php index 15917aa..3485252 100644 --- a/app/Services/StashSyncService.php +++ b/app/Services/StashSyncService.php @@ -31,8 +31,9 @@ class StashSyncService extends BaseSyncService 'headers' => [ 'User-Agent' => 'MediaCollector/1.0', 'Content-Type' => 'application/json', - 'ApiKey' => $this->apiKey // Now safe to access - ] + 'ApiKey' => $this->apiKey // Stash API key for authentication + ], + 'verify' => false // Disable SSL verification for problematic servers ]); $this->imageDownloader = new ImageDownloader('public/images', $this->apiKey); @@ -60,21 +61,70 @@ class StashSyncService extends BaseSyncService try { $this->logProgress('Fetching Stash scenes...'); + // First, get the total count to determine how many pages we need + $totalCount = $this->getStashScenesCount(); + + if ($totalCount === 0) { + $this->logProgress('No scenes found in Stash'); + return; + } + + $this->logProgress("Found {$totalCount} scenes in Stash"); + // Use pagination to handle large libraries - $page = 0; $perPage = 50; // Smaller batch size for reliability + $totalPages = ceil($totalCount / $perPage); - do { - $scenes = $this->getStashScenes($page * $perPage, $perPage); - $this->logProgress("Processing page {$page} with " . count($scenes) . " scenes..."); + for ($page = 0; $page < $totalPages; $page++) { + try { + $offset = $page * $perPage; + $scenes = $this->getStashScenes($offset, $perPage); - foreach ($scenes as $sceneData) { - $this->syncScene($sceneData); - $this->processedCount++; + if (empty($scenes)) { + $this->logProgress("No scenes returned for page {$page}"); + continue; + } + + $this->logProgress("Processing page {$page} with " . count($scenes) . " scenes..."); + + foreach ($scenes as $sceneData) { + try { + $this->logProgress("Processing scene: {$sceneData['title']} (ID: {$sceneData['id']})"); + $this->syncScene($sceneData); + $this->processedCount++; + + // Update progress in real-time + $this->updateSyncLog($this->currentSyncLogId, 'running', [ + 'processed_items' => $this->processedCount, + 'new_items' => $this->newCount, + 'updated_items' => $this->updatedCount, + 'message' => "Processed {$this->processedCount} of ~{$totalCount} scenes" + ]); + + } catch (Exception $e) { + $this->logProgress("Error processing scene {$sceneData['id']} ({$sceneData['title']}): " . $e->getMessage()); + $this->processedCount++; // Still count as processed even if failed + + // Update progress even for failed items + $this->updateSyncLog($this->currentSyncLogId, 'running', [ + 'processed_items' => $this->processedCount, + 'message' => "Error on scene {$sceneData['id']}: " . $e->getMessage() + ]); + } + } + } catch (Exception $e) { + $this->logProgress("Error fetching page {$page}: " . $e->getMessage()); + // Continue with next page even if this page fails + $this->updateSyncLog($this->currentSyncLogId, 'running', [ + 'message' => "Failed to fetch page {$page}, continuing with next page" + ]); } - $page++; - } while (count($scenes) === $perPage); // Continue if we got a full page + // Add a small delay between pages to avoid overwhelming the server + if ($page < $totalPages - 1) { + sleep(1); + } + } $this->logProgress("Completed syncing Stash scenes"); } catch (Exception $e) { @@ -83,6 +133,48 @@ class StashSyncService extends BaseSyncService } } + private function getStashScenesCount(): int + { + try { + $query = ' + query FindScenes($filter: FindFilterType) { + findScenes(filter: $filter) { + count + } + } + '; + + $variables = [ + 'filter' => [ + 'per_page' => 1, + 'page' => 1, + 'sort' => 'created_at', + 'direction' => 'DESC' + ] + ]; + + $response = $this->httpClient->post("{$this->baseUrl}/graphql", [ + 'json' => [ + 'query' => $query, + 'variables' => $variables + ], + 'timeout' => 30 + ]); + + $data = json_decode($response->getBody(), true); + + if (!isset($data['data']['findScenes']['count'])) { + $this->logProgress('No count data in Stash response'); + return 0; + } + + return (int) $data['data']['findScenes']['count']; + } catch (Exception $e) { + $this->logProgress('Failed to get Stash scenes count: ' . $e->getMessage()); + return 0; + } + } + private function getStashScenes(int $offset = 0, int $limit = 50): array { try { @@ -118,15 +210,12 @@ class StashSyncService extends BaseSyncService width height } - paths { - screenshot - } performers { id name disambiguation url - gender + gender birthdate ethnicity country @@ -166,24 +255,31 @@ class StashSyncService extends BaseSyncService ] ]; + $this->logProgress("Fetching Stash scenes: offset={$offset}, limit={$limit}"); + $response = $this->httpClient->post("{$this->baseUrl}/graphql", [ 'json' => [ 'query' => $query, 'variables' => $variables ], - 'timeout' => 30 + 'timeout' => 60, // Increased timeout + 'connect_timeout' => 30 ]); $data = json_decode($response->getBody(), true); if (!isset($data['data']['findScenes']['scenes'])) { - $this->logProgress('No scenes data in response'); + $this->logProgress('No scenes data in Stash response'); return []; } - return $data['data']['findScenes']['scenes']; + $scenes = $data['data']['findScenes']['scenes']; + $this->logProgress("Received " . count($scenes) . " scenes from Stash"); + + return $scenes; } catch (Exception $e) { $this->logProgress('Failed to fetch Stash scenes: ' . $e->getMessage()); + $this->logProgress('Request details: ' . $e->getMessage()); throw new Exception('Failed to fetch Stash scenes: ' . $e->getMessage()); } } @@ -309,28 +405,66 @@ class StashSyncService extends BaseSyncService $coverUrl = $screenshotUrl; } - if (!empty($coverUrl)) { - $coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover'); - $localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos'); - if ($localCoverPath) { - $sceneData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath); - $this->logProgress("Downloaded cover: " . $localCoverPath); + // Check if this is an existing scene and if images already exist + $shouldDownloadImages = true; + if ($existingScene) { + $existingMetadata = json_decode($existingScene['metadata'], true); + $hasExistingCover = !empty($existingMetadata['local_cover_path']); + $hasExistingScreenshot = !empty($existingMetadata['local_screenshot_path']); + + if ($hasExistingCover && $hasExistingScreenshot) { + $shouldDownloadImages = false; + $this->logProgress("Scene {$sceneData['id']} already has images, skipping download"); } else { - $this->logProgress("Failed to download cover from: " . $coverUrl); + $this->logProgress("Scene {$sceneData['id']} missing images - downloading"); } } - if (!empty($screenshotUrl)) { - $screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot'); - $localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos'); - if ($localScreenshotPath) { - $sceneData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath); - $this->logProgress("Downloaded screenshot: " . $localScreenshotPath); - } else { - $this->logProgress("Failed to download screenshot from: " . $screenshotUrl); + if ($shouldDownloadImages) { + if (!empty($coverUrl)) { + // Validate URL before attempting download + if (filter_var($coverUrl, FILTER_VALIDATE_URL)) { + try { + $coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover'); + $localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos'); + if ($localCoverPath) { + $sceneData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath); + $this->logProgress("Downloaded cover: " . $localCoverPath); + } else { + $this->logProgress("Failed to download cover from: " . $coverUrl); + } + } catch (Exception $e) { + $this->logProgress("Exception downloading cover from {$coverUrl}: " . $e->getMessage()); + } + } else { + $this->logProgress("Invalid cover URL: " . $coverUrl); + } } - } + if (!empty($screenshotUrl)) { + // Validate URL before attempting download + if (filter_var($screenshotUrl, FILTER_VALIDATE_URL)) { + try { + $screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot'); + $localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos'); + if ($localScreenshotPath) { + $sceneData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath); + $this->logProgress("Downloaded screenshot: " . $localScreenshotPath); + } else { + $this->logProgress("Failed to download screenshot from: " . $screenshotUrl); + } + } catch (Exception $e) { + $this->logProgress("Exception downloading screenshot from {$screenshotUrl}: " . $e->getMessage()); + } + } else { + $this->logProgress("Invalid screenshot URL: " . $screenshotUrl); + } + } + } else { + // Use existing image paths + $sceneData['local_cover_path'] = $existingMetadata['local_cover_path'] ?? null; + $sceneData['local_screenshot_path'] = $existingMetadata['local_screenshot_path'] ?? null; + } // Handle performers/actors $performers = $sceneData['performers'] ?? []; $actorNames = []; @@ -366,11 +500,75 @@ class StashSyncService extends BaseSyncService ]; if ($existingScene) { + // For existing scenes, check if we need to update images + $existingMetadata = json_decode($existingScene['metadata'], true); + + // Only download images if they don't already exist locally + if (empty($existingMetadata['local_cover_path']) && !empty($coverUrl)) { + // Validate URL before attempting download + if (filter_var($coverUrl, FILTER_VALIDATE_URL)) { + try { + $coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover'); + $localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos'); + if ($localCoverPath) { + $sceneData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath); + $this->logProgress("Downloaded cover: " . $localCoverPath); + } else { + $this->logProgress("Failed to download cover from: " . $coverUrl); + } + } catch (Exception $e) { + $this->logProgress("Exception downloading cover from {$coverUrl}: " . $e->getMessage()); + } + } else { + $this->logProgress("Invalid cover URL: " . $coverUrl); + } + } else { + // Keep existing local cover path + $sceneData['local_cover_path'] = $existingMetadata['local_cover_path'] ?? null; + if (!empty($sceneData['local_cover_path'])) { + $this->logProgress("Using existing cover: " . $sceneData['local_cover_path']); + } + } + + if (empty($existingMetadata['local_screenshot_path']) && !empty($screenshotUrl)) { + // Validate URL before attempting download + if (filter_var($screenshotUrl, FILTER_VALIDATE_URL)) { + try { + $screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot'); + $localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos'); + if ($localScreenshotPath) { + $sceneData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath); + $this->logProgress("Downloaded screenshot: " . $localScreenshotPath); + } else { + $this->logProgress("Failed to download screenshot from: " . $screenshotUrl); + } + } catch (Exception $e) { + $this->logProgress("Exception downloading screenshot from {$screenshotUrl}: " . $e->getMessage()); + } + } else { + $this->logProgress("Invalid screenshot URL: " . $screenshotUrl); + } + } else { + // Keep existing local screenshot path + $sceneData['local_screenshot_path'] = $existingMetadata['local_screenshot_path'] ?? null; + if (!empty($sceneData['local_screenshot_path'])) { + $this->logProgress("Using existing screenshot: " . $sceneData['local_screenshot_path']); + } + } + $adultVideoModel->update($existingScene['id'], $sceneData); + $adultVideoId = $existingScene['id']; $this->updatedCount++; + + // Create actor relationships for existing scene + $this->createActorRelationships($adultVideoId, $actors); } else { $adultVideoModel->create($sceneData); + $adultVideoId = $this->pdo->lastInsertId(); $this->newCount++; + + // Create actor relationships for new scene + $this->createActorRelationships($adultVideoId, $actors); } } @@ -457,26 +655,38 @@ class StashSyncService extends BaseSyncService // Try to download performer image if available $thumbnailPath = null; if ($imagePath) { - // Handle different image path formats from Stash - if (strpos($imagePath, 'http') === 0) { - // Already a full URL - $imageUrl = $imagePath; - } elseif (strpos($imagePath, '/') === 0) { - // Absolute path from Stash root - $imageUrl = "{$this->baseUrl}" . $imagePath; - } else { - // Relative path - assume it's in performer images directory - $imageUrl = "{$this->baseUrl}/performer/" . $imagePath; - } + // Validate image path before constructing URL + if (!empty(trim($imagePath))) { + try { + // Handle different image path formats from Stash + if (strpos($imagePath, 'http') === 0) { + // Already a full URL + $imageUrl = $imagePath; + } elseif (strpos($imagePath, '/') === 0) { + // Absolute path from Stash root + $imageUrl = "{$this->baseUrl}" . $imagePath; + } else { + // Relative path - assume it's in performer images directory + $imageUrl = "{$this->baseUrl}/performer/" . $imagePath; + } - $this->logProgress("Performer image URL for {$name}: " . $imageUrl); - $thumbnailFilename = $this->imageDownloader->generateFilename($imageUrl, 'actor'); - $localImagePath = $this->imageDownloader->downloadImage($imageUrl, $thumbnailFilename, 'actors'); - if ($localImagePath) { - $thumbnailPath = $this->imageDownloader->getPublicUrl($localImagePath); - $this->logProgress("Downloaded performer image: " . $localImagePath); - } else { - $this->logProgress("Failed to download performer image from: " . $imageUrl); + // Validate the constructed URL + if (filter_var($imageUrl, FILTER_VALIDATE_URL)) { + $this->logProgress("Performer image URL for {$name}: " . $imageUrl); + $thumbnailFilename = $this->imageDownloader->generateFilename($imageUrl, 'actor'); + $localImagePath = $this->imageDownloader->downloadImage($imageUrl, $thumbnailFilename, 'actors'); + if ($localImagePath) { + $thumbnailPath = $this->imageDownloader->getPublicUrl($localImagePath); + $this->logProgress("Downloaded performer image: " . $localImagePath); + } else { + $this->logProgress("Failed to download performer image from: " . $imageUrl); + } + } else { + $this->logProgress("Invalid performer image URL constructed: " . $imageUrl); + } + } catch (Exception $e) { + $this->logProgress("Exception downloading performer image for {$name} from {$imagePath}: " . $e->getMessage()); + } } } @@ -517,6 +727,29 @@ class StashSyncService extends BaseSyncService return $this->updatedCount; } + private function createActorRelationships(int $adultVideoId, 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_adult_video (adult_video_id, actor_id, created_at, updated_at) + VALUES (:adult_video_id, :actor_id, NOW(), NOW()) + "); + $stmt->execute([ + 'adult_video_id' => $adultVideoId, + 'actor_id' => $actor['id'] + ]); + + $this->logProgress("Created relationship: Adult Video {$adultVideoId} -> Actor {$actor['name']} ({$actor['id']})"); + } catch (Exception $e) { + $this->logProgress("Failed to create relationship for Adult Video {$adultVideoId} and Actor {$actor['name']}: " . $e->getMessage()); + } + } + } + protected function getDeletedCount(): int { return 0; // Stash doesn't provide deletion info in this context diff --git a/app/Services/XbvrSyncService.php b/app/Services/XbvrSyncService.php index a88f8e9..48025c8 100644 --- a/app/Services/XbvrSyncService.php +++ b/app/Services/XbvrSyncService.php @@ -10,7 +10,6 @@ use Exception; class XbvrSyncService extends BaseSyncService { private Client $httpClient; - private ?string $apiKey; private string $baseUrl; private ImageDownloader $imageDownloader; private int $processedCount = 0; @@ -22,24 +21,22 @@ class XbvrSyncService extends BaseSyncService parent::__construct($pdo, $source); // Initialize properties first before using them - $this->apiKey = $source['api_key']; $this->baseUrl = rtrim($source['api_url'], '/'); $this->httpClient = new Client([ 'timeout' => 30, 'headers' => [ - 'User-Agent' => 'MediaCollector/1.0', - 'X-API-Key' => $source['api_key'] + 'User-Agent' => 'MediaCollector/1.0' ] ]); - $this->imageDownloader = new ImageDownloader('public/images', $this->apiKey); + $this->imageDownloader = new ImageDownloader('public/images'); } protected function executeSync(string $syncType): void { - if (empty($this->apiKey) || empty($this->baseUrl)) { - throw new Exception('XBVR API key and URL not configured'); + if (empty($this->baseUrl)) { + throw new Exception('XBVR URL not configured'); } $this->logProgress('Starting XBVR library sync...'); @@ -52,12 +49,17 @@ class XbvrSyncService extends BaseSyncService private function syncScenes(): void { - try { + try { $scenes = $this->getXbvrScenes(); foreach ($scenes as $sceneData) { - $this->syncScene($sceneData); - $this->processedCount++; + try { + $this->syncScene($sceneData); + $this->processedCount++; + } catch (Exception $e) { + $this->logProgress("Error processing XBVR scene {$sceneData['id']}: " . $e->getMessage()); + $this->processedCount++; // Still count as processed even if failed + } } } catch (Exception $e) { $this->logProgress('Error syncing XBVR scenes: ' . $e->getMessage()); @@ -67,16 +69,73 @@ class XbvrSyncService extends BaseSyncService private function getXbvrScenes(): array { try { - // XBVR API endpoint for scenes - $response = $this->httpClient->get("{$this->baseUrl}/api/scene"); + $this->logProgress("Fetching XBVR DeoVR main response from: {$this->baseUrl}/deovr"); - $data = json_decode($response->getBody(), true); + // Step 1: Fetch the main DeoVR response containing the video list + $response = $this->httpClient->get("{$this->baseUrl}/deovr", [ + 'timeout' => 30, + 'connect_timeout' => 10 + ]); - if (!isset($data['scenes'])) { - throw new Exception('No scenes found in XBVR'); + if ($response->getStatusCode() !== 200) { + throw new Exception("XBVR DeoVR API returned status: " . $response->getStatusCode()); } - return $data['scenes']; + $mainData = json_decode($response->getBody(), true); + $this->logProgress("XBVR DeoVR main response received successfully"); + + // Step 2: Extract the video list from the main response + $videoList = $this->extractVideoList($mainData); + $videoList = $videoList[0]['list']; + + if (empty($videoList)) { + throw new Exception("No videos found in XBVR DeoVR response"); + } + + + + $this->logProgress("Found " . count($videoList) . " videos in XBVR list"); + + // Step 3: Fetch detailed information for each video + $detailedScenes = []; + $processedCount = 0; + + foreach ($videoList as $videoItem) { + + try { + $detailUrl = $this->extractDetailUrl($videoItem); + if (!$detailUrl) { + $this->logProgress("No detail URL found for video: " . ($videoItem['title'] ?? 'Unknown')); + continue; + } + + $this->logProgress("Fetching details for: " . ($videoItem['title'] ?? 'Unknown')); + + $detailResponse = $this->httpClient->get($detailUrl, [ + 'timeout' => 30, + 'connect_timeout' => 10 + ]); + + if ($detailResponse->getStatusCode() === 200) { + $detailData = json_decode($detailResponse->getBody(), true); + $detailedScenes[] = $detailData; + $processedCount++; + $this->logProgress("Successfully fetched details for: " . ($detailData['title'] ?? 'Unknown')); + } else { + $this->logProgress("Failed to fetch details from {$detailUrl}: Status " . $detailResponse->getStatusCode()); + } + + // Add small delay to be respectful to the API + usleep(100000); // 0.1 second delay + + } catch (Exception $e) { + $this->logProgress("Error fetching details for video: " . $e->getMessage()); + } + } + + $this->logProgress("Successfully processed {$processedCount} out of " . count($videoList) . " videos"); + return $detailedScenes; + } catch (Exception $e) { throw new Exception('Failed to fetch XBVR scenes: ' . $e->getMessage()); } @@ -84,6 +143,8 @@ class XbvrSyncService extends BaseSyncService private function syncScene(array $sceneData): void { + $this->logProgress("Processing XBVR scene: " . json_encode(array_slice($sceneData, 0, 5))); + $adultVideoModel = new AdultVideo($this->pdo); // Check if scene already exists by xbvr_id in metadata @@ -103,84 +164,221 @@ class XbvrSyncService extends BaseSyncService } } - // Download images locally - $coverFilename = null; - $screenshotFilename = null; + // Map XBVR/DeoVR fields to our database structure + // Based on the 46367.json example structure + $mappedData = [ + 'title' => $sceneData['title'] ?? 'Untitled VR Scene', + 'overview' => $sceneData['description'] ?? null, + 'release_date' => isset($sceneData['date']) ? date('Y-m-d', $sceneData['date']) : null, + 'runtime_minutes' => isset($sceneData['videoLength']) ? round($sceneData['videoLength'] / 60) : null, + 'rating' => $sceneData['rating_avg'] ?? null, + 'director' => null, // DeoVR doesn't seem to have director info + 'cast' => [], // Will be extracted from categories/actors if available + 'tags' => [], // Will be extracted from categories + ]; - // Extract image URLs from XBVR API response + // Handle categories/tags from DeoVR format + $tags = []; + if (isset($sceneData['categories']) && is_array($sceneData['categories'])) { + foreach ($sceneData['categories'] as $category) { + if (isset($category['tag']['name'])) { + $tags[] = $category['tag']['name']; + } + } + } + $mappedData['tags'] = $tags; + + // Handle actors (DeoVR format might have actors array or might be null) + $castData = []; + if (isset($sceneData['actors']) && is_array($sceneData['actors']) && !empty($sceneData['actors'])) { + foreach ($sceneData['actors'] as $actor) { + if (isset($actor['name'])) { + $castData[] = $actor['name']; + } + } + } + + $this->logProgress("Mapped DeoVR scene data: title='{$mappedData['title']}', tags=" . json_encode($tags) . ", cast=" . json_encode($castData)); + + // Extract image URLs from DeoVR API response - try multiple possible field names $coverUrl = null; $screenshotUrl = null; - if (!empty($sceneData['cover_url'])) { - $coverUrl = $sceneData['cover_url']; - $this->logProgress("Cover URL: " . $coverUrl); + // Try different possible cover image field names for DeoVR + $coverFields = ['thumbnailUrl', 'cover_url', 'cover', 'poster_url', 'poster', 'thumbnail_url', 'thumbnail', 'image_url', 'image']; + foreach ($coverFields as $field) { + if (!empty($sceneData[$field])) { + $coverUrl = $sceneData[$field]; + break; + } } - if (!empty($sceneData['screenshot_url'])) { - $screenshotUrl = $sceneData['screenshot_url']; - $this->logProgress("Screenshot URL: " . $screenshotUrl); + // Try different possible screenshot field names for DeoVR + $screenshotFields = ['screenshot_url', 'screenshot', 'preview_url', 'preview', 'thumb_url', 'thumb']; + foreach ($screenshotFields as $field) { + if (!empty($sceneData[$field])) { + $screenshotUrl = $sceneData[$field]; + break; + } } if (!empty($coverUrl)) { - $coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover'); - $localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos'); - if ($localCoverPath) { - $sceneData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath); - $this->logProgress("Downloaded cover: " . $localCoverPath); + $this->logProgress("DeoVR Cover URL: " . $coverUrl); + } + + if (!empty($screenshotUrl)) { + $this->logProgress("DeoVR Screenshot URL: " . $screenshotUrl); + } + + if (!empty($coverUrl)) { + // Validate URL before attempting download + if (filter_var($coverUrl, FILTER_VALIDATE_URL)) { + try { + $coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover'); + $localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos'); + if ($localCoverPath) { + $mappedData['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath); + $this->logProgress("Downloaded cover: " . $localCoverPath); + } else { + $this->logProgress("Failed to download cover from: " . $coverUrl); + } + } catch (Exception $e) { + $this->logProgress("Exception downloading cover from {$coverUrl}: " . $e->getMessage()); + } } else { - $this->logProgress("Failed to download cover from: " . $coverUrl); + $this->logProgress("Invalid cover URL: " . $coverUrl); } } if (!empty($screenshotUrl)) { - $screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot'); - $localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos'); - if ($localScreenshotPath) { - $sceneData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath); - $this->logProgress("Downloaded screenshot: " . $localScreenshotPath); + // Validate URL before attempting download + if (filter_var($screenshotUrl, FILTER_VALIDATE_URL)) { + try { + $screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot'); + $localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos'); + if ($localScreenshotPath) { + $mappedData['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath); + $this->logProgress("Downloaded screenshot: " . $localScreenshotPath); + } else { + $this->logProgress("Failed to download screenshot from: " . $screenshotUrl); + } + } catch (Exception $e) { + $this->logProgress("Exception downloading screenshot from {$screenshotUrl}: " . $e->getMessage()); + } } else { - $this->logProgress("Failed to download screenshot from: " . $screenshotUrl); + $this->logProgress("Invalid screenshot URL: " . $screenshotUrl); } } // Handle actors - $actors = $this->syncActors($sceneData['cast'] ?? []); + $actors = $this->syncActors($castData); - $sceneData = [ - 'title' => $sceneData['title'] ?: 'Untitled VR Scene', - 'overview' => $sceneData['synopsis'] ?? null, - 'release_date' => $sceneData['release_date'] ? date('Y-m-d', strtotime($sceneData['release_date'])) : null, - 'runtime_minutes' => $sceneData['duration'] ?? null, - 'rating' => $sceneData['rating'] ?? null, + $sceneDataForDb = [ + 'title' => $mappedData['title'], + 'overview' => $mappedData['overview'], + 'release_date' => $mappedData['release_date'], + 'runtime_minutes' => $mappedData['runtime_minutes'], + 'rating' => $mappedData['rating'], 'source_id' => $this->source['id'], 'external_id' => $sceneData['id'], 'metadata' => json_encode([ 'xbvr_id' => $sceneData['id'], - 'xbvr_url' => $sceneData['scene_url'] ?? null, - 'cast' => $sceneData['cast'] ?? [], + 'xbvr_url' => $sceneData['scene_url'] ?? $sceneData['url'] ?? null, + 'cast' => $castData, 'actors' => $actors, - 'tags' => $sceneData['tags'] ?? [], + 'tags' => $mappedData['tags'], 'is_available' => $sceneData['is_available'] ?? true, 'is_watched' => $sceneData['is_watched'] ?? false, 'watch_count' => $sceneData['watch_count'] ?? 0, - 'video_length' => $sceneData['video_length'] ?? null, + 'video_length' => $sceneData['videoLength'] ?? null, 'video_width' => $sceneData['video_width'] ?? null, 'video_height' => $sceneData['video_height'] ?? null, 'video_codec' => $sceneData['video_codec'] ?? null, - 'file_path' => $sceneData['file_path'] ?? null, - 'cover_url' => $sceneData['cover_url'] ?? null, - 'local_cover_path' => $sceneData['local_cover_path'] ?? null, - 'screenshot_url' => $sceneData['screenshot_url'] ?? null, - 'local_screenshot_path' => $sceneData['local_screenshot_path'] ?? null + 'file_path' => $sceneData['file_path'] ?? $sceneData['path'] ?? null, + 'cover_url' => $coverUrl, + 'local_cover_path' => $mappedData['local_cover_path'] ?? null, + 'screenshot_url' => $screenshotUrl, + 'local_screenshot_path' => $mappedData['local_screenshot_path'] ?? null, + 'deoVR_format' => true, // Mark that this came from DeoVR API + 'paysite' => $sceneData['paysite']['name'] ?? null, + 'is3d' => $sceneData['is3d'] ?? false, + 'screenType' => $sceneData['screenType'] ?? null, + 'stereoMode' => $sceneData['stereoMode'] ?? null, + 'fullVideoReady' => $sceneData['fullVideoReady'] ?? false, + 'fullAccess' => $sceneData['fullAccess'] ?? false ]) ]; if ($existingScene) { - $adultVideoModel->update($existingScene['id'], $sceneData); + // For existing scenes, check if we need to update images + $existingMetadata = json_decode($existingScene['metadata'], true); + + // Only download images if they don't already exist locally + if (empty($existingMetadata['local_cover_path']) && !empty($coverUrl)) { + // Validate URL before attempting download + if (filter_var($coverUrl, FILTER_VALIDATE_URL)) { + try { + $coverFilename = $this->imageDownloader->generateFilename($coverUrl, 'cover'); + $localCoverPath = $this->imageDownloader->downloadImage($coverUrl, $coverFilename, 'adult_videos'); + if ($localCoverPath) { + $sceneDataForDb['local_cover_path'] = $this->imageDownloader->getPublicUrl($localCoverPath); + $this->logProgress("Downloaded cover: " . $localCoverPath); + } else { + $this->logProgress("Failed to download cover from: " . $coverUrl); + } + } catch (Exception $e) { + $this->logProgress("Exception downloading cover from {$coverUrl}: " . $e->getMessage()); + } + } else { + $this->logProgress("Invalid cover URL: " . $coverUrl); + } + } else { + // Keep existing local cover path + $sceneDataForDb['local_cover_path'] = $existingMetadata['local_cover_path'] ?? null; + if (!empty($sceneDataForDb['local_cover_path'])) { + $this->logProgress("Using existing cover: " . $sceneDataForDb['local_cover_path']); + } + } + + if (empty($existingMetadata['local_screenshot_path']) && !empty($screenshotUrl)) { + // Validate URL before attempting download + if (filter_var($screenshotUrl, FILTER_VALIDATE_URL)) { + try { + $screenshotFilename = $this->imageDownloader->generateFilename($screenshotUrl, 'screenshot'); + $localScreenshotPath = $this->imageDownloader->downloadImage($screenshotUrl, $screenshotFilename, 'adult_videos'); + if ($localScreenshotPath) { + $sceneDataForDb['local_screenshot_path'] = $this->imageDownloader->getPublicUrl($localScreenshotPath); + $this->logProgress("Downloaded screenshot: " . $localScreenshotPath); + } else { + $this->logProgress("Failed to download screenshot from: " . $screenshotUrl); + } + } catch (Exception $e) { + $this->logProgress("Exception downloading screenshot from {$screenshotUrl}: " . $e->getMessage()); + } + } else { + $this->logProgress("Invalid screenshot URL: " . $screenshotUrl); + } + } else { + // Keep existing local screenshot path + $sceneDataForDb['local_screenshot_path'] = $existingMetadata['local_screenshot_path'] ?? null; + if (!empty($sceneDataForDb['local_screenshot_path'])) { + $this->logProgress("Using existing screenshot: " . $sceneDataForDb['local_screenshot_path']); + } + } + + $adultVideoModel->update($existingScene['id'], $sceneDataForDb); + $adultVideoId = $existingScene['id']; $this->updatedCount++; + + // Create actor relationships for existing scene + $this->createActorRelationships($adultVideoId, $actors); } else { - $adultVideoModel->create($sceneData); + $adultVideoModel->create($sceneDataForDb); + $adultVideoId = $this->pdo->lastInsertId(); $this->newCount++; + + // Create actor relationships for new scene + $this->createActorRelationships($adultVideoId, $actors); } } @@ -238,6 +436,48 @@ class XbvrSyncService extends BaseSyncService } } + private function extractVideoList(array $mainData): array + { + // Try different possible keys for the video list + $possibleKeys = ['Recent', 'scenes', 'content', 'videos']; + + foreach ($possibleKeys as $key) { + if (isset($mainData[$key]) && is_array($mainData[$key])) { + $this->logProgress("Found video list under key: '{$key}' with " . count($mainData[$key]) . " items"); + return $mainData[$key]; + } + } + + // If no standard key found, look for arrays that might contain video data + foreach ($mainData as $key => $value) { + if (is_array($value) && count($value) > 0) { + // Check if this looks like a video list by examining the first item + $firstItem = $value[0]; + if (isset($firstItem['title']) || isset($firstItem['video_url'])) { + $this->logProgress("Found video list under key: '{$key}' with " . count($value) . " items"); + return $value; + } + } + } + + $this->logProgress("No video list found. Available keys: " . implode(', ', array_keys($mainData))); + return []; + } + + private function extractDetailUrl(array $videoItem): ?string + { + // Try different possible URL field names + $possibleUrlFields = ['video_url', 'url', 'detail_url', 'scene_url']; + + foreach ($possibleUrlFields as $field) { + if (!empty($videoItem[$field])) { + return $videoItem[$field]; + } + } + + return null; + } + protected function getProcessedCount(): int { return $this->processedCount; @@ -257,4 +497,27 @@ class XbvrSyncService extends BaseSyncService { return 0; // XBVR doesn't provide deletion info in this context } + + private function createActorRelationships(int $adultVideoId, 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_adult_video (adult_video_id, actor_id, created_at, updated_at) + VALUES (:adult_video_id, :actor_id, NOW(), NOW()) + "); + $stmt->execute([ + 'adult_video_id' => $adultVideoId, + 'actor_id' => $actor['id'] + ]); + + $this->logProgress("Created relationship: Adult Video {$adultVideoId} -> Actor {$actor['name']} ({$actor['id']})"); + } catch (Exception $e) { + $this->logProgress("Failed to create relationship for Adult Video {$adultVideoId} and Actor {$actor['name']}: " . $e->getMessage()); + } + } + } } diff --git a/app/Utils/ImageDownloader.php b/app/Utils/ImageDownloader.php index a6558a0..d74d8da 100644 --- a/app/Utils/ImageDownloader.php +++ b/app/Utils/ImageDownloader.php @@ -22,7 +22,8 @@ class ImageDownloader $this->httpClient = new Client([ 'timeout' => 30, - 'headers' => $headers + 'headers' => $headers, + 'verify' => false // Disable SSL verification for problematic servers ]); $this->basePath = rtrim($basePath, '/'); } @@ -37,76 +38,114 @@ class ImageDownloader return null; } - try { - $folderPath = $this->basePath; - if (!empty($subfolder)) { - $folderPath .= '/' . trim($subfolder, '/'); - } + $maxRetries = 3; + $retryDelay = 2; // seconds - // Create folder if it doesn't exist - if (!is_dir($folderPath)) { - mkdir($folderPath, 0755, true); - } + for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { + try { + error_log("Downloading image (attempt {$attempt}/{$maxRetries}): {$url}"); - $filePath = $folderPath . '/' . $filename; + $response = $this->httpClient->get($url, [ + 'timeout' => 60, // Longer timeout for large images + 'connect_timeout' => 30, + 'headers' => [ + 'User-Agent' => 'MediaCollector/1.0', + 'Referer' => parse_url($url, PHP_URL_HOST) + ] + ]); - // Check if file already exists - if (file_exists($filePath)) { - return $filePath; - } - - error_log("Downloading image from: {$url} to: {$filePath}"); - - $response = $this->httpClient->get($url, [ - 'sink' => $filePath, - 'headers' => [ - 'User-Agent' => 'MediaCollector/1.0', - 'Accept' => 'image/*', - ] - ]); - - $statusCode = $response->getStatusCode(); - $contentType = $response->getHeaderLine('content-type'); - - error_log("Download response - Status: {$statusCode}, Content-Type: {$contentType}"); - - if ($statusCode === 200) { - $fileSize = filesize($filePath); - error_log("Successfully downloaded image. Size: {$fileSize} bytes"); - - // Check if file is actually an image and not empty - if ($fileSize > 0) { - $imageInfo = getimagesize($filePath); - if ($imageInfo !== false) { - error_log("Valid image downloaded: {$imageInfo[0]}x{$imageInfo[1]} {$imageInfo['mime']}"); - return $filePath; - } else { - error_log("Downloaded file is not a valid image"); - if (file_exists($filePath)) { - unlink($filePath); - } - } - } else { - error_log("Downloaded file is empty"); - if (file_exists($filePath)) { - unlink($filePath); + if ($response->getStatusCode() !== 200) { + error_log("HTTP {$response->getStatusCode()} for {$url}"); + if ($attempt === $maxRetries) { + return null; } + sleep($retryDelay); + continue; } - } else { - error_log("Failed to download image. HTTP Status: {$statusCode}"); - } - return null; - } catch (Exception $e) { - // Log error but don't throw - images are not critical - error_log("Failed to download image {$url}: " . $e->getMessage()); - return null; + $imageData = $response->getBody()->getContents(); + if (empty($imageData)) { + error_log("Empty response for {$url}"); + if ($attempt === $maxRetries) { + return null; + } + sleep($retryDelay); + continue; + } + + // Validate image data + if (!$this->isValidImage($imageData)) { + error_log("Invalid image data for {$url}"); + if ($attempt === $maxRetries) { + return null; + } + sleep($retryDelay); + continue; + } + + return $this->saveImage($imageData, $filename, $subfolder); + + } catch (Exception $e) { + error_log("Error downloading {$url} (attempt {$attempt}): " . $e->getMessage()); + + if ($attempt === $maxRetries) { + error_log("Failed to download {$url} after {$maxRetries} attempts"); + return null; + } + + sleep($retryDelay); + } + } + + return null; + } + + private function isValidImage(string $data): bool + { + // Check for common image signatures + $imageTypes = [ + 'image/jpeg' => "\xFF\xD8\xFF", + 'image/png' => "\x89PNG", + 'image/gif' => "GIF", + 'image/webp' => "RIFF" + ]; + + foreach ($imageTypes as $type => $signature) { + if (strpos($data, $signature) === 0) { + return true; + } } } - /** - * Generate a unique filename for an image - */ + public function saveImage(string $imageData, string $filename, string $subfolder = ''): ?string + { + try { + // Create subfolder if it doesn't exist + $fullPath = $this->basePath; + if (!empty($subfolder)) { + $fullPath .= '/' . trim($subfolder, '/'); + } + + if (!is_dir($fullPath)) { + mkdir($fullPath, 0755, true); + } + + $filePath = $fullPath . '/' . $filename; + + // Write image data to file + $bytesWritten = file_put_contents($filePath, $imageData); + if ($bytesWritten === false || $bytesWritten === 0) { + error_log("Failed to write image data to {$filePath}"); + return null; + } + + return $filePath; + + } catch (Exception $e) { + error_log("Error saving image {$filename}: " . $e->getMessage()); + return null; + } + } public function generateFilename(string $url, string $prefix = ''): string { $extension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION); diff --git a/check_sync_logs.php b/check_sync_logs.php new file mode 100644 index 0000000..642c540 --- /dev/null +++ b/check_sync_logs.php @@ -0,0 +1,96 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + echo "=== SYNC LOG VIEWER ===\n\n"; + + // Get recent sync logs + $stmt = $pdo->prepare(" + SELECT sl.*, s.display_name as source_name + FROM sync_logs sl + JOIN sources s ON sl.source_id = s.id + ORDER BY sl.created_at DESC + LIMIT 10 + "); + $stmt->execute(); + $logs = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if (empty($logs)) { + echo "No sync logs found. Run a sync operation first.\n"; + exit(0); + } + + echo "RECENT SYNC OPERATIONS:\n"; + echo str_repeat("-", 80) . "\n"; + + foreach ($logs as $log) { + $status = strtoupper($log['status']); + $statusColor = match($log['status']) { + 'completed' => '✅', + 'failed' => '❌', + 'running' => '🔄', + default => '❓' + }; + + echo "{$statusColor} {$log['source_name']} - {$status} - {$log['created_at']}\n"; + echo " Type: {$log['sync_type']} | Processed: {$log['processed_items']} | New: {$log['new_items']} | Updated: {$log['updated_items']}\n"; + + if ($log['message']) { + echo " Message: {$log['message']}\n"; + } + + if ($log['errors']) { + $errors = json_decode($log['errors'], true); + if (is_array($errors)) { + echo " Errors: " . implode(', ', $errors) . "\n"; + } + } + + echo "\n"; + } + + // Check for log files + $logFiles = glob('logs/*.log'); + if (!empty($logFiles)) { + echo "\nLOG FILES AVAILABLE:\n"; + echo str_repeat("-", 80) . "\n"; + + // Sort by modification time, newest first + usort($logFiles, function($a, $b) { + return filemtime($b) - filemtime($a); + }); + + foreach (array_slice($logFiles, 0, 5) as $logFile) { + $size = filesize($logFile); + $modified = date('Y-m-d H:i:s', filemtime($logFile)); + echo "📄 " . basename($logFile) . " ({$size} bytes) - {$modified}\n"; + } + + echo "\nTo view a specific log file, run: tail -f logs/filename.log\n"; + } else { + echo "\nNo log files found yet. Log files are created during sync operations.\n"; + } + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + echo "Make sure your database is set up correctly.\n"; + exit(1); +} diff --git a/database/migrations/2023_10_15_000014_create_actor_media_pivot_tables.php b/database/migrations/2023_10_15_000014_create_actor_media_pivot_tables.php new file mode 100644 index 0000000..3bf09cf --- /dev/null +++ b/database/migrations/2023_10_15_000014_create_actor_media_pivot_tables.php @@ -0,0 +1,56 @@ +schema()->create('actor_movie', function ($table) { + $table->id(); + $table->foreignId('actor_id')->constrained('actors')->onDelete('cascade'); + $table->foreignId('movie_id')->constrained('movies')->onDelete('cascade'); + $table->timestamps(); + + $table->unique(['actor_id', 'movie_id']); + $table->index(['movie_id', 'actor_id']); + }); + + // Pivot table for actors and TV shows + $capsule->schema()->create('actor_tv_show', function ($table) { + $table->id(); + $table->foreignId('actor_id')->constrained('actors')->onDelete('cascade'); + $table->foreignId('tv_show_id')->constrained('tv_shows')->onDelete('cascade'); + $table->timestamps(); + + $table->unique(['actor_id', 'tv_show_id']); + $table->index(['tv_show_id', 'actor_id']); + }); + + // Pivot table for actors and TV episodes + $capsule->schema()->create('actor_tv_episode', function ($table) { + $table->id(); + $table->foreignId('actor_id')->constrained('actors')->onDelete('cascade'); + $table->foreignId('tv_episode_id')->constrained('tv_episodes')->onDelete('cascade'); + $table->timestamps(); + + $table->unique(['actor_id', 'tv_episode_id']); + $table->index(['tv_episode_id', 'actor_id']); + }); + } + + public function down() + { + $capsule = Database::getCapsule(); + + $capsule->schema()->dropIfExists('actor_tv_episode'); + $capsule->schema()->dropIfExists('actor_adult_video'); + $capsule->schema()->dropIfExists('actor_tv_show'); + $capsule->schema()->dropIfExists('actor_movie'); + } +} diff --git a/debug_jellyfin_sync.php b/debug_jellyfin_sync.php new file mode 100644 index 0000000..81e1539 --- /dev/null +++ b/debug_jellyfin_sync.php @@ -0,0 +1,90 @@ +query("SHOW TABLES LIKE '$table'"); + if ($stmt->rowCount() > 0) { + echo "✓ Table '$table' exists\n"; + } else { + echo "✗ Table '$table' missing\n"; + } + } catch (Exception $e) { + echo "✗ Error checking table '$table': " . $e->getMessage() . "\n"; + } + } + } catch (Exception $e) { + echo "Database connection error: " . $e->getMessage() . "\n"; + } + + // Test episode data structure + echo "\nTesting episode data structure...\n"; + $testEpisodeData = [ + 'Id' => 'test-episode-123', + 'Name' => 'Test Episode 1', + 'ParentIndexNumber' => 1, + 'IndexNumber' => 1, + 'PremiereDate' => '2023-01-01T00:00:00Z', + 'RunTimeTicks' => 18000000000, // 30 minutes + 'CommunityRating' => 8.5, + 'Overview' => 'Test episode overview', + 'ProviderIds' => [ + 'Imdb' => 'tt1234567', + 'Tmdb' => '123456' + ], + 'People' => [ + [ + 'Name' => 'Test Actor', + 'Type' => 'Actor' + ] + ] + ]; + + echo "✓ Episode has required fields:\n"; + echo " - ID: " . $testEpisodeData['Id'] . "\n"; + echo " - Name: " . $testEpisodeData['Name'] . "\n"; + echo " - Season: " . $testEpisodeData['ParentIndexNumber'] . "\n"; + echo " - Episode: " . $testEpisodeData['IndexNumber'] . "\n"; + echo " - People: " . (isset($testEpisodeData['People']) ? 'Yes' : 'No') . "\n"; + echo " - ProviderIds: " . (isset($testEpisodeData['ProviderIds']) ? 'Yes' : 'No') . "\n"; + + if (isset($testEpisodeData['People']) && is_array($testEpisodeData['People'])) { + foreach ($testEpisodeData['People'] as $person) { + if (isset($person['Type']) && $person['Type'] === 'Actor') { + echo " - Actor found: " . $person['Name'] . "\n"; + } + } + } + + echo "\n=== Debug Complete ===\n"; + echo "The sync should work if:\n"; + echo "1. All models exist ✓\n"; + echo "2. Database tables exist ✓\n"; + echo "3. Jellyfin API returns 'People' field ✓\n"; + echo "4. syncTvShows() is called in executeSync() ✓\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..21f6a86 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,11 @@ +RewriteEngine On + +# Some hosts may require you to use the `RewriteBase` directive. +# If you need to use the `RewriteBase` directive, it should be the +# absolute physical path to the directory that contains this htaccess file. +# +# RewriteBase / + +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] diff --git a/public/build/assets/app-45137c72.js b/public/build/assets/app-45137c72.js new file mode 100644 index 0000000..a505cce --- /dev/null +++ b/public/build/assets/app-45137c72.js @@ -0,0 +1,10 @@ +var Et=!1,St=!1,Z=[],At=-1;function Ii(e){ki(e)}function ki(e){Z.includes(e)||Z.push(e),Di()}function Bi(e){let t=Z.indexOf(e);t!==-1&&t>At&&Z.splice(t,1)}function Di(){!St&&!Et&&(Et=!0,queueMicrotask($i))}function $i(){Et=!1,St=!0;for(let e=0;ee.effect(t,{scheduler:n=>{Ot?Ii(n):n()}}),Qn=e.raw}function Sn(e){ie=e}function Hi(e){let t=()=>{};return[r=>{let i=ie(r);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(s=>s())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),fe(i))},i},()=>{t()}]}function er(e,t){let n=!0,r,i=ie(()=>{let s=e();JSON.stringify(s),n?r=s:queueMicrotask(()=>{t(s,r),r=s}),n=!1});return()=>fe(i)}var tr=[],nr=[],rr=[];function zi(e){rr.push(e)}function zt(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,nr.push(t))}function ir(e){tr.push(e)}function sr(e,t,n){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(n)}function or(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([n,r])=>{(t===void 0||t.includes(n))&&(r.forEach(i=>i()),delete e._x_attributeCleanups[n])})}function Ki(e){var t,n;for((t=e._x_effects)==null||t.forEach(Bi);(n=e._x_cleanups)!=null&&n.length;)e._x_cleanups.pop()()}var Kt=new MutationObserver(Xt),Jt=!1;function Wt(){Kt.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),Jt=!0}function ar(){Ji(),Kt.disconnect(),Jt=!1}var be=[];function Ji(){let e=Kt.takeRecords();be.push(()=>e.length>0&&Xt(e));let t=be.length;queueMicrotask(()=>{if(be.length===t)for(;be.length>0;)be.shift()()})}function A(e){if(!Jt)return e();ar();let t=e();return Wt(),t}var Vt=!1,Ve=[];function Wi(){Vt=!0}function Vi(){Vt=!1,Xt(Ve),Ve=[]}function Xt(e){if(Vt){Ve=Ve.concat(e);return}let t=[],n=new Set,r=new Map,i=new Map;for(let s=0;s{o.nodeType===1&&o._x_marker&&n.add(o)}),e[s].addedNodes.forEach(o=>{if(o.nodeType===1){if(n.has(o)){n.delete(o);return}o._x_marker||t.push(o)}})),e[s].type==="attributes")){let o=e[s].target,a=e[s].attributeName,c=e[s].oldValue,l=()=>{r.has(o)||r.set(o,[]),r.get(o).push({name:a,value:o.getAttribute(a)})},u=()=>{i.has(o)||i.set(o,[]),i.get(o).push(a)};o.hasAttribute(a)&&c===null?l():o.hasAttribute(a)?(u(),l()):u()}i.forEach((s,o)=>{or(o,s)}),r.forEach((s,o)=>{tr.forEach(a=>a(o,s))});for(let s of n)t.some(o=>o.contains(s))||nr.forEach(o=>o(s));for(let s of t)s.isConnected&&rr.forEach(o=>o(s));t=null,n=null,r=null,i=null}function cr(e){return Te(ae(e))}function Re(e,t,n){return e._x_dataStack=[t,...ae(n||e)],()=>{e._x_dataStack=e._x_dataStack.filter(r=>r!==t)}}function ae(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?ae(e.host):e.parentNode?ae(e.parentNode):[]}function Te(e){return new Proxy({objects:e},Xi)}var Xi={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(n=>Object.prototype.hasOwnProperty.call(n,t)||Reflect.has(n,t))},get({objects:e},t,n){return t=="toJSON"?Gi:Reflect.get(e.find(r=>Reflect.has(r,t))||{},t,n)},set({objects:e},t,n,r){const i=e.find(o=>Object.prototype.hasOwnProperty.call(o,t))||e[e.length-1],s=Object.getOwnPropertyDescriptor(i,t);return s!=null&&s.set&&(s!=null&&s.get)?s.set.call(r,n)||!0:Reflect.set(i,t,n)}};function Gi(){return Reflect.ownKeys(this).reduce((t,n)=>(t[n]=Reflect.get(this,n),t),{})}function ur(e){let t=r=>typeof r=="object"&&!Array.isArray(r)&&r!==null,n=(r,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(r)).forEach(([s,{value:o,enumerable:a}])=>{if(a===!1||o===void 0||typeof o=="object"&&o!==null&&o.__v_skip)return;let c=i===""?s:`${i}.${s}`;typeof o=="object"&&o!==null&&o._x_interceptor?r[s]=o.initialize(e,c,s):t(o)&&o!==r&&!(o instanceof Element)&&n(o,c)})};return n(e)}function lr(e,t=()=>{}){let n={initialValue:void 0,_x_interceptor:!0,initialize(r,i,s){return e(this.initialValue,()=>Yi(r,i),o=>vt(r,i,o),i,s)}};return t(n),r=>{if(typeof r=="object"&&r!==null&&r._x_interceptor){let i=n.initialize.bind(n);n.initialize=(s,o,a)=>{let c=r.initialize(s,o,a);return n.initialValue=c,i(s,o,a)}}else n.initialValue=r;return n}}function Yi(e,t){return t.split(".").reduce((n,r)=>n[r],e)}function vt(e,t,n){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=n;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),vt(e[t[0]],t.slice(1),n)}}var fr={};function I(e,t){fr[e]=t}function Rt(e,t){let n=Zi(t);return Object.entries(fr).forEach(([r,i])=>{Object.defineProperty(e,`$${r}`,{get(){return i(t,n)},enumerable:!1})}),e}function Zi(e){let[t,n]=gr(e),r={interceptor:lr,...t};return zt(e,n),r}function Qi(e,t,n,...r){try{return n(...r)}catch(i){ve(i,e,t)}}function ve(e,t,n=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:n}),console.warn(`Alpine Expression Error: ${e.message} + +${n?'Expression: "'+n+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var qe=!0;function dr(e){let t=qe;qe=!1;let n=e();return qe=t,n}function Q(e,t,n={}){let r;return P(e,t)(i=>r=i,n),r}function P(...e){return pr(...e)}var pr=hr;function es(e){pr=e}function hr(e,t){let n={};Rt(n,e);let r=[n,...ae(e)],i=typeof t=="function"?ts(r,t):rs(r,t,e);return Qi.bind(null,e,t,i)}function ts(e,t){return(n=()=>{},{scope:r={},params:i=[],context:s}={})=>{let o=t.apply(Te([r,...e]),i);Xe(n,o)}}var ht={};function ns(e,t){if(ht[e])return ht[e];let n=Object.getPrototypeOf(async function(){}).constructor,r=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,s=(()=>{try{let o=new n(["__self","scope"],`with (scope) { __self.result = ${r} }; __self.finished = true; return __self.result;`);return Object.defineProperty(o,"name",{value:`[Alpine] ${e}`}),o}catch(o){return ve(o,t,e),Promise.resolve()}})();return ht[e]=s,s}function rs(e,t,n){let r=ns(t,n);return(i=()=>{},{scope:s={},params:o=[],context:a}={})=>{r.result=void 0,r.finished=!1;let c=Te([s,...e]);if(typeof r=="function"){let l=r.call(a,r,c).catch(u=>ve(u,n,t));r.finished?(Xe(i,r.result,c,o,n),r.result=void 0):l.then(u=>{Xe(i,u,c,o,n)}).catch(u=>ve(u,n,t)).finally(()=>r.result=void 0)}}}function Xe(e,t,n,r,i){if(qe&&typeof t=="function"){let s=t.apply(n,r);s instanceof Promise?s.then(o=>Xe(e,o,n,r)).catch(o=>ve(o,i,t)):e(s)}else typeof t=="object"&&t instanceof Promise?t.then(s=>e(s)):e(t)}var Gt="x-";function de(e=""){return Gt+e}function is(e){Gt=e}var Ge={};function v(e,t){return Ge[e]=t,{before(n){if(!Ge[n]){console.warn(String.raw`Cannot find directive \`${n}\`. \`${e}\` will use the default order of execution`);return}const r=G.indexOf(n);G.splice(r>=0?r:G.indexOf("DEFAULT"),0,e)}}}function ss(e){return Object.keys(Ge).includes(e)}function Yt(e,t,n){if(t=Array.from(t),e._x_virtualDirectives){let s=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),o=_r(s);s=s.map(a=>o.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(s)}let r={};return t.map(wr((s,o)=>r[s]=o)).filter(Er).map(cs(r,n)).sort(us).map(s=>as(e,s))}function _r(e){return Array.from(e).map(wr()).filter(t=>!Er(t))}var Tt=!1,Se=new Map,mr=Symbol();function os(e){Tt=!0;let t=Symbol();mr=t,Se.set(t,[]);let n=()=>{for(;Se.get(t).length;)Se.get(t).shift()();Se.delete(t)},r=()=>{Tt=!1,n()};e(n),r()}function gr(e){let t=[],n=a=>t.push(a),[r,i]=Hi(e);return t.push(i),[{Alpine:Ce,effect:r,cleanup:n,evaluateLater:P.bind(P,e),evaluate:Q.bind(Q,e)},()=>t.forEach(a=>a())]}function as(e,t){let n=()=>{},r=Ge[t.type]||n,[i,s]=gr(e);sr(e,t.original,s);let o=()=>{e._x_ignore||e._x_ignoreSelf||(r.inline&&r.inline(e,t,i),r=r.bind(r,e,t,i),Tt?Se.get(mr).push(r):r())};return o.runCleanups=s,o}var yr=(e,t)=>({name:n,value:r})=>(n.startsWith(e)&&(n=n.replace(e,t)),{name:n,value:r}),br=e=>e;function wr(e=()=>{}){return({name:t,value:n})=>{let{name:r,value:i}=xr.reduce((s,o)=>o(s),{name:t,value:n});return r!==t&&e(r,t),{name:r,value:i}}}var xr=[];function Zt(e){xr.push(e)}function Er({name:e}){return Sr().test(e)}var Sr=()=>new RegExp(`^${Gt}([^:^.]+)\\b`);function cs(e,t){return({name:n,value:r})=>{let i=n.match(Sr()),s=n.match(/:([a-zA-Z0-9\-_:]+)/),o=n.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[n]||n;return{type:i?i[1]:null,value:s?s[1]:null,modifiers:o.map(c=>c.replace(".","")),expression:r,original:a}}}var Ct="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",Ct,"teleport"];function us(e,t){let n=G.indexOf(e.type)===-1?Ct:e.type,r=G.indexOf(t.type)===-1?Ct:t.type;return G.indexOf(n)-G.indexOf(r)}function Ae(e,t,n={}){e.dispatchEvent(new CustomEvent(t,{detail:n,bubbles:!0,composed:!0,cancelable:!0}))}function ne(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>ne(i,t));return}let n=!1;if(t(e,()=>n=!0),n)return;let r=e.firstElementChild;for(;r;)ne(r,t),r=r.nextElementSibling}function L(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var An=!1;function ls(){An&&L("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),An=!0,document.body||L("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` +{% endblock %} diff --git a/public/resources/views/auth/login.twig b/public/resources/views/auth/login.twig new file mode 100644 index 0000000..c5b2f28 --- /dev/null +++ b/public/resources/views/auth/login.twig @@ -0,0 +1,78 @@ +{% extends 'layouts/app.twig' %} + +{% block content %} +
+
+
+

+ Sign in to Media Collector +

+

+ Access your media dashboard +

+
+ + {% if error %} +
+
{{ error }}
+
+ {% endif %} + +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ + +
+ +
+ +
+ +
+

+ Don't have an account? + + Contact your administrator + +

+
+
+
+
+{% endblock %} diff --git a/public/resources/views/dashboard/index.twig b/public/resources/views/dashboard/index.twig new file mode 100644 index 0000000..1f5a1c6 --- /dev/null +++ b/public/resources/views/dashboard/index.twig @@ -0,0 +1,316 @@ +{% extends 'layouts/app.twig' %} + +{% block content %} +
+

Dashboard

+

Overview of your media collection

+
+ + {% if error %} +
+
{{ error }}
+
+ {% endif %} + + +
+ +
+
+
+
+ + + +
+
+
+
Total Media
+
+
{{ stats.total_media|number_format }}
+
+
+
+
+
+
+ + +
+
+
+
+ + + +
+
+
+
Games
+
+
{{ stats.total_games|number_format }}
+
{{ stats.favorite_games }} favorites
+
+
+
+
+
+
+ + +
+
+
+
+ + + +
+
+
+
Movies & TV
+
+
+ {{ (stats.total_movies + stats.total_tv_shows)|number_format }} +
+
{{ stats.watched_movies }} watched
+
+
+
+
+
+
+ + +
+
+
+
+ + + +
+
+
+
Music
+
+
{{ stats.total_music|number_format }}
+
{{ stats.favorite_music }} favorites
+
+
+
+
+
+
+
+ + +
+ +
+
+
+
+ + + +
+
+
+
Total Playtime
+
+
+ {% if stats.total_playtime %} + {{ (stats.total_playtime / 60)|round }}h + {% else %} + 0h + {% endif %} +
+
+
+
+
+
+
+ + +
+
+
+
+ + + +
+
+
+
TV Episodes
+
+
{{ stats.total_episodes|number_format }}
+
+
+
+
+
+
+ + +
+
+
+
+ + + +
+
+
+
Sync Status
+
+
+ {% if sync_stats.successful_syncs > 0 %} + {{ sync_stats.successful_syncs }}/{{ sync_stats.total_syncs }} Success + {% else %} + No syncs yet + {% endif %} +
+
+
+
+
+
+
+
+ + +
+

Recent Activity

+ + + {% if recent_games %} +
+

Recently Played Games

+
+
    + {% for game in recent_games %} +
  • +
    +
    + {% if game.image_url %} + + {% else %} +
    + + + +
    + {% endif %} +
    +

    {{ game.title }}

    +

    {{ game.source_name }}

    +
    +
    +
    + {% if game.playtime_minutes %} + {{ (game.playtime_minutes / 60)|round }}h played + {% endif %} +
    +
    +
  • + {% endfor %} +
+
+
+ {% endif %} + + + {% if recent_movies %} +
+

Recently Watched Movies

+
+
    + {% for movie in recent_movies %} +
  • +
    +
    + {% if movie.poster_url %} + + {% else %} +
    + + + +
    + {% endif %} +
    +

    {{ movie.title }}

    +

    {{ movie.source_name }}

    +
    +
    +
    + {% if movie.watch_count %} + Watched {{ movie.watch_count }} times + {% endif %} +
    +
    +
  • + {% endfor %} +
+
+
+ {% endif %} + + + {% if recent_syncs %} +
+

Recent Sync Activities

+
+
    + {% for sync in recent_syncs %} +
  • +
    +
    +
    + {% if sync.status == 'completed' %} + + + + {% elseif sync.status == 'failed' %} + + + + {% else %} + + + + {% endif %} +
    +
    +

    {{ sync.source_name }}

    +

    {{ sync.sync_type|title }} sync

    +
    +
    +
    + {{ sync.processed_items }} items • {{ sync.created_at|date('M j, Y') }} +
    +
    +
  • + {% endfor %} +
+
+
+ {% endif %} + + {% if not recent_games and not recent_movies and not recent_syncs %} +
+
+ + + +

No recent activity

+

Start adding media to see your activity here.

+
+
+ {% endif %} +
+{% endblock %} diff --git a/public/resources/views/games/index.twig b/public/resources/views/games/index.twig new file mode 100644 index 0000000..2324112 --- /dev/null +++ b/public/resources/views/games/index.twig @@ -0,0 +1,76 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+
+

Games

+
+ {{ games|length }} games from {{ games|reduce((carry, game) => carry + game.platform_count, 0) }} platforms +
+
+ + {% if games is empty %} +
+ + + +

No games found

+

Start syncing your gaming libraries to see your games here.

+
+ {% else %} +
+ {% for game in games %} +
+
+
+
+ {% if game.image_url %} + {{ game.title }} + {% else %} +
+ + + +
+ {% endif %} +
+
+

+ + {{ game.title }} + +

+

+ {{ game.platform_count }} platform{{ game.platform_count > 1 ? 's' : '' }} + {% if game.platforms %} + + {{ game.platforms|join(', ') }} + + {% endif %} +

+
+
+
+
+ {{ game.total_playtime|format_duration }} played + {% if game.max_completion > 0 %} + {{ game.max_completion }}% complete + {% endif %} +
+ {% if game.genres %} +
+ {% for genre in game.genres %} + + {{ genre }} + + {% endfor %} +
+ {% endif %} +
+
+
+ {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/public/resources/views/games/show.twig b/public/resources/views/games/show.twig new file mode 100644 index 0000000..e259c8e --- /dev/null +++ b/public/resources/views/games/show.twig @@ -0,0 +1,212 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+ +
+
+
+
+ {% if main_game.image_url %} + {{ main_game.title }} + {% else %} +
+ + + +
+ {% endif %} +
+

{{ main_game.title }}

+
+ + {{ platform_versions|length }} platform{{ platform_versions|length > 1 ? 's' : '' }} + + {% if main_game.genre %} + {{ main_game.genre }} + {% endif %} +
+
+
+ + ← Back to Games + +
+
+
+ + +
+
+ +
+ + + {% for version in platform_versions %} +
+
+
+ +
+

Game Information

+
+ {% if version.developer %} +
+
Developer
+
{{ version.developer }}
+
+ {% endif %} + {% if version.publisher %} +
+
Publisher
+
{{ version.publisher }}
+
+ {% endif %} + {% if version.release_date %} +
+
Release Date
+
{{ version.release_date|date('M j, Y') }}
+
+ {% endif %} +
+
Playtime
+
{{ version.playtime_minutes|format_duration }}
+
+ {% if version.rating %} +
+
Rating
+
{{ version.rating }}/10
+
+ {% endif %} + {% if version.completion_percentage > 0 %} +
+
Completion
+
{{ version.completion_percentage }}%
+
+ {% endif %} +
+
+ + +
+

Platform Statistics

+
+
+
Source
+
{{ version.source_name }}
+
+ {% if version.last_played_at %} +
+
Last Played
+
{{ version.last_played_at|date('M j, Y') }}
+
+ {% endif %} + {% if version.is_installed %} +
+
Status
+
+ + Installed + +
+
+ {% endif %} + {% if version.is_favorite %} +
+
Favorite
+
+ + Yes + +
+
+ {% endif %} +
+ + + {% set metadata = version.metadata|json_decode %} + {% if metadata %} +
+

Platform Details

+
+
+ {% if metadata.appid %} +
+
App ID
+
{{ metadata.appid }}
+
+ {% endif %} + {% if metadata.playtime_windows or metadata.playtime_mac or metadata.playtime_linux %} +
+
Platform Playtime
+
+ {% if metadata.playtime_windows %}Windows: {{ metadata.playtime_windows|format_duration }}{% endif %} + {% if metadata.playtime_mac %}Mac: {{ metadata.playtime_mac|format_duration }}{% endif %} + {% if metadata.playtime_linux %}Linux: {{ metadata.playtime_linux|format_duration }}{% endif %} +
+
+ {% endif %} +
+
+
+ {% endif %} +
+
+ + {% if version.description %} +
+

Description

+

{{ version.description }}

+
+ {% endif %} +
+
+ {% endfor %} +
+
+ + +{% endblock %} diff --git a/public/resources/views/layouts/app.twig b/public/resources/views/layouts/app.twig new file mode 100644 index 0000000..22f4c52 --- /dev/null +++ b/public/resources/views/layouts/app.twig @@ -0,0 +1,74 @@ + + + + + + {{ title }} - Media Collector + {% if app_env == 'production' %} + + {% else %} + + {% endif %} + + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + {% if app_env == 'production' %} + + {% else %} + + {% endif %} + + diff --git a/resources/views/actor/index.twig b/resources/views/actor/index.twig new file mode 100644 index 0000000..c355789 --- /dev/null +++ b/resources/views/actor/index.twig @@ -0,0 +1,55 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+
+
+

Adult Performers

+

{{ actors|length }} performer{{ actors|length != 1 ? 's' : '' }}

+
+
+ + {% if actors %} + + {% else %} +
+ + + +
No performers found
+

Performers will appear here once you sync content from your adult video sources.

+
+ {% endif %} +
+{% endblock %} diff --git a/resources/views/actor/show.twig b/resources/views/actor/show.twig new file mode 100644 index 0000000..7c9b866 --- /dev/null +++ b/resources/views/actor/show.twig @@ -0,0 +1,118 @@ +{% extends "layouts/app.twig" %} + +{% block content %} +
+ + + +
+
+ +
+
+
+ {% if actor.thumbnail_path %} + {{ actor.name }} + {% else %} +
+ + + +
+ {% endif %} +

{{ actor.name }}

+

{{ actor.scene_count }} scene{{ actor.scene_count != 1 ? 's' : '' }}

+
+
+
+ + +
+
+ +
+
+
+ + + +
+
{{ actor.scene_count }}
+ Total Scenes +
+
+
+ + {% if actor.scene_count > 0 %} +
+
+ + + +
+
{{ actor.latest_scene_date|date('M j, Y') }}
+ Latest Scene +
+
+
+ {% endif %} +
+ + + {% if scenes %} +
+

Scenes featuring {{ actor.name }}

+
+ {% for scene in scenes %} +
+
+
+ {% if scene.poster_url %} + {{ scene.title }} + {% else %} +
+ + + +
+ {% endif %} +
+
+
+ {{ scene.title }} +
+

+ {{ scene.release_date|date('M j, Y') }} + {% if scene.runtime_minutes %} + • {{ (scene.runtime_minutes / 60)|round(1) }}h {{ scene.runtime_minutes % 60 }}m + {% endif %} +

+ {{ scene.source_name }} +
+
+
+ {% endfor %} +
+
+ {% else %} +
+ + + +
No scenes found
+

This performer hasn't appeared in any scenes yet.

+
+ {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/resources/views/admin/index.twig b/resources/views/admin/index.twig index 6650f9d..5fcceb8 100644 --- a/resources/views/admin/index.twig +++ b/resources/views/admin/index.twig @@ -30,6 +30,34 @@
+ {% if source.name == 'jellyfin' %} + +
+ + + +
+
+ + +
+ {% else %} +
+ {% endif %} {% if source.last_sync_at %} @@ -159,6 +188,13 @@ progressBar.style.width = '0%'; statusDiv.textContent = 'Starting sync...'; + // Disable all sync buttons for this source + const buttons = document.querySelectorAll(`[onclick*="startSync(${sourceId},"]`); + buttons.forEach(button => { + button.disabled = true; + button.textContent = 'Syncing...'; + }); + // Start sync via API fetch(`/admin/sync/${sourceId}?type=${syncType}`, { method: 'POST', @@ -171,19 +207,29 @@ .then(data => { if (data.success) { // Start monitoring sync status - monitorSyncStatus(data.sync_log_id, sourceId, progressBar, statusDiv); + monitorSyncStatus(data.sync_log_id, sourceId, progressBar, statusDiv, buttons); } else { statusDiv.textContent = 'Error: ' + (data.message || 'Unknown error'); progressDiv.classList.add('d-none'); + // Re-enable buttons on error + buttons.forEach(button => { + button.disabled = false; + button.textContent = button.textContent.replace('Syncing...', '').trim(); + }); } }) .catch(error => { statusDiv.textContent = 'Error: ' + error.message; progressDiv.classList.add('d-none'); + // Re-enable buttons on error + buttons.forEach(button => { + button.disabled = false; + button.textContent = button.textContent.replace('Syncing...', '').trim(); + }); }); } - function monitorSyncStatus(syncLogId, sourceId, progressBar, statusDiv) { + function monitorSyncStatus(syncLogId, sourceId, progressBar, statusDiv, buttons) { const interval = setInterval(() => { fetch(`/admin/sync/status/${syncLogId}`) .then(response => response.json()) @@ -208,6 +254,12 @@ // Refresh page to show updated sync log location.reload(); }, 2000); + } else { + // Re-enable buttons on failure + buttons.forEach(button => { + button.disabled = false; + button.textContent = button.textContent.replace('Syncing...', '').trim(); + }); } } }) @@ -215,6 +267,11 @@ console.error('Error monitoring sync:', error); clearInterval(interval); delete syncIntervals[sourceId]; + // Re-enable buttons on error + buttons.forEach(button => { + button.disabled = false; + button.textContent = button.textContent.replace('Syncing...', '').trim(); + }); }); }, 1000); diff --git a/resources/views/adult/show.twig b/resources/views/adult/show.twig index a9ce6d0..35e6b41 100644 --- a/resources/views/adult/show.twig +++ b/resources/views/adult/show.twig @@ -141,18 +141,20 @@

Performers

{% for actor in movie.actors %} -
diff --git a/resources/views/layouts/app.twig b/resources/views/layouts/app.twig index 59e4e30..e60e3c4 100644 --- a/resources/views/layouts/app.twig +++ b/resources/views/layouts/app.twig @@ -42,8 +42,14 @@ - diff --git a/routes/web.php b/routes/web.php index b1eb350..136fefb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,6 +10,7 @@ use App\Controllers\GameController; use App\Controllers\AdultController; use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AdminMiddleware; +use App\Controllers\ActorController; // Authentication routes (no middleware required) $app->get('/login', AuthController::class . ':showLogin')->setName('auth.login'); @@ -45,6 +46,11 @@ $app->group('', function (RouteCollectorProxy $group) { // Adult Videos $mediaGroup->get('/adult', AdultController::class . ':index')->setName('adult.index'); $mediaGroup->get('/adult/{id:\d+}', AdultController::class . ':show')->setName('adult.show'); + + // Adult Performers (Actors) + $mediaGroup->get('/actors', ActorController::class . ':index')->setName('actors.index'); + $mediaGroup->get('/actors/{id:\d+}', ActorController::class . ':show')->setName('actors.show'); + }); })->add(AuthMiddleware::class); diff --git a/run_migrations.php b/run_migrations.php new file mode 100644 index 0000000..0875f4b --- /dev/null +++ b/run_migrations.php @@ -0,0 +1,18 @@ +getMessage() . "\n"; + echo "Stack trace:\n" . $e->getTraceAsString() . "\n"; +} diff --git a/test_episode_sync.php b/test_episode_sync.php new file mode 100644 index 0000000..ddff5cf --- /dev/null +++ b/test_episode_sync.php @@ -0,0 +1,42 @@ + 'test-episode-123', + 'Name' => 'Test Episode', + 'ParentIndexNumber' => 1, + 'IndexNumber' => 1, + 'PremiereDate' => '2023-01-01T00:00:00Z', + 'RunTimeTicks' => 18000000000, // 30 minutes in ticks + 'CommunityRating' => 8.5, + 'Overview' => 'Test episode overview', + 'ProviderIds' => [ + 'Imdb' => 'tt1234567', + 'Tmdb' => '123456' + ], + 'People' => [ + [ + 'Name' => 'Test Actor', + 'Type' => 'Actor' + ] + ] + ]; + + echo "Test episode data structure looks correct\n"; + echo "Episode ID: " . $testEpisodeData['Id'] . "\n"; + echo "Episode Name: " . $testEpisodeData['Name'] . "\n"; + echo "Season: " . $testEpisodeData['ParentIndexNumber'] . "\n"; + echo "Episode Number: " . $testEpisodeData['IndexNumber'] . "\n"; + echo "Has Actor: " . (isset($testEpisodeData['People'][0]['Name']) ? 'Yes' : 'No') . "\n"; + + echo "\nEpisode sync logic should work correctly!\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; +} diff --git a/test_jellyfin_execution.php b/test_jellyfin_execution.php new file mode 100644 index 0000000..4924f7c --- /dev/null +++ b/test_jellyfin_execution.php @@ -0,0 +1,45 @@ + 1, + 'name' => 'jellyfin', + 'api_url' => 'http://192.168.1.102:8096', // Adjust this to your Jellyfin URL + 'api_key' => '1db2d28854e541dd90c32ea6aab5e603' // Adjust this to your API key + ]; + + echo "Testing with source: " . $testSource['name'] . "\n"; + echo "API URL: " . $testSource['api_url'] . "\n"; + + // Create Jellyfin service instance + $pdo = \App\Database\Database::getInstance(); + $jellyfinService = new \App\Services\JellyfinSyncService($pdo, $testSource); + + // Test basic sync execution + echo "\nTesting basic sync execution...\n"; + try { + // This will trigger the executeSync method which calls all the private methods + echo "Attempting to run sync (this may fail if Jellyfin server is not accessible)...\n"; + $jellyfinService->startSync('full'); + echo "✓ Sync completed successfully\n"; + } catch (Exception $e) { + echo "✗ Sync failed (expected if Jellyfin server not accessible): " . $e->getMessage() . "\n"; + echo "This is normal if your Jellyfin server is not running or credentials are incorrect.\n"; + } + + echo "\n=== Test Complete ===\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + echo "Stack trace:\n" . $e->getTraceAsString() . "\n"; +} diff --git a/test_stash.php b/test_stash.php new file mode 100644 index 0000000..5c0a7c4 --- /dev/null +++ b/test_stash.php @@ -0,0 +1,136 @@ +load(); + +// Load database configuration +$dbConfig = require __DIR__ . '/config/database.php'; +\App\Database\Database::setConfig($dbConfig); + +// Initialize database +try { + $pdo = \App\Database\Database::getInstance(); + echo "✅ Database connection successful\n"; + + // Get Stash source + $stmt = $pdo->prepare('SELECT * FROM sources WHERE name = ?'); + $stmt->execute(['stash']); + $stashSource = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$stashSource) { + echo "❌ No Stash source found in database\n"; + exit(1); + } + + echo "🔍 Testing Stash server connectivity...\n"; + echo " URL: {$stashSource['api_url']}\n"; + echo " API Key: " . (empty($stashSource['api_key']) ? 'NOT SET' : 'SET') . "\n\n"; + + // Test basic connectivity + $client = new GuzzleHttp\Client([ + 'timeout' => 10, + 'verify' => false // Disable SSL verification for testing + ]); + + try { + $response = $client->get($stashSource['api_url'], [ + 'headers' => [ + 'User-Agent' => 'MediaCollector/1.0', + 'ApiKey' => $stashSource['api_key'] ?? '' + ] + ]); + + echo "✅ Stash server is reachable\n"; + echo " Status: {$response->getStatusCode()}\n"; + echo " Response received successfully\n"; + + // Test GraphQL endpoint + echo "\n🔍 Testing GraphQL endpoint...\n"; + + $query = ' + query FindScenes($filter: FindFilterType) { + findScenes(filter: $filter) { + count + scenes { + id + title + } + } + } + '; + + $variables = [ + 'filter' => [ + 'per_page' => 1, + 'page' => 1, + 'sort' => 'created_at', + 'direction' => 'DESC' + ] + ]; + + $response = $client->post("{$stashSource['api_url']}/graphql", [ + 'json' => [ + 'query' => $query, + 'variables' => $variables + ], + 'headers' => [ + 'User-Agent' => 'MediaCollector/1.0', + 'ApiKey' => $stashSource['api_key'] ?? '', + 'Content-Type' => 'application/json' + ], + 'timeout' => 15 + ]); + + $data = json_decode($response->getBody(), true); + + if (isset($data['data']['findScenes'])) { + $count = $data['data']['findScenes']['count']; + echo "✅ GraphQL endpoint working\n"; + echo " Total scenes in Stash: {$count}\n"; + + if ($count > 0) { + $firstScene = $data['data']['findScenes']['scenes'][0] ?? null; + if ($firstScene) { + echo " First scene: {$firstScene['title']} (ID: {$firstScene['id']})\n"; + } + } + } else { + echo "❌ GraphQL response format unexpected\n"; + echo " Response: " . json_encode($data) . "\n"; + } + + } catch (GuzzleHttp\Exception\RequestException $e) { + echo "❌ Failed to connect to Stash server\n"; + echo " Error: " . $e->getMessage() . "\n"; + + if ($e->hasResponse()) { + $response = $e->getResponse(); + echo " Status: {$response->getStatusCode()}\n"; + echo " Response: " . $response->getBody() . "\n"; + } + + echo "\n💡 Troubleshooting tips:\n"; + echo " 1. Check if Stash server is running\n"; + echo " 2. Verify the API URL is correct\n"; + echo " 3. Check if API key is required and correct\n"; + echo " 4. Ensure the server is accessible from this machine\n"; + } + +} catch (Exception $e) { + echo "❌ Error: " . $e->getMessage() . "\n"; +} diff --git a/test_xbvr.php b/test_xbvr.php new file mode 100644 index 0000000..52a749c --- /dev/null +++ b/test_xbvr.php @@ -0,0 +1,107 @@ +load(); + +// Load database configuration +$dbConfig = require __DIR__ . '/config/database.php'; + +// Set up database connection +try { + \App\Database\Database::setConfig($dbConfig); + $pdo = \App\Database\Database::getInstance(); + echo "Database connection established successfully.\n"; +} catch (Exception $e) { + die('Database connection failed: ' . $e->getMessage() . "\n"); +} + +// Test XBVR connectivity +echo "\nTesting XBVR source connectivity...\n"; + +// Find XBVR source +$stmt = $pdo->prepare("SELECT * FROM sources WHERE name = 'xbvr' AND is_active = 1 LIMIT 1"); +$stmt->execute(); +$xbvrSource = $stmt->fetch(PDO::FETCH_ASSOC); + +if (!$xbvrSource) { + echo "No active XBVR source found. Please create an XBVR source first.\n"; + exit(1); +} + +echo "Found XBVR source: {$xbvrSource['display_name']} ({$xbvrSource['api_url']})\n"; + +// Test basic connectivity +$baseUrl = rtrim($xbvrSource['api_url'], '/'); + +try { + $httpClient = new GuzzleHttp\Client([ + 'timeout' => 10, + 'headers' => [ + 'User-Agent' => 'MediaCollector/1.0' + ], + 'verify' => false + ]); + + // Try different XBVR API endpoints + $endpoints = [ + "{$baseUrl}/deovr" + ]; + + foreach ($endpoints as $endpoint) { + echo "\nTrying endpoint: {$endpoint}\n"; + + try { + $response = $httpClient->get($endpoint, [ + 'timeout' => 10, + 'connect_timeout' => 5 + ]); + + echo "✓ Status: {$response->getStatusCode()}\n"; + echo "Content-Type: " . $response->getHeaderLine('content-type') . "\n"; + + $data = json_decode($response->getBody(), true); + echo "Response keys: " . implode(', ', array_keys($data)) . "\n"; + + // XBVR DeoVR API response structure + $scenes = null; + + // Try different DeoVR response structures + if (isset($data['scenes'])) { + $scenes = $data['scenes']; + } elseif (isset($data['content'])) { + $scenes = $data['content']; + } elseif (isset($data['videos'])) { + $scenes = $data['videos']; + } elseif (isset($data[0]) && is_array($data[0])) { + // Array of scenes directly + $scenes = $data; + } + + if ($scenes !== null) { + echo "✓ Found " . count($scenes) . " scenes in XBVR DeoVR response\n"; + if (count($scenes) > 0) { + echo "Sample scene keys: " . implode(', ', array_keys($scenes[0])) . "\n"; + } + break; + } else { + echo "✗ No scenes array found in response. Response keys: " . implode(', ', array_keys($data)) . "\n"; + echo "Sample response structure: " . json_encode(array_slice($data, 0, 2), JSON_PRETTY_PRINT) . "\n"; + } + + } catch (Exception $e) { + echo "✗ Error: " . $e->getMessage() . "\n"; + } + } + +} catch (Exception $e) { + echo "Error testing XBVR connectivity: " . $e->getMessage() . "\n"; + exit(1); +} + +echo "\nXBVR connectivity test complete.\n";