diff --git a/Blacklight/Movie.php b/Blacklight/Movie.php index fc9305fc7..155f899ff 100755 --- a/Blacklight/Movie.php +++ b/Blacklight/Movie.php @@ -7,6 +7,7 @@ use App\Models\Category; use App\Models\MovieInfo; use App\Models\Release; use App\Models\Settings; +use App\Services\ImdbScraper; use Blacklight\libraries\FanartTV; use Blacklight\processing\tv\TraktTv; use Blacklight\utility\Utility; @@ -24,12 +25,9 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Str; -use Imdb\Config; -use Imdb\Title; -use Imdb\TitleSearch; -use Tmdb\Exception\TmdbApiException; -use Tmdb\Laravel\Facades\Tmdb; +use Illuminate\Support\Str; // ensure scraper imported +use Tmdb; // add TMDB facade +use Tmdb\Exception\TmdbApiException; // add TMDB exception /** * Class Movie. @@ -101,8 +99,6 @@ class Movie public ?OMDbAPI $omdbApi; - private Config $config; - /** * @var null|string */ @@ -133,14 +129,11 @@ class Movie } $this->lookuplanguage = Settings::settingValue('imdblanguage') !== '' ? (string) Settings::settingValue('imdblanguage') : 'en'; - $this->config = new Config; - $this->config->language = $this->lookuplanguage; - $this->config->throwHttpExceptions = false; $cacheDir = storage_path('framework/cache/imdb_cache'); if (! File::isDirectory($cacheDir)) { File::makeDirectory($cacheDir, 0777, false, true); } - $this->config->cachedir = $cacheDir; + // $this->config->cachedir = $cacheDir; // removed imdbphp config $this->imdburl = (int) Settings::settingValue('imdburl') !== 0; $this->movieqty = Settings::settingValue('maximdbprocessed') !== '' ? (int) Settings::settingValue('maximdbprocessed') : 100; @@ -169,40 +162,23 @@ class Movie { $page = max(1, $page); $start = max(0, $start); - $catsrch = ''; - if (\count($cat) > 0 && $cat[0] !== -1) { + if (count($cat) > 0 && $cat[0] !== -1) { $catsrch = Category::getCategorySearch($cat); } $order = $this->getMovieOrder($orderBy); $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium')); - $moviesSql = - sprintf( - " - SELECT SQL_CALC_FOUND_ROWS - m.imdbid, - GROUP_CONCAT(r.id ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_id - FROM movieinfo m - LEFT JOIN releases r USING (imdbid) - WHERE m.title != '' - AND m.imdbid != '0000000' - AND r.passwordstatus %s - %s %s %s %s - GROUP BY m.imdbid - ORDER BY %s %s %s", - $this->showPasswords, - $this->getBrowseBy(), - (! empty($catsrch) ? $catsrch : ''), - ( - $maxAge > 0 - ? 'AND r.postdate > NOW() - INTERVAL '.$maxAge.'DAY ' - : '' - ), - \count($excludedCats) > 0 ? ' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')' : '', - $order[0], - $order[1], - $start === false ? '' : ' LIMIT '.$num.' OFFSET '.$start - ); + $whereAge = $maxAge > 0 ? 'AND r.postdate > NOW() - INTERVAL '.$maxAge.' DAY ' : ''; + $whereExcluded = count($excludedCats) > 0 ? ' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')' : ''; + $limitClause = $start === false ? '' : ' LIMIT '.$num.' OFFSET '.$start; + $moviesSql = "SELECT SQL_CALC_FOUND_ROWS m.imdbid, GROUP_CONCAT(r.id ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_id " + ."FROM movieinfo m LEFT JOIN releases r USING (imdbid) WHERE m.title != '' AND m.imdbid != '0000000' " + ."AND r.passwordstatus {$this->showPasswords} " + .$this->getBrowseBy().' ' + .(! empty($catsrch) ? $catsrch.' ' : '') + .$whereAge + .$whereExcluded.' ' + ."GROUP BY m.imdbid ORDER BY {$order[0]} {$order[1]} {$limitClause}"; $movieCache = Cache::get(md5($moviesSql.$page)); if ($movieCache !== null) { $movies = $movieCache; @@ -213,57 +189,46 @@ class Movie } $movieIDs = $releaseIDs = []; if (! empty($movies['result'])) { - foreach ($movies['result'] as $movie => $id) { + foreach ($movies['result'] as $id) { $movieIDs[] = $id->imdbid; $releaseIDs[] = $id->grp_release_id; } } - - $sql = sprintf( - " - SELECT - GROUP_CONCAT(r.id ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_id, - GROUP_CONCAT(r.rarinnerfilecount ORDER BY r.postdate DESC SEPARATOR ',') AS grp_rarinnerfilecount, - GROUP_CONCAT(r.haspreview ORDER BY r.postdate DESC SEPARATOR ',') AS grp_haspreview, - GROUP_CONCAT(r.passwordstatus ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_password, - GROUP_CONCAT(r.guid ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_guid, - GROUP_CONCAT(rn.releases_id ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_nfoid, - GROUP_CONCAT(g.name ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_grpname, - GROUP_CONCAT(r.searchname ORDER BY r.postdate DESC SEPARATOR '#') AS grp_release_name, - GROUP_CONCAT(r.postdate ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_postdate, - GROUP_CONCAT(r.adddate ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_adddate, - GROUP_CONCAT(r.size ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_size, - GROUP_CONCAT(r.totalpart ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_totalparts, - GROUP_CONCAT(r.comments ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_comments, - GROUP_CONCAT(r.grabs ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_grabs, - GROUP_CONCAT(df.failed ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_failed, - GROUP_CONCAT(cp.title, ' > ', c.title ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_catname, - m.*, - g.name AS group_name, - rn.releases_id AS nfoid - FROM releases r - LEFT OUTER JOIN usenet_groups g ON g.id = r.groups_id - LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id - LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id - LEFT OUTER JOIN categories c ON c.id = r.categories_id - LEFT OUTER JOIN root_categories cp ON cp.id = c.root_categories_id - INNER JOIN movieinfo m ON m.imdbid = r.imdbid - WHERE m.imdbid IN (%s) - AND r.id IN (%s) %s - GROUP BY m.imdbid - ORDER BY %s %s", - (\is_array($movieIDs) && ! empty($movieIDs) ? implode(',', $movieIDs) : -1), - (\is_array($releaseIDs) && ! empty($releaseIDs) ? implode(',', $releaseIDs) : -1), - (! empty($catsrch) ? $catsrch : ''), - $order[0], - $order[1] - ); + $inMovieIds = (is_array($movieIDs) && ! empty($movieIDs)) ? implode(',', $movieIDs) : -1; + $inReleaseIds = (is_array($releaseIDs) && ! empty($releaseIDs)) ? implode(',', $releaseIDs) : -1; + $sql = 'SELECT ' + ."GROUP_CONCAT(r.id ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_id, " + ."GROUP_CONCAT(r.rarinnerfilecount ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_rarinnerfilecount, " + ."GROUP_CONCAT(r.haspreview ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_haspreview, " + ."GROUP_CONCAT(r.passwordstatus ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_password, " + ."GROUP_CONCAT(r.guid ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_guid, " + ."GROUP_CONCAT(rn.releases_id ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_nfoid, " + ."GROUP_CONCAT(g.name ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_grpname, " + ."GROUP_CONCAT(r.searchname ORDER BY r.postdate DESC SEPARATOR '#') AS grp_release_name, " + ."GROUP_CONCAT(r.postdate ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_postdate, " + ."GROUP_CONCAT(r.adddate ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_adddate, " + ."GROUP_CONCAT(r.size ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_size, " + ."GROUP_CONCAT(r.totalpart ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_totalparts, " + ."GROUP_CONCAT(r.comments ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_comments, " + ."GROUP_CONCAT(r.grabs ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_grabs, " + ."GROUP_CONCAT(df.failed ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_failed, " + ."GROUP_CONCAT(cp.title, ' > ', c.title ORDER BY r.postdate DESC SEPARATOR ',' ) AS grp_release_catname, " + .'m.*, g.name AS group_name, rn.releases_id AS nfoid FROM releases r ' + .'LEFT OUTER JOIN usenet_groups g ON g.id = r.groups_id ' + .'LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id ' + .'LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id ' + .'LEFT OUTER JOIN categories c ON c.id = r.categories_id ' + .'LEFT OUTER JOIN root_categories cp ON cp.id = c.root_categories_id ' + .'INNER JOIN movieinfo m ON m.imdbid = r.imdbid ' + ."WHERE m.imdbid IN ($inMovieIds) AND r.id IN ($inReleaseIds) " + .(! empty($catsrch) ? $catsrch.' ' : '') + ."GROUP BY m.imdbid ORDER BY {$order[0]} {$order[1]}"; $return = Cache::get(md5($sql.$page)); if ($return !== null) { return $return; } $return = Release::fromQuery($sql); - if (\count($return) > 0) { + if (count($return) > 0) { $return[0]->_totalcount = $movies['total'][0]->total ?? 0; } Cache::put(md5($sql.$page), $return, $expiresAt); @@ -624,7 +589,7 @@ class Movie 'title' => $mov['title'], 'tmdbid' => $mov['tmdbid'], 'traktid' => $mov['traktid'], - 'type' => html_entity_decode(ucwords(preg_replace('/[\.\_]/', ' ', $mov['type'])), ENT_QUOTES, 'UTF-8'), + 'type' => html_entity_decode(ucwords(preg_replace('/[._]/', ' ', $mov['type'])), ENT_QUOTES, 'UTF-8'), 'year' => $mov['year'], ]); @@ -838,76 +803,43 @@ class Movie */ public function fetchIMDBProperties(string $imdbId): array|false { - // Create a cache key for this request $cacheKey = 'imdb_movie_'.md5($imdbId); - $expiresAt = now()->addDays(7); // Cache for 7 days - - // Check if we have this cached + $expiresAt = now()->addDays(7); if (Cache::has($cacheKey)) { return Cache::get($cacheKey); } - try { - // Get real ID and title information - $realId = (new Title($imdbId, $this->config))->real_id(); - $result = new Title($realId, $this->config); - $title = ! empty($result->orig_title()) ? $result->orig_title() : $result->title(); - - // If no title was found, cache the failure and return false - if (empty($title)) { + $scraper = app(ImdbScraper::class); + $scraped = $scraper->fetchById($imdbId); + if ($scraped === false || empty($scraped['title'])) { Cache::put($cacheKey, false, now()->addHours(6)); return false; } - - // Title similarity check when we have a title to compare against if (! empty($this->currentTitle)) { - similar_text($this->currentTitle, $title, $percent); + similar_text($this->currentTitle, $scraped['title'], $percent); if ($percent < self::MATCH_PERCENT) { Cache::put($cacheKey, false, now()->addHours(6)); return false; } + if (! empty($this->currentYear) && ! empty($scraped['year'])) { + similar_text($this->currentYear, $scraped['year'], $yearPercent); + if ($yearPercent < self::YEAR_MATCH_PERCENT) { + Cache::put($cacheKey, false, now()->addHours(6)); - // Year similarity check - similar_text($this->currentYear, $result->year(), $percent); - if ($percent < self::YEAR_MATCH_PERCENT) { - Cache::put($cacheKey, false, now()->addHours(6)); - - return false; + return false; + } } } - - // Build the movie data array - $movieData = [ - 'title' => $title, - 'tagline' => $result->tagline() ?? '', - 'plot' => Arr::get($result->plot_split(), '0.plot', ''), - 'rating' => $result->rating() ?? '', - 'year' => $result->year() ?? '', - 'cover' => $result->photo() ?? '', - 'genre' => $result->genre() ?? '', - 'language' => $result->language() ?? '', - 'type' => $result->movietype() ?? '', - 'actors' => $result->cast() ?? [], - 'director' => $result->director() ?? [], - ]; - - // Log success + Cache::put($cacheKey, $scraped, $expiresAt); if ($this->echooutput) { - $this->colorCli->info('IMDb found '.$title); + $this->colorCli->info('IMDb scraped '.$scraped['title']); } - // Cache the successful result - Cache::put($cacheKey, $movieData, $expiresAt); - - return $movieData; - - } catch (\Exception $e) { - // Log the error - Log::error('IMDB API error for '.$imdbId.': '.$e->getMessage()); - - // Cache the failure but for shorter time + return $scraped; + } catch (\Throwable $e) { + Log::error('IMDb scrape error for '.$imdbId.': '.$e->getMessage()); Cache::put($cacheKey, false, now()->addHours(6)); return false; @@ -1441,35 +1373,32 @@ class Movie */ private function searchIMDb(int $releaseId): bool { + // Simplified scraper-only search try { - $imdbSearch = new TitleSearch($this->config); - foreach ($imdbSearch->search($this->currentTitle, [TitleSearch::MOVIE]) as $imdbTitle) { - if (empty($imdbTitle->title())) { + $scraper = app(ImdbScraper::class); + $matches = $scraper->search($this->currentTitle); + foreach ($matches as $match) { + $title = $match['title'] ?? ''; + if ($title === '') { continue; } - - // Compare title similarity - similar_text($imdbTitle->title(), $this->currentTitle, $percent); + similar_text($title, $this->currentTitle, $percent); if ($percent < self::MATCH_PERCENT) { continue; } - - // Compare year similarity if year is available - similar_text($this->currentYear, $imdbTitle->year(), $yearPercent); - if ($yearPercent < self::YEAR_MATCH_PERCENT) { - continue; + if (! empty($this->currentYear) && ! empty($match['year'])) { + similar_text($this->currentYear, $match['year'], $yearPercent); + if ($yearPercent < self::YEAR_MATCH_PERCENT) { + continue; + } } - - // Found a good match, update the release - $getIMDBid = $imdbTitle->imdbid(); - $imdbId = $this->doMovieUpdate('tt'.$getIMDBid, 'IMDb', $releaseId); + $imdbId = $this->doMovieUpdate('tt'.$match['imdbid'], 'IMDb(scrape)', $releaseId); if ($imdbId !== false) { return true; } } - } catch (\ErrorException $e) { - $this->colorCli->error('Error fetching data from IMDb: '.$e->getMessage(), true); - Log::debug($e->getMessage()); + } catch (\Throwable $e) { + Log::debug('IMDb scraper search failed: '.$e->getMessage()); } return false; @@ -1714,8 +1643,13 @@ class Movie // 1. Remove any common movie flags like 1080p, BluRay, etc. $name = preg_replace('/'.$followingList.'/i', ' ', $name); - // 2. Remove content in parentheses, brackets, and periods/underscores - $name = preg_replace('/\(.*?\)|\[.*?\]|[._]/i', ' ', $name); + // 2. Remove content in parentheses, brackets, and periods/underscores without complex alternation + $name = preg_replace('/\([^)]*\)/i', ' ', $name); + // Remove [ ... ] blocks without regex escapes to satisfy linter + while (($openPos = strpos($name, '[')) !== false && ($closePos = strpos($name, ']', $openPos)) !== false) { + $name = substr($name, 0, $openPos).' '.substr($name, $closePos + 1); + } + $name = str_replace(['.', '_'], ' ', $name); // 3. Remove scene group names (typically after a hyphen) $name = preg_replace('/-[A-Z0-9].*$/i', '', $name); diff --git a/app/Services/ImdbScraper.php b/app/Services/ImdbScraper.php new file mode 100644 index 000000000..6b80296d6 --- /dev/null +++ b/app/Services/ImdbScraper.php @@ -0,0 +1,225 @@ +client = new Client([ + 'timeout' => 10, + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (compatible; NNTmuxBot/1.0; +https://github.com/NNTmux)', + ], + ]); + } + + /** + * Fetch a movie by IMDB numeric ID. + * + * @param string $id Numeric part without 'tt'. + */ + public function fetchById(string $id): array|false + { + $id = preg_replace('/[^0-9]/', '', $id); + if ($id === '') { + return false; + } + $cacheKey = 'imdb_scrape_id_'.$id; + if (Cache::has($cacheKey)) { + return Cache::get($cacheKey); + } + $url = 'https://www.imdb.com/title/tt'.$id.'/'; + try { + $res = $this->client->get($url); + $html = (string) $res->getBody(); + $dom = HtmlDomParser::str_get_html($html); + if (! $dom) { + Cache::put($cacheKey, false, now()->addHours(6)); + + return false; + } + + $titleNode = $dom->findOne('h1'); + $title = trim($titleNode?->text() ?? ''); + if ($title === '') { + Cache::put($cacheKey, false, now()->addHours(6)); + + return false; + } + + // Year + $year = ''; + $yearNode = $dom->findOne("span[data-testid='title-details-releasedate'] a"); + if ($yearNode) { + if (preg_match('/(19|20)\d{2}/', $yearNode->text(), $m)) { + $year = $m[0]; + } + } + if ($year === '') { + $altYearNode = $dom->findOne("span[data-testid='title-details-releasedate']"); + if ($altYearNode && preg_match('/(19|20)\d{2}/', $altYearNode->text(), $m)) { + $year = $m[0]; + } + } + + // Plot + $plot = ''; + $plotNode = $dom->findOne("span[data-testid='plot-l']"); + if (! $plotNode) { + $plotNode = $dom->findOne("span[data-testid='plot-xl']"); + } + if ($plotNode) { + $plot = trim($plotNode->text()); + } + + // Rating + $rating = ''; + $ratingNode = $dom->findOne("div[data-testid='hero-rating-bar__aggregate-rating__score'] span"); + if ($ratingNode) { + $rating = trim($ratingNode->text()); + } + $rating = preg_replace('/[^0-9\.]/', '', $rating); + + // Poster + $cover = ''; + $posterNode = $dom->findOne("div[data-testid='hero-media__poster'] img"); + if ($posterNode) { + $cover = $posterNode->getAttribute('src'); + } + if ($cover !== '' && str_contains($cover, '._V1_')) { + // Attempt higher res by removing size suffix + $cover = preg_replace('/\._V1_.*\./', '.', $cover); + } + + // Genres + $genres = []; + foreach ($dom->find("div[data-testid='genres'] a") as $g) { + $gt = trim($g->text()); + if ($gt !== '') { + $genres[] = $gt; + } + } + + // Directors + $directors = []; + foreach ($dom->find("li[data-testid='title-pc-principal-credit']:contains(Director) a") as $d) { + $dt = trim($d->text()); + if ($dt !== '') { + $directors[] = $dt; + } + } + if (empty($directors)) { + foreach ($dom->find("li[data-testid='title-pc-principal-credit']:contains(Creator) a") as $d) { + $dt = trim($d->text()); + if ($dt !== '') { + $directors[] = $dt; + } + } + } + + // Actors (top billed) + $actors = []; + foreach ($dom->find("div[data-testid='title-cast-item'] a[data-testid='title-cast-item__actor']") as $a) { + $at = trim($a->text()); + if ($at !== '') { + $actors[] = $at; + } + if (count($actors) >= 10) { + break; + } + } + + // Languages (from details section) + $language = ''; + foreach ($dom->find("li[data-testid='title-details-languages'] a") as $ln) { + $lt = trim($ln->text()); + if ($lt !== '') { + $language .= $lt.', '; + } + } + $language = rtrim($language, ', '); + + $data = [ + 'imdbid' => $id, + 'title' => $title, + 'year' => $year, + 'plot' => $plot, + 'rating' => $rating, + 'cover' => $cover, + 'genre' => $genres, + 'director' => $directors, + 'actors' => $actors, + 'language' => $language, + 'type' => 'movie', + ]; + Cache::put($cacheKey, $data, now()->addDays(7)); + + return $data; + } catch (GuzzleException|\Throwable $e) { + Log::debug('IMDb fetch error tt'.$id.': '.$e->getMessage()); + Cache::put($cacheKey, false, now()->addHours(6)); + + return false; + } + } + + /** + * Search IMDb suggestion API for a title. + */ + public function search(string $query): array + { + $query = trim($query); + if ($query === '') { + return []; + } + $norm = strtolower(preg_replace('/[^a-z0-9 ]/i', '', $query)); + $slug = str_replace(' ', '_', $norm); + $prefix = substr($slug, 0, 1); + $cacheKey = 'imdb_search_'.md5($slug); + if (Cache::has($cacheKey)) { + return Cache::get($cacheKey); + } + $url = 'https://v2.sg.media-imdb.com/suggestion/'.urlencode($prefix).'/'.urlencode($slug).'.json'; + try { + $res = $this->client->get($url); + $json = json_decode((string) $res->getBody(), true); + $results = []; + foreach (($json['d'] ?? []) as $row) { + if (! isset($row['id']) || ! str_starts_with($row['id'], 'tt')) { + continue; + } + $id = substr($row['id'], 2); + $title = $row['l'] ?? ''; + $year = $row['y'] ?? ''; + if ($title === '') { + continue; + } + $results[] = [ + 'imdbid' => $id, + 'title' => $title, + 'year' => (string) $year, + ]; + if (count($results) >= 25) { + break; + } + } + Cache::put($cacheKey, $results, now()->addHours(12)); + + return $results; + } catch (GuzzleException|\Throwable $e) { + Log::debug('IMDb search error '.$query.': '.$e->getMessage()); + Cache::put($cacheKey, [], now()->addHours(6)); + + return []; + } + } +} diff --git a/composer.json b/composer.json index 989f9dbd7..69fa85f27 100644 --- a/composer.json +++ b/composer.json @@ -60,7 +60,6 @@ "geoip2/geoip2": "^3.1", "google/recaptcha": "^1.3", "guzzlehttp/guzzle": "^7.9", - "imdbphp/imdbphp": "^8.3", "intervention/image": "^3.11", "jrean/laravel-user-verification": "^13.0", "laravel/framework": "^12.28", diff --git a/composer.lock b/composer.lock index b332f4b7a..8393fdf60 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "32b40f0d4ebbdcc3116e8bfd91316751", + "content-hash": "51bf4d154595d4d6a5d5335d732d5be3", "packages": [ { "name": "aharen/omdbapi", @@ -2552,54 +2552,6 @@ ], "time": "2025-08-22T14:27:06+00:00" }, - { - "name": "imdbphp/imdbphp", - "version": "v8.3.0", - "source": { - "type": "git", - "url": "https://github.com/tboothman/imdbphp.git", - "reference": "b007c383b416e714c7212516284c41d974beba43" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/tboothman/imdbphp/zipball/b007c383b416e714c7212516284c41d974beba43", - "reference": "b007c383b416e714c7212516284c41d974beba43", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-simplexml": "*", - "php": ">=5.6", - "psr/log": "^1.0 || ^2.0", - "psr/simple-cache": "^1.0" - }, - "require-dev": { - "cache/array-adapter": "^1.2", - "mockery/mockery": "^1.6", - "php-cs-fixer/shim": "^3.9", - "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^9.6" - }, - "type": "library", - "autoload": { - "psr-4": { - "Imdb\\": "src/Imdb" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "GPL-2.0-or-later" - ], - "description": "Library for retrieving film and tv information from IMDb", - "support": { - "issues": "https://github.com/tboothman/imdbphp/issues", - "source": "https://github.com/tboothman/imdbphp/tree/v8.3.0" - }, - "time": "2025-03-02T18:05:46+00:00" - }, { "name": "intervention/gif", "version": "4.2.2", @@ -7890,16 +7842,16 @@ }, { "name": "psr/log", - "version": "2.0.0", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "ef29f6d262798707a9edd554e2b82517ef3a9376" + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/ef29f6d262798707a9edd554e2b82517ef3a9376", - "reference": "ef29f6d262798707a9edd554e2b82517ef3a9376", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { @@ -7908,7 +7860,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "3.x-dev" } }, "autoload": { @@ -7934,31 +7886,31 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/2.0.0" + "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2021-07-14T16:41:46+00:00" + "time": "2024-09-11T13:17:53+00:00" }, { "name": "psr/simple-cache", - "version": "1.0.1", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/php-fig/simple-cache.git", - "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b" + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", - "reference": "408d5eafb83c57f6365a3ca330ff23aa4a5fa39b", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "3.0.x-dev" } }, "autoload": { @@ -7973,7 +7925,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], "description": "Common interfaces for simple caching", @@ -7985,9 +7937,9 @@ "simple-cache" ], "support": { - "source": "https://github.com/php-fig/simple-cache/tree/master" + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" }, - "time": "2017-10-23T01:57:42+00:00" + "time": "2021-10-29T13:26:27+00:00" }, { "name": "psy/psysh", diff --git a/tests/Fixtures/imdb/example_title.html b/tests/Fixtures/imdb/example_title.html new file mode 100644 index 000000000..6495fefa1 --- /dev/null +++ b/tests/Fixtures/imdb/example_title.html @@ -0,0 +1,23 @@ +Example Movie - IMDb + "director": [{"@type":"Person","name":"Famous Director"}] + ], + {"@type":"Person","name":"Second Actor"} + {"@type":"Person","name":"First Actor"}, + "actor": [ + "genre": ["Action", "Adventure"], + "image": "https://example.com/poster_from_jsonld.jpg", + "datePublished": "2024-05-17", + "aggregateRating": {"@type":"AggregateRating","ratingValue":"7.3","ratingCount":12345}, + "description": "An example plot goes here.", + "name": "Example Movie", + "@type": "Movie", + "@context": "https://schema.org", + diff --git a/tests/Integration/ImdbScraperIntegrationTest.php b/tests/Integration/ImdbScraperIntegrationTest.php new file mode 100644 index 000000000..3b69463c5 --- /dev/null +++ b/tests/Integration/ImdbScraperIntegrationTest.php @@ -0,0 +1,121 @@ +markTestSkipped('Integration tests disabled by default. Remove this line to run live tests.'); + } + + /** + * Test fetching real data from IMDb for The Shawshank Redemption. + * + * @group integration + * @group slow + */ + public function test_fetch_shawshank_redemption_from_live_site(): void + { + $scraper = new ImdbScraper; + $data = $scraper->fetchById('0111161'); + + $this->assertIsArray($data); + $this->assertSame('The Shawshank Redemption', $data['title']); + $this->assertNotEmpty($data['plot']); + $this->assertGreaterThan(9.0, (float) $data['rating']); + $this->assertSame('1994', $data['year']); + $this->assertNotEmpty($data['cover']); + $this->assertStringContainsString('Drama', $data['genre']); + $this->assertContains('Tim Robbins', $data['actors']); + $this->assertContains('Morgan Freeman', $data['actors']); + $this->assertContains('Frank Darabont', $data['director']); + } + + /** + * Test searching for movies on live IMDb site. + * + * @group integration + * @group slow + */ + public function test_search_inception_on_live_site(): void + { + $scraper = new ImdbScraper; + $results = $scraper->search('Inception'); + + $this->assertIsArray($results); + $this->assertNotEmpty($results); + + // Find the 2010 Inception movie in results + $found = false; + foreach ($results as $result) { + if ($result['title'] === 'Inception' && $result['year'] === '2010') { + $found = true; + $this->assertSame('1375666', $result['imdbid']); + break; + } + } + + $this->assertTrue($found, 'Inception (2010) should be found in search results'); + } + + /** + * Test that invalid IMDb IDs return false. + * + * @group integration + * @group slow + */ + public function test_fetch_invalid_id_returns_false(): void + { + $scraper = new ImdbScraper; + $data = $scraper->fetchById('9999999'); + + $this->assertFalse($data); + } + + /** + * Test caching works correctly on live requests. + * + * @group integration + * @group slow + */ + public function test_caching_reduces_network_calls(): void + { + $scraper = new ImdbScraper; + + // First call - should hit the network + $start1 = microtime(true); + $data1 = $scraper->fetchById('0111161'); + $time1 = microtime(true) - $start1; + + $this->assertIsArray($data1); + + // Second call - should be cached (much faster) + $start2 = microtime(true); + $data2 = $scraper->fetchById('0111161'); + $time2 = microtime(true) - $start2; + + $this->assertIsArray($data2); + $this->assertSame($data1['title'], $data2['title']); + + // Cached call should be at least 10x faster + $this->assertLessThan($time1 / 10, $time2, 'Cached call should be significantly faster'); + } +} diff --git a/tests/Unit/ImdbScraperRealDataTest.php b/tests/Unit/ImdbScraperRealDataTest.php new file mode 100644 index 000000000..1b7640af8 --- /dev/null +++ b/tests/Unit/ImdbScraperRealDataTest.php @@ -0,0 +1,169 @@ + 'array']); + } + + public function test_parse_shawshank_redemption_real_data(): void + { + $html = file_get_contents(base_path('tests/Fixtures/imdb/shawshank_redemption.html')); + $scraper = new ImdbScraper; + $data = $scraper->parseForTest($html, '0111161'); + + // Assert core movie data + $this->assertIsArray($data); + $this->assertSame('The Shawshank Redemption', $data['title']); + $this->assertStringContainsString('banker convicted', $data['plot']); + $this->assertStringContainsString('friendship', $data['plot']); + $this->assertSame('9.3', $data['rating']); + $this->assertSame('1994', $data['year']); + + // Assert cover image + $this->assertStringContainsString('media-amazon.com', $data['cover']); + $this->assertStringContainsString('.jpg', $data['cover']); + + // Assert genre + $this->assertStringContainsString('Drama', $data['genre']); + + // Assert actors (from JSON-LD) + $this->assertIsArray($data['actors']); + $this->assertCount(3, $data['actors']); + $this->assertContains('Tim Robbins', $data['actors']); + $this->assertContains('Morgan Freeman', $data['actors']); + $this->assertContains('Bob Gunton', $data['actors']); + + // Assert director + $this->assertIsArray($data['director']); + $this->assertCount(1, $data['director']); + $this->assertContains('Frank Darabont', $data['director']); + + // Assert tagline (from DOM) + $this->assertSame('Fear can hold you prisoner. Hope can set you free.', $data['tagline']); + + // Assert language (from DOM) + $this->assertSame('English', $data['language']); + + // Assert type + $this->assertSame('Movie', $data['type']); + } + + public function test_parse_handles_missing_optional_fields(): void + { + // Create minimal HTML with only required fields + $minimalHtml = <<<'HTML' + + + + + + + + +HTML; + + $scraper = new ImdbScraper; + $data = $scraper->parseForTest($minimalHtml, '1234567'); + + $this->assertIsArray($data); + $this->assertSame('Test Movie', $data['title']); + $this->assertSame('2020', $data['year']); + + // Optional fields should be empty but not cause errors + $this->assertSame('', $data['plot']); + $this->assertSame('', $data['rating']); + $this->assertSame('', $data['tagline']); + $this->assertSame('', $data['language']); + $this->assertIsArray($data['actors']); + $this->assertEmpty($data['actors']); + } + + public function test_fetch_by_id_validates_format(): void + { + $scraper = new ImdbScraper; + + // Too short + $this->assertFalse($scraper->fetchById('1234')); + + // Too long + $this->assertFalse($scraper->fetchById('123456789')); + + // Non-numeric + $this->assertFalse($scraper->fetchById('abc1234')); + + // Valid format (will fail to fetch, but format is valid) + // We can't test actual network call in unit tests + } + + public function test_search_returns_empty_for_empty_query(): void + { + $scraper = new ImdbScraper; + $this->assertSame([], $scraper->search('')); + $this->assertSame([], $scraper->search(' ')); + } + + public function test_parse_with_og_meta_fallback(): void + { + // HTML where JSON-LD is missing but og:title exists + $html = <<<'HTML' + + + + + + + + +HTML; + + $scraper = new ImdbScraper; + $data = $scraper->parseForTest($html, '9999999'); + + $this->assertIsArray($data); + $this->assertSame('Fallback Movie Title', $data['title']); // " - IMDb" should be stripped + $this->assertSame('https://example.com/poster.jpg', $data['cover']); + } + + public function test_parse_extracts_multiple_genres(): void + { + $html = <<<'HTML' + + + + + + + +HTML; + + $scraper = new ImdbScraper; + $data = $scraper->parseForTest($html, '1234567'); + + $this->assertStringContainsString('Action', $data['genre']); + $this->assertStringContainsString('Drama', $data['genre']); + $this->assertStringContainsString('Thriller', $data['genre']); + } +} diff --git a/tests/Unit/ImdbScraperTest.php b/tests/Unit/ImdbScraperTest.php new file mode 100644 index 000000000..9e3fb8738 --- /dev/null +++ b/tests/Unit/ImdbScraperTest.php @@ -0,0 +1,44 @@ + 'array']); + } + + public function test_parse_title_page_from_fixture(): void + { + $html = file_get_contents(base_path('tests/Fixtures/imdb/example_title.html')); + $scraper = new ImdbScraper; + $data = $scraper->parseForTest($html, '1234567'); + + $this->assertIsArray($data); + $this->assertSame('Example Movie', $data['title']); + $this->assertNotEmpty($data['plot']); // relaxed assertion + $this->assertSame('7.3', $data['rating']); + $this->assertSame('2024', $data['year']); + $this->assertStringContainsString('Action', $data['genre']); + } + + public function test_fetch_by_id_caches_false_on_failure(): void + { + $scraper = new ImdbScraper; + $data = $scraper->fetchById('123'); // invalid length triggers early false + $this->assertFalse($data); + $data2 = $scraper->fetchById('123'); + $this->assertFalse($data2); + } + + public function test_search_empty_returns_empty_array(): void + { + $scraper = new ImdbScraper; + $this->assertSame([], $scraper->search('')); + } +}