releaseImage = new ReleaseImageService; $traktApiKey = trim((string) config('nntmux_api.trakttv_api_key', '')); $this->traktcheck = $traktApiKey !== '' ? $traktApiKey : null; if ($this->traktcheck !== null) { $this->traktTv = new TraktProvider; } $this->client = new Client; $fanartApiKey = trim((string) config('nntmux_api.fanarttv_api_key', '')); $this->fanartapikey = $fanartApiKey !== '' ? $fanartApiKey : null; $this->fanart = new FanartTvService($this->fanartapikey); $omdbApiKey = trim((string) config('nntmux_api.omdb_api_key', '')); $this->omdbapikey = $omdbApiKey !== '' ? $omdbApiKey : null; if ($this->omdbapikey !== null) { $this->omdbApi = new OMDbAPI($this->omdbapikey); } $this->lookuplanguage = Settings::settingValue('imdblanguage') !== '' ? (string) Settings::settingValue('imdblanguage') : 'en'; $cacheDir = storage_path('framework/cache/imdb_cache'); if (! File::isDirectory($cacheDir)) { File::makeDirectory($cacheDir, 0777, false, true); } $this->imdburl = (int) Settings::settingValue('imdburl') !== 0; $this->movieqty = Settings::settingValue('maximdbprocessed') !== '' ? (int) Settings::settingValue('maximdbprocessed') : 100; $this->showPasswords = app(ReleaseBrowseService::class)->showPasswords(); $this->echooutput = config('nntmux.echocli'); $this->imgSavePath = storage_path('covers/movies/'); $this->service = ''; } /** * Get movie info by IMDB ID. */ public function getMovieInfo(int|string|null $imdbId): ?MovieInfo { $sanitized = $this->sanitizeImdbId($imdbId); if ($sanitized === null) { return null; } return MovieInfo::query()->where('imdbid', $sanitized)->first(); } /** * Get trailer using IMDB Id. * * @throws \Exception * @throws GuzzleException */ public function getTrailer(int|string|null $imdbId): string|false { $sanitized = $this->sanitizeImdbId($imdbId); if ($sanitized === null) { return false; } $trailer = MovieInfo::query()->where('imdbid', $sanitized)->where('trailer', '<>', '')->first(['trailer']); if ($trailer !== null) { return $trailer['trailer']; } if ($this->traktcheck !== null) { $data = $this->traktTv->client->getMovieSummary('tt'.$sanitized, 'full'); if (($data !== false) && ! empty($data['trailer'])) { return $data['trailer']; } } $trailer = imdb_trailers($sanitized); if ($trailer) { MovieInfo::query()->where('imdbid', $sanitized)->update(['trailer' => $trailer]); return $trailer; } return false; } /** * Sanitize an IMDB ID to its raw numeric string, preserving leading zeros. * Returns null if the ID is empty or all zeros. */ private function sanitizeImdbId(int|string|null $imdbId): ?string { if ($imdbId === null || $imdbId === '') { return null; } $sanitizedImdbId = preg_replace('/\D/', '', trim((string) $imdbId)); if ($sanitizedImdbId === null || $sanitizedImdbId === '' || ! imdb_id_is_valid($sanitizedImdbId)) { return null; } return $sanitizedImdbId; } /** * Parse trakt info, insert into DB. * * @param array $data */ public function parseTraktTv(array &$data): mixed { if (empty($data['ids']['imdb'])) { return false; } if (! empty($data['trailer'])) { $data['trailer'] = str_ireplace( ['watch?v=', 'http://'], ['embed/', 'https://'], $data['trailer'] ); } $imdbId = (string) $data['ids']['imdb']; $imdbId = str_starts_with($imdbId, 'tt') ? substr($imdbId, 2) : $imdbId; $genres = $data['genres'] ?? []; $genre = is_array($genres) ? implode(', ', $genres) : (string) $genres; $cover = 0; if (File::isFile($this->imgSavePath.$imdbId.'-cover.jpg')) { $cover = 1; } return $this->update([ 'genre' => $genre, 'imdbid' => $this->checkTraktValue($imdbId), 'language' => $this->checkTraktValue($data['language'] ?? ''), 'plot' => $this->checkTraktValue($data['overview'] ?? ''), 'rating' => $this->checkTraktValue($data['rating'] ?? ''), 'tagline' => $this->checkTraktValue($data['tagline'] ?? ''), 'title' => $this->checkTraktValue($data['title'] ?? ''), 'tmdbid' => $this->checkTraktValue($data['ids']['tmdb'] ?? 0), 'traktid' => $this->checkTraktValue($data['ids']['trakt'] ?? 0), 'trailer' => $this->checkTraktValue($data['trailer'] ?? ''), 'cover' => $cover, 'year' => $this->checkTraktValue($data['year'] ?? ''), ]); } private function checkTraktValue(mixed $value): mixed { if (\is_array($value) && ! empty($value)) { $temp = ''; foreach ($value as $val) { if (! is_array($val) && ! is_object($val)) { $temp .= $val; } } $value = $temp; } return ! empty($value) ? $value : ''; } /** * Get array of column keys, for inserting / updating. * * @return array */ public function getColumnKeys(): array { return [ 'actors', 'backdrop', 'cover', 'director', 'genre', 'imdbid', 'language', 'plot', 'rating', 'rtrating', 'tagline', 'title', 'tmdbid', 'traktid', 'trailer', 'type', 'year', ]; } /** * Choose the first non-empty variable from up to five inputs. * * @param array $variable1 * @param array $variable2 * @param array $variable3 * @param array $variable4 * @param array $variable5 * @return array */ protected function setVariables(string|array|int|float $variable1, string|array|int|float $variable2, string|array|int|float $variable3, string|array|int|float $variable4, string|array|int|float $variable5 = ''): array|string { if (! empty($variable1)) { return is_array($variable1) ? $variable1 : (string) $variable1; } if (! empty($variable2)) { return is_array($variable2) ? $variable2 : (string) $variable2; } if (! empty($variable3)) { return is_array($variable3) ? $variable3 : (string) $variable3; } if (! empty($variable4)) { return is_array($variable4) ? $variable4 : (string) $variable4; } if (! empty($variable5)) { return is_array($variable5) ? $variable5 : (string) $variable5; } return ''; } /** * Update movie on movie-edit page. * * @param array $values */ public function update(array $values): bool { if (! count($values)) { return false; } $query = []; $onDuplicateKey = ['created_at' => now()]; $found = 0; foreach ($values as $key => $value) { if (! empty($value)) { $found++; if (\in_array($key, ['genre', 'language'], true)) { $value = substr($value, 0, 64); } $query += [$key => $value]; $onDuplicateKey += [$key => $value]; } } if (! $found) { return false; } foreach ($query as $key => $value) { $query[$key] = rtrim((string) $value, ', '); } MovieInfo::upsert($query, ['imdbid'], $onDuplicateKey); // Always attempt to fetch a missing cover if imdbid present and cover not provided. if (! empty($query['imdbid'])) { $imdbIdForCover = $query['imdbid']; $coverProvided = array_key_exists('cover', $values) && ! empty($values['cover']); if (! $coverProvided && ! $this->hasCover($imdbIdForCover)) { if ($this->fetchAndSaveCoverOnly($imdbIdForCover)) { MovieInfo::query()->where('imdbid', $imdbIdForCover)->update(['cover' => 1]); } } } return true; } /** * Fetch IMDB/TMDB/TRAKT/OMDB/iTunes info for the movie. * * @throws \Exception */ public function updateMovieInfo(string $imdbId): bool { if ($this->echooutput && $this->service !== '') { cli()->primary('Fetching IMDB info from TMDB/IMDB/Trakt/OMDB/iTunes using IMDB id: '.$imdbId); } // Check TMDB for IMDB info. $tmdb = $this->fetchTMDBProperties($imdbId); // Check IMDB for movie info. $imdb = $this->fetchIMDBProperties($imdbId); // Check TRAKT for movie info $trakt = $this->fetchTraktTVProperties($imdbId); // Check OMDb for movie info $omdb = $this->fetchOmdbAPIProperties($imdbId); if (! $imdb && ! $tmdb && ! $trakt && ! $omdb) { return false; } // Check FanArt.tv for cover and background images. $fanart = $this->fetchFanartTVProperties($imdbId); $mov = []; $mov['cover'] = $mov['backdrop'] = $mov['banner'] = 0; $mov['type'] = $mov['director'] = $mov['actors'] = $mov['language'] = ''; $mov['imdbid'] = $imdbId; $mov['tmdbid'] = (! isset($tmdb['tmdbid']) || $tmdb['tmdbid'] === '') ? 0 : $tmdb['tmdbid']; $mov['traktid'] = (! isset($trakt['id']) || $trakt['id'] === '') ? 0 : $trakt['id']; // Prefer Fanart.tv cover over TMDB,TMDB over IMDB,IMDB over OMDB and OMDB over iTunes. if (! empty($fanart['cover'])) { try { $mov['cover'] = $this->releaseImage->saveImage($imdbId.'-cover', $fanart['cover'], $this->imgSavePath); if ($mov['cover'] === 0) { Log::warning('Failed to save FanartTV cover for '.$imdbId.' from URL: '.$fanart['cover']); } } catch (\Throwable $e) { Log::error('Error saving FanartTV cover for '.$imdbId.': '.$e->getMessage()); $mov['cover'] = 0; } } if ($mov['cover'] === 0 && ! empty($tmdb['cover'])) { try { $mov['cover'] = $this->releaseImage->saveImage($imdbId.'-cover', $tmdb['cover'], $this->imgSavePath); if ($mov['cover'] === 0) { Log::warning('Failed to save TMDB cover for '.$imdbId.' from URL: '.$tmdb['cover']); } } catch (\Throwable $e) { Log::error('Error saving TMDB cover for '.$imdbId.': '.$e->getMessage()); $mov['cover'] = 0; } } if ($mov['cover'] === 0 && ! empty($imdb['cover'])) { try { $mov['cover'] = $this->releaseImage->saveImage($imdbId.'-cover', $imdb['cover'], $this->imgSavePath); if ($mov['cover'] === 0) { Log::warning('Failed to save IMDB cover for '.$imdbId.' from URL: '.$imdb['cover']); } } catch (\Throwable $e) { Log::error('Error saving IMDB cover for '.$imdbId.': '.$e->getMessage()); $mov['cover'] = 0; } } if ($mov['cover'] === 0 && ! empty($omdb['cover'])) { try { $mov['cover'] = $this->releaseImage->saveImage($imdbId.'-cover', $omdb['cover'], $this->imgSavePath); if ($mov['cover'] === 0) { Log::warning('Failed to save OMDB cover for '.$imdbId.' from URL: '.$omdb['cover']); } } catch (\Throwable $e) { Log::error('Error saving OMDB cover for '.$imdbId.': '.$e->getMessage()); $mov['cover'] = 0; } } // Backdrops. if (! empty($fanart['backdrop'])) { try { $mov['backdrop'] = $this->releaseImage->saveImage($imdbId.'-backdrop', $fanart['backdrop'], $this->imgSavePath, 1920, 1024); } catch (\Throwable $e) { Log::warning('Error saving FanartTV backdrop for '.$imdbId.': '.$e->getMessage()); $mov['backdrop'] = 0; } } if ($mov['backdrop'] === 0 && ! empty($tmdb['backdrop'])) { try { $mov['backdrop'] = $this->releaseImage->saveImage($imdbId.'-backdrop', $tmdb['backdrop'], $this->imgSavePath, 1920, 1024); } catch (\Throwable $e) { Log::warning('Error saving TMDB backdrop for '.$imdbId.': '.$e->getMessage()); $mov['backdrop'] = 0; } } // Banner if (! empty($fanart['banner'])) { try { $mov['banner'] = $this->releaseImage->saveImage($imdbId.'-banner', $fanart['banner'], $this->imgSavePath); } catch (\Throwable $e) { Log::warning('Error saving FanartTV banner for '.$imdbId.': '.$e->getMessage()); $mov['banner'] = 0; } } // RottenTomatoes rating from OmdbAPI if ($omdb !== false && ! empty($omdb['rtRating'])) { $mov['rtrating'] = $omdb['rtRating']; } $mov['title'] = $this->setVariables($imdb['title'] ?? '', $tmdb['title'] ?? '', $trakt['title'] ?? '', $omdb['title'] ?? ''); $mov['rating'] = $this->setVariables($imdb['rating'] ?? '', $tmdb['rating'] ?? '', $trakt['rating'] ?? '', $omdb['rating'] ?? ''); $mov['plot'] = $this->setVariables($imdb['plot'] ?? '', $tmdb['plot'] ?? '', $trakt['overview'] ?? '', $omdb['plot'] ?? ''); $mov['tagline'] = $this->setVariables($imdb['tagline'] ?? '', $tmdb['tagline'] ?? '', $trakt['tagline'] ?? '', $omdb['tagline'] ?? ''); $mov['year'] = $this->setVariables($imdb['year'] ?? '', $tmdb['year'] ?? '', $trakt['year'] ?? '', $omdb['year'] ?? ''); $mov['genre'] = $this->setVariables($imdb['genre'] ?? '', $tmdb['genre'] ?? '', $trakt['genres'] ?? '', $omdb['genre'] ?? ''); if (! empty($imdb['type'])) { $mov['type'] = $imdb['type']; } if (! empty($imdb['director'])) { $mov['director'] = \is_array($imdb['director']) ? implode(', ', array_unique($imdb['director'])) : $imdb['director']; } elseif (! empty($omdb['director'])) { $mov['director'] = \is_array($omdb['director']) ? implode(', ', array_unique($omdb['director'])) : $omdb['director']; } elseif (! empty($tmdb['director'])) { $mov['director'] = \is_array($tmdb['director']) ? implode(', ', array_unique($tmdb['director'])) : $tmdb['director']; } if (! empty($imdb['actors'])) { $mov['actors'] = \is_array($imdb['actors']) ? implode(', ', array_unique($imdb['actors'])) : $imdb['actors']; } elseif (! empty($omdb['actors'])) { $mov['actors'] = \is_array($omdb['actors']) ? implode(', ', array_unique($omdb['actors'])) : $omdb['actors']; } elseif (! empty($tmdb['actors'])) { $mov['actors'] = \is_array($tmdb['actors']) ? implode(', ', array_unique($tmdb['actors'])) : $tmdb['actors']; } if (! empty($imdb['language'])) { $mov['language'] = \is_array($imdb['language']) ? implode(', ', array_unique($imdb['language'])) : $imdb['language']; } elseif (! empty($omdb['language']) && ! is_bool($omdb['language'])) { $mov['language'] = \is_array($omdb['language']) ? implode(', ', array_unique($omdb['language'])) : $omdb['language']; } if (\is_array($mov['genre'])) { $mov['genre'] = implode(', ', array_unique($mov['genre'])); } if (\is_array($mov['type'])) { $mov['type'] = implode(', ', array_unique($mov['type'])); } $mov['title'] = html_entity_decode($mov['title'], ENT_QUOTES, 'UTF-8'); $mov['title'] = str_replace(['/', '\\'], '', $mov['title']); $movieID = $this->update([ 'actors' => html_entity_decode($mov['actors'], ENT_QUOTES, 'UTF-8'), 'backdrop' => $mov['backdrop'], 'cover' => $mov['cover'], 'director' => html_entity_decode($mov['director'], ENT_QUOTES, 'UTF-8'), 'genre' => html_entity_decode($mov['genre'], ENT_QUOTES, 'UTF-8'), 'imdbid' => $mov['imdbid'], 'language' => html_entity_decode($mov['language'], ENT_QUOTES, 'UTF-8'), 'plot' => html_entity_decode(preg_replace('/\s+See full summary ยป/u', ' ', $mov['plot']), ENT_QUOTES, 'UTF-8'), 'rating' => round((int) $mov['rating'], 1), 'rtrating' => $mov['rtrating'] ?? 'N/A', 'tagline' => html_entity_decode($mov['tagline'], ENT_QUOTES, 'UTF-8'), 'title' => $mov['title'], 'tmdbid' => $mov['tmdbid'], 'traktid' => $mov['traktid'], 'type' => html_entity_decode(ucwords(preg_replace('/[._]/', ' ', $mov['type'])), ENT_QUOTES, 'UTF-8'), 'year' => $mov['year'], ]); // After updating, if cover flag is still 0 but file now exists (race condition), update DB. if ($mov['cover'] === 0 && $this->hasCover($imdbId)) { MovieInfo::query()->where('imdbid', $imdbId)->update(['cover' => 1]); } if ($this->echooutput && $this->service !== '') { PHP_EOL.cli()->headerOver('Added/updated movie: '). cli()->primary( $mov['title']. ' ('. $mov['year']. ') - '. $mov['imdbid'] ); } return $movieID; } /** * Fetch FanArt.tv backdrop / cover / title. * * @return array */ protected function fetchFanartTVProperties(string $imdbId): false|array { if (! $this->fanart->isConfigured()) { return false; } try { $result = $this->fanart->getMovieProperties($imdbId); if ($result !== null) { if ($this->echooutput) { cli()->info('Fanart found '.$result['title']); } return $result; } } catch (\Throwable $e) { Log::warning('FanartTV API error for '.$imdbId.': '.$e->getMessage()); } return false; } /** * Fetch movie information from TMDB using an IMDB ID. * * @return array */ public function fetchTMDBProperties(string $imdbId, bool $text = false): array|false { $lookupId = $text === false && (strlen($imdbId) === 7 || strlen($imdbId) === 8) ? 'tt'.$imdbId : $imdbId; $cacheKey = 'tmdb_movie_'.md5($lookupId); $expiresAt = now()->addDays(7); if (Cache::has($cacheKey)) { return Cache::get($cacheKey); } try { $tmdbClient = app(TmdbClient::class); if (! $tmdbClient->isConfigured()) { return false; } $tmdbLookup = $tmdbClient->getMovie($lookupId, ['credits']); // @phpstan-ignore argument.type if ($tmdbLookup === null || empty($tmdbLookup)) { Cache::put($cacheKey, false, $expiresAt); return false; } $title = TmdbClient::getString($tmdbLookup, 'title'); if ($this->currentTitle !== '' && ! empty($title)) { $percent = $this->similarityPercent($this->currentTitle, $title); if ($percent < self::MATCH_PERCENT) { $tmdbId = TmdbClient::getInt($tmdbLookup, 'id'); $altTitles = $tmdbId > 0 ? $tmdbClient->getMovieAlternativeTitles($tmdbId) : null; $titles = is_array($altTitles) ? ($altTitles['titles'] ?? []) : []; $matched = false; foreach ($titles as $alt) { $altTitle = is_array($alt) ? ($alt['title'] ?? '') : ''; if ($altTitle !== '') { $altPercent = $this->similarityPercent($this->currentTitle, $altTitle); if ($altPercent >= self::MATCH_PERCENT) { $matched = true; break; } } } if (! $matched) { Cache::put($cacheKey, false, $expiresAt); return false; } } } $releaseDate = TmdbClient::getString($tmdbLookup, 'release_date'); if ($this->currentYear !== '' && ! empty($releaseDate)) { $tmdbYear = Carbon::parse($releaseDate)->year; $percent = $this->similarityPercent($this->currentYear, $tmdbYear); if ($percent < self::YEAR_MATCH_PERCENT) { Cache::put($cacheKey, false, $expiresAt); return false; } } $imdbIdFromResponse = TmdbClient::getString($tmdbLookup, 'imdb_id'); $ret = [ 'title' => $title, 'tmdbid' => TmdbClient::getInt($tmdbLookup, 'id'), 'imdbid' => str_replace('tt', '', $imdbIdFromResponse), 'rating' => '', 'actors' => '', 'director' => '', 'plot' => TmdbClient::getString($tmdbLookup, 'overview'), 'tagline' => TmdbClient::getString($tmdbLookup, 'tagline'), 'year' => '', 'genre' => '', 'cover' => '', 'backdrop' => '', ]; $vote = TmdbClient::getFloat($tmdbLookup, 'vote_average'); if ($vote > 0) { $ret['rating'] = $vote; } $credits = TmdbClient::getArray($tmdbLookup, 'credits'); $cast = TmdbClient::getArray($credits, 'cast'); if (! empty($cast)) { $actors = []; foreach ($cast as $member) { if (is_array($member) && ! empty($member['name'])) { $actors[] = $member['name']; } } if (! empty($actors)) { $ret['actors'] = $actors; } } $crew = TmdbClient::getArray($credits, 'crew'); foreach ($crew as $crewMember) { if (! is_array($crewMember)) { continue; } $department = TmdbClient::getString($crewMember, 'department'); $job = TmdbClient::getString($crewMember, 'job'); if ($department === 'Directing' && $job === 'Director') { $ret['director'] = TmdbClient::getString($crewMember, 'name'); break; } } if (! empty($releaseDate)) { $ret['year'] = Carbon::parse($releaseDate)->year; } $genresa = TmdbClient::getArray($tmdbLookup, 'genres'); if (! empty($genresa)) { $genres = []; foreach ($genresa as $genre) { if (is_array($genre) && ! empty($genre['name'])) { $genres[] = $genre['name']; } } if (! empty($genres)) { $ret['genre'] = $genres; } } $posterPath = TmdbClient::getString($tmdbLookup, 'poster_path'); if (! empty($posterPath)) { $ret['cover'] = 'https://image.tmdb.org/t/p/original'.$posterPath; } $backdropPath = TmdbClient::getString($tmdbLookup, 'backdrop_path'); if (! empty($backdropPath)) { $ret['backdrop'] = 'https://image.tmdb.org/t/p/original'.$backdropPath; } if ($this->echooutput) { cli()->info('TMDb found '.$ret['title']); } Cache::put($cacheKey, $ret, $expiresAt); return $ret; } catch (\Throwable $e) { Log::warning('TMDB API error for '.$lookupId.': '.$e->getMessage()); Cache::put($cacheKey, false, now()->addHours(6)); return false; } } /** * Fetch movie information from IMDB. * * @return array */ public function fetchIMDBProperties(string $imdbId): array|false { $cacheKey = 'imdb_movie_'.md5($imdbId); $expiresAt = now()->addDays(7); if (Cache::has($cacheKey)) { return Cache::get($cacheKey); } try { $scraper = app(ImdbScraper::class); $scraped = $scraper->fetchById($imdbId); if ($scraped === false || empty($scraped['title'])) { $ttl = $scraper->wasBlockedByWaf() ? now()->addMinutes(30) : now()->addHours(6); $failureReason = $scraper->getLastFailureReason() ?? 'unknown'; $fallbackFailureReason = $scraper->getLastFallbackFailureReason(); if ($scraper->wasBlockedByWaf()) { Log::warning('IMDb title fetch failed after WAF block.', [ 'imdb_id' => $imdbId, 'reason' => $failureReason, 'fallback_reason' => $fallbackFailureReason, 'source' => $scraper->getLastFetchSource(), 'negative_cache' => '30m', ]); } else { Log::warning('IMDb metadata fetch failed.', [ 'imdb_id' => $imdbId, 'reason' => $failureReason, 'fallback_reason' => $fallbackFailureReason, 'source' => $scraper->getLastFetchSource(), 'negative_cache' => '6h', ]); } if ($this->echooutput) { $message = 'IMDb fetch failed ['.$failureReason; if ($fallbackFailureReason !== null) { $message .= ' -> '.$fallbackFailureReason; } $message .= '] for tt'.$imdbId; cli()->warning($message); } Cache::put($cacheKey, false, $ttl); return false; } if (! empty($this->currentTitle)) { $percent = $this->similarityPercent($this->currentTitle, $scraped['title']); if ($percent < self::MATCH_PERCENT) { Cache::put($cacheKey, false, now()->addHours(6)); return false; } if (! empty($this->currentYear) && ! empty($scraped['year'])) { $yearPercent = $this->similarityPercent($this->currentYear, $scraped['year']); if ($yearPercent < self::YEAR_MATCH_PERCENT) { Cache::put($cacheKey, false, now()->addHours(6)); return false; } } } Cache::put($cacheKey, $scraped, $expiresAt); if ($this->echooutput) { $sourceLabel = match ($scraper->getLastFetchSource()) { 'imdbapi_dev' => 'IMDb fallback (imdbapi.dev)', 'imdb_html' => 'IMDb scrape', default => 'IMDb', }; cli()->info($sourceLabel.' found '.$scraped['title']); } return $scraped; } catch (\Throwable $e) { Log::warning('IMDb scrape error for '.$imdbId.': '.$e->getMessage()); Cache::put($cacheKey, false, now()->addHours(6)); return false; } } /** * Fetch movie information from Trakt.tv using IMDB ID. * * @return array * * @throws GuzzleException */ public function fetchTraktTVProperties(string $imdbId): array|false { if ($this->traktcheck === null) { return false; } $cacheKey = 'trakt_movie_'.md5($imdbId); $expiresAt = now()->addDays(7); if (Cache::has($cacheKey)) { return Cache::get($cacheKey); } try { $resp = $this->traktTv->client->getMovieSummary('tt'.$imdbId, 'full'); if ($resp === false || empty($resp['title'])) { Cache::put($cacheKey, false, now()->addHours(6)); return false; } if (! empty($this->currentTitle)) { $percent = $this->similarityPercent($this->currentTitle, $resp['title']); if ($percent < self::MATCH_PERCENT) { Cache::put($cacheKey, false, now()->addHours(6)); return false; } } if (! empty($this->currentYear) && ! empty($resp['year'])) { $percent = $this->similarityPercent($this->currentYear, $resp['year']); if ($percent < self::YEAR_MATCH_PERCENT) { Cache::put($cacheKey, false, now()->addHours(6)); return false; } } $movieData = [ 'id' => $resp['ids']['trakt'] ?? null, 'title' => $resp['title'], 'overview' => $resp['overview'] ?? '', 'tagline' => $resp['tagline'] ?? '', 'year' => $resp['year'] ?? '', 'genres' => $resp['genres'] ?? '', 'rating' => $resp['rating'] ?? '', 'votes' => $resp['votes'] ?? 0, 'language' => $resp['language'] ?? '', 'runtime' => $resp['runtime'] ?? 0, 'trailer' => $resp['trailer'] ?? '', ]; if ($this->echooutput) { cli()->info('Trakt found '.$movieData['title']); } Cache::put($cacheKey, $movieData, $expiresAt); return $movieData; } catch (\Throwable $e) { Log::warning('Trakt API error for '.$imdbId.': '.$e->getMessage()); Cache::put($cacheKey, false, now()->addHours(6)); return false; } } /** * Fetch movie information from OMDB API using IMDB ID. * * @return array */ public function fetchOmdbAPIProperties(string $imdbId): array|false { if ($this->omdbapikey === null) { return false; } $cacheKey = 'omdb_movie_'.md5($imdbId); $expiresAt = now()->addDays(7); if (Cache::has($cacheKey)) { return Cache::get($cacheKey); } try { $resp = $this->omdbApi->fetch('i', 'tt'.$imdbId); if (! is_object($resp) || $resp->message !== 'OK' || Str::contains($resp->data->Response, 'Error:') || $resp->data->Response === 'False') { Cache::put($cacheKey, false, now()->addHours(6)); return false; } if (! empty($this->currentTitle)) { $percent = $this->similarityPercent($this->currentTitle, $resp->data->Title); if ($percent < self::MATCH_PERCENT) { Cache::put($cacheKey, false, now()->addHours(6)); return false; } if (! empty($this->currentYear)) { $percent = $this->similarityPercent($this->currentYear, $resp->data->Year); if ($percent < self::YEAR_MATCH_PERCENT) { Cache::put($cacheKey, false, now()->addHours(6)); return false; } } } $rtRating = ''; if (isset($resp->data->Ratings) && is_array($resp->data->Ratings) && count($resp->data->Ratings) > 1) { $rtRating = $resp->data->Ratings[1]->Value ?? ''; } $movieData = [ 'title' => $resp->data->Title ?? '', 'cover' => $resp->data->Poster ?? '', 'genre' => $resp->data->Genre ?? '', 'year' => $resp->data->Year ?? '', 'plot' => $resp->data->Plot ?? '', 'rating' => $resp->data->imdbRating ?? '', 'rtRating' => $rtRating, 'tagline' => $resp->data->Tagline ?? '', 'director' => $resp->data->Director ?? '', 'actors' => $resp->data->Actors ?? '', 'language' => $resp->data->Language ?? '', 'boxOffice' => $resp->data->BoxOffice ?? '', ]; if ($this->echooutput) { cli()->info('OMDbAPI Found '.$movieData['title']); } Cache::put($cacheKey, $movieData, $expiresAt); return $movieData; } catch (\Throwable $e) { Log::warning('OMDB API error for '.$imdbId.': '.$e->getMessage()); Cache::put($cacheKey, false, now()->addHours(6)); return false; } } /** * Update a release with an IMDB ID and related movie information. * * @throws \Exception */ public function doMovieUpdate(string $buffer, string $service, int $id, int $processImdb = 1): string|false { $existingImdbId = Release::query()->where('id', $id)->value('imdbid'); if ($existingImdbId !== null && imdb_id_is_valid($existingImdbId)) { return $existingImdbId; } $imdbId = false; if (preg_match('/(?:imdb.*?)?(?:tt|Title\?)(?P\d{5,})/i', $buffer, $hits)) { $imdbId = $hits['imdbid']; } if ($imdbId !== false) { try { $this->service = $service; if ($this->echooutput && $this->service !== '') { cli()->info($this->service.' found IMDBid: tt'.$imdbId); } $movieInfoId = MovieInfo::query()->where('imdbid', $imdbId)->first(['id']); Release::query()->where('id', $id)->update([ 'imdbid' => $imdbId, 'movieinfo_id' => $movieInfoId !== null ? $movieInfoId['id'] : null, ]); Search::updateRelease($id); if ($processImdb === 1) { $movCheck = $this->getMovieInfo($imdbId); $thirtyDaysInSeconds = 30 * 24 * 60 * 60; if ($movCheck === null || (isset($movCheck['updated_at']) && (time() - strtotime((string) $movCheck['updated_at'])) > $thirtyDaysInSeconds)) { $info = $this->updateMovieInfo($imdbId); if ($info === true) { $freshMovieInfo = MovieInfo::query()->where('imdbid', $imdbId)->first(['id']); Release::query()->where('id', $id)->update([ 'movieinfo_id' => $freshMovieInfo !== null ? $freshMovieInfo['id'] : null, ]); Search::updateRelease($id); } } } return $imdbId; } catch (\Exception $e) { Log::error('Error updating movie information: '.$e->getMessage()); return false; } } return $imdbId; } /** * Process releases with no IMDB IDs by looking up movie information from various sources. * * @throws \Exception * @throws GuzzleException */ public function processMovieReleases(string $groupID = '', string $guidChar = '', int $lookupIMDB = 1): void { if ($lookupIMDB === 0) { return; } $query = Release::query() ->select(['searchname', 'id']) ->whereBetween('categories_id', [Category::MOVIE_ROOT, Category::MOVIE_OTHER]) ->where(function ($query): void { $query->whereNull('imdbid') ->orWhereIn('imdbid', imdb_id_pending_values()); }); if ($groupID !== '') { $query->where('groups_id', $groupID); } if ($guidChar !== '') { $query->where('leftguid', $guidChar); } if ((int) $lookupIMDB === 2) { $query->where('isrenamed', '=', 1); } $res = $query->orderByDesc('id')->limit($this->movieqty)->get(); $movieCount = count($res); $failedIDs = []; if ($movieCount > 0) { if ($this->echooutput && $movieCount > 1) { cli()->header('Processing '.$movieCount.' movie releases.'); } foreach ($res as $arr) { if (! $this->parseMovieSearchName($arr['searchname'])) { $failedIDs[] = $arr['id']; continue; } $this->currentRelID = (string) $arr['id']; $movieName = $this->formatMovieName(); if ($this->echooutput) { cli()->info('Looking up: '.$movieName); } $foundIMDB = $this->searchLocalDatabase($arr['id']) || $this->searchIMDb($arr['id']) || $this->searchOMDbAPI($arr['id']) || $this->searchTraktTV($arr['id'], $movieName) || $this->searchTMDB($arr['id']); if ($foundIMDB) { if ($this->echooutput) { cli()->primary('Successfully updated release with IMDB ID'); } continue; } else { $releaseCheck = Release::query() ->where('id', $arr['id']) ->where(function ($query): void { $query->whereNotNull('imdbid') ->where('imdbid', '<>', '') ->whereNotIn('imdbid', imdb_id_pending_values()); }) ->exists(); if ($releaseCheck) { if ($this->echooutput) { cli()->info('Release already has IMDB ID, skipping'); } continue; } } $failedIDs[] = $arr['id']; } if (! empty($failedIDs)) { if ($this->echooutput) { $failedReleases = Release::query() ->select(['id', 'searchname']) ->whereIn('id', $failedIDs) ->get(); cli()->header('Failed to find IMDB IDs for '.count($failedIDs).' releases:'); foreach ($failedReleases as $release) { cli()->error("ID: {$release->id} - {$release->searchname}"); } } foreach (array_chunk($failedIDs, 100) as $chunk) { Release::query()->whereIn('id', $chunk)->update(['imdbid' => '']); ReleaseSearchIndexSync::forIds($chunk); } } } } private function formatMovieName(): string { $movieName = $this->currentTitle; if ($this->currentYear !== '') { $movieName .= ' ('.$this->currentYear.')'; } return $movieName; } private function searchLocalDatabase(int $releaseId): bool { $getIMDBid = $this->localIMDBSearch(); if ($getIMDBid === false) { return false; } $imdbId = $this->doMovieUpdate('tt'.$getIMDBid, 'Local DB', $releaseId); return $imdbId !== false; } private function searchIMDb(int $releaseId): bool { try { $scraper = app(ImdbScraper::class); $matches = $scraper->search($this->currentTitle); foreach ($matches as $match) { $title = $match['title'] ?? ''; if ($title === '') { continue; } $percent = $this->similarityPercent($title, $this->currentTitle); $yearMatches = empty($this->currentYear) || empty($match['year']); if (! $yearMatches) { $yearPercent = $this->similarityPercent($this->currentYear, $match['year']); $yearMatches = $yearPercent >= self::YEAR_MATCH_PERCENT; } $titleMatchThreshold = $yearMatches ? self::MATCH_PERCENT_ALT_TITLE : self::MATCH_PERCENT; if ($percent < $titleMatchThreshold) { continue; } if (! $yearMatches) { continue; } $imdbId = $this->doMovieUpdate('tt'.$match['imdbid'], 'IMDb(scrape)', $releaseId); if ($imdbId !== false) { return true; } } } catch (\Throwable $e) { Log::debug('IMDb scraper search failed: '.$e->getMessage()); } return false; } private function searchOMDbAPI(int $releaseId): bool { if ($this->omdbapikey === null) { return false; } $omdbTitle = strtolower(str_replace(' ', '_', $this->currentTitle)); try { $buffer = $this->currentYear !== '' ? $this->omdbApi->search($omdbTitle, 'movie', $this->currentYear) : $this->omdbApi->search($omdbTitle, 'movie'); if ($this->currentYear !== '' && ( ! is_object($buffer) || $buffer->message !== 'OK' || ($buffer->data->Response ?? '') !== 'True' || empty($buffer->data->Search[0]->imdbID ?? null) )) { $buffer = $this->omdbApi->search($omdbTitle, 'movie'); } if (! is_object($buffer) || $buffer->message !== 'OK' || Str::contains($buffer->data->Response, 'Error:') || $buffer->data->Response !== 'True' || empty($buffer->data->Search[0]->imdbID)) { return false; } $getIMDBid = $buffer->data->Search[0]->imdbID; $imdbId = $this->doMovieUpdate($getIMDBid, 'OMDbAPI', $releaseId); return $imdbId !== false; } catch (\Exception $e) { Log::error('OMDb API error: '.$e->getMessage()); return false; } } private function searchTraktTV(int $releaseId, string $movieName): bool { if ($this->traktcheck === null) { return false; } try { $data = $this->traktTv->client->getMovieSummary($movieName, 'full'); if ($data === false || empty($data['ids']['imdb'])) { return false; } $this->parseTraktTv($data); $imdbId = $this->doMovieUpdate($data['ids']['imdb'], 'Trakt', $releaseId); return $imdbId !== false; } catch (\Exception $e) { Log::error('Trakt.tv error: '.$e->getMessage()); return false; } } private function searchTMDB(int $releaseId): bool { try { $tmdbClient = app(TmdbClient::class); if (! $tmdbClient->isConfigured()) { return false; } $year = $this->currentYear !== '' ? $this->currentYear : null; $data = $tmdbClient->searchMovies($this->currentTitle, 1, $year); if ($data === null || empty($data['total_results']) || empty($data['results'])) { return false; } $results = TmdbClient::getArray($data, 'results'); foreach ($results as $result) { if (! is_array($result)) { continue; } $resultId = TmdbClient::getInt($result, 'id'); $releaseDate = TmdbClient::getString($result, 'release_date'); if ($resultId === 0 || empty($releaseDate)) { continue; } $percent = $this->similarityPercent( $this->currentYear, Carbon::parse($releaseDate)->year, ); if ($percent < self::YEAR_MATCH_PERCENT) { continue; } $ret = $this->fetchTMDBProperties((string) $resultId, true); if ($ret === false || empty($ret['imdbid'])) { continue; } $imdbId = $this->doMovieUpdate('tt'.$ret['imdbid'], 'TMDB', $releaseId); if ($imdbId !== false) { return true; } } } catch (\Throwable $e) { Log::warning('TMDB API error: '.$e->getMessage()); } return false; } protected function localIMDBSearch(): string|false { if (empty($this->currentTitle)) { return false; } $cacheKey = 'local_imdb_'.md5($this->currentTitle.$this->currentYear); if (Cache::has($cacheKey)) { return Cache::get($cacheKey); } $query = MovieInfo::query() ->select(['imdbid', 'title']) ->where('title', 'like', '%'.$this->currentTitle.'%'); if (! empty($this->currentYear)) { $start = Carbon::createFromFormat('Y', $this->currentYear)->subYears(2)->year; $end = Carbon::createFromFormat('Y', $this->currentYear)->addYears(2)->year; $query->whereBetween('year', [$start, $end]); } $potentialMatches = $query->get(); if ($potentialMatches->isEmpty()) { Cache::put($cacheKey, false, now()->addHours(6)); return false; } foreach ($potentialMatches as $match) { $percent = $this->similarityPercent($this->currentTitle, $match['title']); if ($percent >= self::MATCH_PERCENT) { Cache::put($cacheKey, $match['imdbid'], now()->addDays(7)); if ($this->echooutput) { cli()->info("Found local match: {$match['title']} ({$match['imdbid']})"); } return $match['imdbid']; } } Cache::put($cacheKey, false, now()->addHours(6)); return false; } /** * Normalize release searchname before movie title/year parsing. * Replaces dots and underscores with spaces so patterns like "Title_2024" or "Title.Year.2024" * can be matched by the name+year regex (which expects a non-word character before the year). */ protected function cleanReleaseNameForMovieLookup(string $searchname): string { $s = str_replace(['.', '_'], ' ', $searchname); $s = trim(preg_replace('/\s{2,}/', ' ', $s)); return $s; } private function similarityPercent(mixed $left, mixed $right): float { $normalizedLeft = $this->normalizeComparisonValue($left); $normalizedRight = $this->normalizeComparisonValue($right); if ($normalizedLeft === null || $normalizedRight === null) { return 0.0; } similar_text($normalizedLeft, $normalizedRight, $percent); return $percent; } private function normalizeComparisonValue(mixed $value): ?string { if (is_string($value)) { return $value; } if (is_int($value) || is_float($value)) { return (string) $value; } if ($value instanceof \Stringable) { return (string) $value; } return null; } protected function parseMovieSearchName(string $releaseName): bool { $releaseName = $this->cleanReleaseNameForMovieLookup($releaseName); if (empty(trim($releaseName))) { return false; } $cacheKey = 'parse_movie_'.md5($releaseName); if (Cache::has($cacheKey)) { $result = Cache::get($cacheKey); if (is_array($result)) { $this->currentTitle = $result['title']; $this->currentYear = $result['year']; return true; } return false; } $name = $year = ''; $followingList = '[^\w]((1080|480|720|2160)p|AC3D|Directors([^\w]CUT)?|DD5\.1|(DVD|BD|BR|UHD)(Rip)?|' .'BluRay|divx|HDTV|iNTERNAL|LiMiTED|(Real\.)?PROPER|RE(pack|Rip)|Sub\.?(fix|pack)|' .'Unrated|WEB-?DL|WEBRip|(x|H|HEVC)[ ._-]?26[45]|xvid|AAC|REMUX)[^\w]'; if (preg_match('/(?P[\w. -]+)[^\w](?P(19|20)\d\d)/i', $releaseName, $hits)) { $name = $hits['name']; $year = $hits['year']; } elseif (preg_match('/([^\w]{2,})?(?P[\w .-]+?)'.$followingList.'/i', $releaseName, $hits)) { $name = $hits['name']; } elseif (preg_match('/^(?P[\w .-]+?)'.$followingList.'/i', $releaseName, $hits)) { $name = $hits['name']; } elseif (strlen($releaseName) <= 100 && ! preg_match('/\.(rar|zip|avi|mkv|mp4)$/i', $releaseName)) { $name = $releaseName; } if (! empty($name)) { $name = preg_replace('/'.$followingList.'/i', ' ', $name); $name = preg_replace('/\([^)]*\)/i', ' ', $name); while (($openPos = strpos($name, '[')) !== false && ($closePos = strpos($name, ']', $openPos)) !== false) { $name = substr($name, 0, $openPos).' '.substr($name, $closePos + 1); } $name = str_replace(['.', '_'], ' ', $name); $name = preg_replace('/-[A-Z0-9].*$/i', '', $name); $name = trim(preg_replace('/\s{2,}/', ' ', $name)); if (strlen($name) > 2 && ! preg_match('/^\d+$/', $name)) { $this->currentTitle = $name; $this->currentYear = $year; Cache::put($cacheKey, [ 'title' => $name, 'year' => $year, ], now()->addDays(7)); return true; } } Cache::put($cacheKey, false, now()->addHours(24)); return false; } /** * Get IMDB genres. * * @return array */ public function getGenres(): array { return [ 'Action', 'Adventure', 'Animation', 'Biography', 'Comedy', 'Crime', 'Documentary', 'Drama', 'Family', 'Fantasy', 'Film-Noir', 'Game-Show', 'History', 'Horror', 'Music', 'Musical', 'Mystery', 'News', 'Reality-TV', 'Romance', 'Sci-Fi', 'Sport', 'Talk-Show', 'Thriller', 'War', 'Western', ]; } protected function hasCover(string $imdbId): bool { $record = MovieInfo::query()->select('cover')->where('imdbid', $imdbId)->first(); $dbHas = $record !== null && (int) $record->cover === 1; $filePath = $this->imgSavePath.$imdbId.'-cover.jpg'; $fileHas = File::isFile($filePath); return $dbHas || $fileHas; } protected function fetchAndSaveCoverOnly(string $imdbId): bool { try { $fanart = $this->fetchFanartTVProperties($imdbId); if (! empty($fanart['cover'])) { if ($this->releaseImage->saveImage($imdbId.'-cover', $fanart['cover'], $this->imgSavePath)) { return true; } } } catch (\Throwable $e) { Log::debug('Fanart cover fetch failed for '.$imdbId.': '.$e->getMessage()); } try { $tmdb = $this->fetchTMDBProperties($imdbId); if (! empty($tmdb['cover'])) { if ($this->releaseImage->saveImage($imdbId.'-cover', $tmdb['cover'], $this->imgSavePath)) { return true; } } } catch (\Throwable $e) { Log::debug('TMDB cover fetch failed for '.$imdbId.': '.$e->getMessage()); } try { $imdb = $this->fetchIMDBProperties($imdbId); if (! empty($imdb['cover'])) { if ($this->releaseImage->saveImage($imdbId.'-cover', $imdb['cover'], $this->imgSavePath)) { return true; } } } catch (\Throwable $e) { Log::debug('IMDB cover fetch failed for '.$imdbId.': '.$e->getMessage()); } try { $omdb = $this->fetchOmdbAPIProperties($imdbId); if (! empty($omdb['cover'])) { if ($this->releaseImage->saveImage($imdbId.'-cover', $omdb['cover'], $this->imgSavePath)) { return true; } } } catch (\Throwable $e) { Log::debug('OMDB cover fetch failed for '.$imdbId.': '.$e->getMessage()); } return false; } }