baseUrl = rtrim(config('movies.tmdb.base_url', 'https://api.themoviedb.org/3'), '/'); $this->apiToken = config('movies.tmdb.api_token'); $this->language = (string) config('movies.tmdb.language', 'en-US'); $this->imageBase = rtrim(config('movies.tmdb.image_base', 'https://image.tmdb.org/t/p/'), '/'); $this->posterSize = (string) config('movies.tmdb.poster_size', 'w500'); $this->backdropSize = (string) config('movies.tmdb.backdrop_size', 'w780'); $this->profileSize = (string) config('movies.tmdb.profile_size', 'w185'); $this->cacheTtl = (int) config('movies.tmdb.cache_ttl', 3600); } public function search(string $query, int $page = 1): array { $query = trim($query); if ($query === '') { return ['results' => collect(), 'total' => 0, 'page' => 1, 'has_more' => false]; } $cacheKey = sprintf('tmdb.search.%s.p%s.%s', md5($query), $page, $this->language); $data = Cache::remember($cacheKey, $this->cacheTtl, function () use ($query, $page) { $response = Http::acceptJson() ->withHeaders($this->authHeaders()) ->timeout(6) ->retry(3, 300) ->get($this->baseUrl . '/search/movie', [ 'query' => $query, 'page' => $page, 'include_adult' => false, 'language' => $this->language, ]); if ($response->failed()) { abort(502, "Couldn’t reach TMDb. Please try again."); } return $response->json(); }); $total = (int) ($data['total_results'] ?? 0); $hasMore = ($data['page'] ?? 1) < ($data['total_pages'] ?? 1); $results = collect($data['results'] ?? [])->map(function ($item) { return [ 'provider' => 'tmdb', 'provider_id' => isset($item['id']) ? (string) $item['id'] : null, 'title' => $item['title'] ?? ($item['original_title'] ?? null), 'year' => isset($item['release_date']) && $item['release_date'] ? (int) substr($item['release_date'], 0, 4) : null, 'poster_url' => $this->buildImageUrl($item['poster_path'] ?? null, true), ]; }); return [ 'results' => $results, 'total' => $total, 'page' => (int) ($data['page'] ?? $page), 'has_more' => $hasMore, ]; } public function details(string $id): array { $id = trim($id); $cacheKey = sprintf('tmdb.details.%s.%s', $id, $this->language); $data = Cache::remember($cacheKey, $this->cacheTtl, function () use ($id) { $response = Http::acceptJson() ->withHeaders($this->authHeaders()) ->timeout(6) ->retry(3, 300) ->get($this->baseUrl . "/movie/{$id}", [ 'append_to_response' => 'credits,external_ids', 'language' => $this->language, ]); if ($response->failed()) { abort(502, "Couldn’t reach TMDb. Please try again."); } return $response->json(); }); if (!isset($data['id'])) { abort(404, 'Movie not found in TMDb'); } $genres = collect($data['genres'] ?? [])->pluck('name')->filter()->values()->all(); $cast = collect($data['credits']['cast'] ?? [])->sortByDesc('popularity')->take(15); $actors = $cast->pluck('name')->filter()->values()->all(); // Map of actor name => TMDb profile image URL (FULL URL), used to store on Actor records $actorsProfiles = $cast ->mapWithKeys(function ($c) { $name = $c['name'] ?? null; $path = $c['profile_path'] ?? null; if (!$name || !$path) return []; return [$name => $this->buildProfileUrl($path)]; }) ->all(); $directors = collect($data['credits']['crew'] ?? [])->filter(fn($c) => ($c['job'] ?? null) === 'Director')->pluck('name')->unique()->values()->all(); $studios = collect($data['production_companies'] ?? [])->pluck('name')->filter()->values()->all(); $countries = collect($data['production_countries'] ?? [])->pluck('name')->filter()->values()->all(); $languages = collect($data['spoken_languages'] ?? [])->pluck('english_name')->filter()->values()->all(); // External IDs include imdb_id when available $external = [ 'tmdb' => isset($data['id']) ? (string) $data['id'] : null, ]; if (!empty($data['external_ids']['imdb_id'])) { $external['imdb'] = $data['external_ids']['imdb_id']; } return [ 'provider' => 'tmdb', 'provider_id' => isset($data['id']) ? (string) $data['id'] : null, 'external_ids' => $external, 'title' => $data['title'] ?? null, 'original_title' => $data['original_title'] ?? ($data['title'] ?? null), 'description' => $data['overview'] ?? null, 'poster_url' => $this->buildImageUrl($data['poster_path'] ?? null, true), 'backdrop_url' => $this->buildImageUrl($data['backdrop_path'] ?? null, false), 'genres' => $genres, 'rating' => null, // Certification requires extra call; left null for now 'release_date' => $data['release_date'] ?? null, 'year' => isset($data['release_date']) && $data['release_date'] ? (int) substr($data['release_date'], 0, 4) : null, 'runtime' => isset($data['runtime']) ? (int) $data['runtime'] : null, 'actors' => $actors, 'actors_profiles' => $actorsProfiles, 'directors' => $directors, 'studios' => $studios, 'countries' => $countries, 'languages' => $languages, ]; } protected function authHeaders(): array { return $this->apiToken ? ['Authorization' => 'Bearer ' . $this->apiToken] : []; } protected function buildImageUrl(?string $path, bool $poster = true): ?string { if (!$path) return null; $size = $poster ? $this->posterSize : $this->backdropSize; return rtrim($this->imageBase, '/') . '/' . trim($size, '/') . $path; } protected function buildProfileUrl(?string $path): ?string { if (!$path) return null; return rtrim($this->imageBase, '/') . '/' . trim($this->profileSize, '/') . $path; } }